{"repo": "camthink-ai/neomind", "files": {"crates/neomind-api/src/handlers/llm_backends.rs": {"3aa27280d1dc3afc38cea7574b839852cef36274": {"t": 1785746228.6844075, "n": 64163, "h": "c5ee5c483284c215", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n let prev = instance.thinking_enabled;\n instance.thinking_enabled = thinking_enabled;\n // Dedicated log so users can verify the toggle took effect.\n // The \"Updated Ollama model capabilities\" log above reports\n // caps.supports_thinking (model capability from /api/show), NOT\n // the user's enable/disable choice — these are different concepts\n // and conflating them causes \"I turned it off but logs say true\"\n // confusion.\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n prev_thinking_enabled = prev,\n new_thinking_enabled = thinking_enabled,\n \"User thinking_enabled setting updated\"\n );\n }\n if let Some(mut capabilities) = req.capabilities {\n // Preserve fields that the frontend doesn't render.\n // The capabilities object sent by the frontend typically doesn't include\n // `multimodal_user_override` or `multimodal_source` (those are managed via\n // the dedicated PATCH /capabilities endpoint). Without this preservation,\n // any user override or runtime_api detection would be silently wiped every\n // time the user saved an unrelated field change like temperature.\n let saved_user_override = instance.capabilities.multimodal_user_override;\n let saved_source = instance.capabilities.multimodal_source.clone();\n let saved_multimodal = instance.capabilities.supports_multimodal;\n // supports_audio is also not rendered by the frontend, so save it\n // before the request body overwrites the whole capabilities struct.\n // The frontend sends a partial capabilities object (typically omitting\n // audio); without this, every unrelated PUT (e.g. temperature change)\n // would silently reset audio capability to its serde default (false)\n // and a backend that the runtime had detected as audio-capable would\n // appear to lose it until the next capability refresh cycle.\n let saved_supports_audio = instance.capabilities.supports_audio;\n\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n\n // Restore preserved fields unless the caller explicitly cleared them\n // (sending `null` for the override). Since the request deserializer\n // can't distinguish \"absent\" from \"explicit null\" for an Option,\n // we always restore — the dedicated PATCH endpoint is the only way to\n // clear an override.\n //\n // Priority: user_override > runtime_api > name-based detection.\n // The frontend doesn't send multimodal_source, so adjust_capabilities_for_model\n // would overwrite a correct runtime_api detection with a less-accurate\n // name-based guess (e.g. qwen3.5 correctly detected by Ollama /api/show as\n // multimodal, but missed by both registry and heuristic). We must restore the\n // runtime_api source and its associated multimodal value.\n if capabilities.multimodal_user_override.is_none() {\n capabilities.multimodal_user_override = saved_user_override;\n if let Some(override_val) = saved_user_override {\n // Override restored → all three fields must reflect it:\n // supports_multimodal (effective value), source (provenance),\n // and multimodal_user_override (already set above). Without\n // syncing supports_multimodal, the runtime would read the\n // auto-detected value and ignore the user's override.\n capabilities.supports_multimodal = override_val;\n capabilities.multimodal_source = Some(\"user_override\".to_string());\n } else if saved_source.as_deref() == Some(\"runtime_api\") {\n // Runtime API detection (Ollama /api/show) is authoritative —\n // restore both the value and source so the correct detection\n // from the third-party backend isn't lost.\n capabilities.supports_multimodal = saved_multimodal;\n capabilities.multimodal_source = saved_source;\n }\n // else: leave source alone — adjust_capabilities_for_model set it.\n }\n\n instance.capabilities = capabilities;\n // Restore supports_audio — the frontend doesn't render an audio toggle\n // and the request body (deserialized as a whole capabilities struct)\n // would otherwise leave this at the serde default (false). The\n // centralized capability detector / runtime refresh loop is the only\n // path that should re-author this field.\n //\n // TODO(audio-override): once a PATCH `/capabilities` audio field is\n // added (symmetric with `multimodal`), this restore must switch to\n // the same conditional pattern used for `multimodal_user_override`\n // above — otherwise the restore would silently drop an audio user\n // override that the frontend finally learned how to send. Audio has\n // no override surface today (plumbing-only); see review Minor 1.\n instance.capabilities.supports_audio = saved_supports_audio;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Request body for the capabilities-override PATCH endpoint.\n///\n/// All fields are optional. `multimodal: null` clears the override (returns\n/// to auto-detection); `multimodal: true/false` pins the value.\n#[derive(Debug, Deserialize)]\npub struct UpdateCapabilitiesOverrideRequest {\n /// Override the multimodal/vision capability.\n ///\n /// - `Some(true)`/`Some(false)` — pin the value, skip auto-detection.\n /// - `None` — clear the override, resume auto-detection.\n ///\n /// JSON `null` and field-absence both deserialize to `None` (serde's\n /// default behavior for `Option`).\n #[serde(default)]\n pub multimodal: Option,\n}\n\n/// Set or clear a user override on backend capabilities.\n///\n/// `PATCH /api/llm-backends/:id/capabilities`\n///\n/// Currently only the `multimodal` (vision) capability is overridable — this\n/// is the main source of false positives in auto-detection, and is the field\n/// users most commonly need to correct manually.\npub async fn update_capabilities_override_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Apply override.\n instance.capabilities.multimodal_user_override = req.multimodal;\n if let Some(v) = req.multimodal {\n instance.capabilities.supports_multimodal = v;\n instance.capabilities.multimodal_source = Some(\"user_override\".to_string());\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n override_value = v,\n \"User set multimodal override\"\n );\n } else {\n // Override cleared — re-run layered detection immediately so the\n // stored value reflects the auto-detected truth.\n let detected = neomind_core::llm::detect_vision_capability(&instance.model);\n // Source attribution: 3-tier (registry / heuristic / unknown).\n // Use the same logic as `adjust_capabilities_for_model` so the source\n // is consistent across creation, update, and override-clear paths.\n let source: Option<&str> =\n if neomind_core::llm::registry::lookup_vision(&instance.model).is_some() {\n Some(\"registry\")\n } else if neomind_core::llm::registry::heuristic_vision_match(&instance.model) {\n Some(\"heuristic\")\n } else {\n None\n };\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n detected = detected,\n source = ?source,\n \"User cleared multimodal override, re-detected\"\n );\n instance.capabilities.supports_multimodal = detected;\n instance.capabilities.multimodal_source = source.map(str::to_string);\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"supports_multimodal\": instance.capabilities.supports_multimodal,\n \"multimodal_user_override\": instance.capabilities.multimodal_user_override,\n \"multimodal_source\": instance.capabilities.multimodal_source,\n \"message\": \"Capabilities override applied\",\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: storage_caps.supports_audio,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler (temp file cleanup)\n if let Err(e) = state.agents.start_memory_scheduler().await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n // Track that this value came from a live runtime probe, so that\n // `ensure_instance_capabilities` doesn't second-guess it with the\n // less-accurate name-based heuristic on next load.\n multimodal_source: Some(\"runtime_api\".to_string()),\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n // Source attribution matches `adjust_capabilities_for_model`.\n let multimodal_source = if neomind_core::llm::registry::lookup_vision(model_name).is_some() {\n Some(\"registry\".to_string())\n } else if neomind_core::llm::registry::heuristic_vision_match(model_name) {\n Some(\"heuristic\".to_string())\n } else {\n None\n };\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n multimodal_source,\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // GLM models (智谱)\n if name_lower.contains(\"glm\") {\n return 128000;\n }\n\n // MiniMax models\n if name_lower.contains(\"abab\") || name_lower.contains(\"minimax\") {\n return 512000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 128000\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n ..Default::default()\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n ..Default::default()\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n ..Default::default()\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n ..Default::default()\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n ..Default::default()\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n ..Default::default()\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n ..Default::default()\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection.\n // Also record provenance so the frontend and diagnostics can show WHERE\n // the value came from. We do NOT touch `multimodal_user_override` here —\n // that field is sacred and only set by the explicit override endpoint.\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n if capabilities.multimodal_user_override.is_none() {\n // Determine source: registry hit vs heuristic vs unknown.\n // We can't tell \"registry\" from \"heuristic\" from this call site without\n // re-running the layered lookup, so we use the more specific helper.\n if neomind_core::llm::registry::lookup_vision(&name_lower).is_some() {\n capabilities.multimodal_source = Some(\"registry\".to_string());\n } else if neomind_core::llm::registry::heuristic_vision_match(&name_lower) {\n capabilities.multimodal_source = Some(\"heuristic\".to_string());\n } else {\n capabilities.multimodal_source = None;\n }\n }\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models).\n // NOTE: do NOT couple this to `supports_multimodal` — modern multimodal\n // models (gpt-4o, qwen3.5-vl, gemini-2.0-flash-thinking, claude-opus-4)\n // support both vision and thinking. The old \"vision blocks thinking\" rule\n // was correct for 2024-era llava-class models but is now stale; users can\n // always override via PATCH /capabilities.\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\");\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n #[serde(default)]\n audio: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name = path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n // Audio from modalities field (llama.cpp server reports\n // `modalities.audio` when the loaded model has an audio\n // projector, e.g. Qwen2-Audio / Ultravox).\n if let Some(audio) = props.modalities.as_ref().and_then(|m| m.audio) {\n capabilities.supports_audio = audio;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "1fe6886a928c3634d237be1054a23f77b18e174a": {"t": 1785760836.9858935, "n": 64163, "h": "c5ee5c483284c215", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n let prev = instance.thinking_enabled;\n instance.thinking_enabled = thinking_enabled;\n // Dedicated log so users can verify the toggle took effect.\n // The \"Updated Ollama model capabilities\" log above reports\n // caps.supports_thinking (model capability from /api/show), NOT\n // the user's enable/disable choice — these are different concepts\n // and conflating them causes \"I turned it off but logs say true\"\n // confusion.\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n prev_thinking_enabled = prev,\n new_thinking_enabled = thinking_enabled,\n \"User thinking_enabled setting updated\"\n );\n }\n if let Some(mut capabilities) = req.capabilities {\n // Preserve fields that the frontend doesn't render.\n // The capabilities object sent by the frontend typically doesn't include\n // `multimodal_user_override` or `multimodal_source` (those are managed via\n // the dedicated PATCH /capabilities endpoint). Without this preservation,\n // any user override or runtime_api detection would be silently wiped every\n // time the user saved an unrelated field change like temperature.\n let saved_user_override = instance.capabilities.multimodal_user_override;\n let saved_source = instance.capabilities.multimodal_source.clone();\n let saved_multimodal = instance.capabilities.supports_multimodal;\n // supports_audio is also not rendered by the frontend, so save it\n // before the request body overwrites the whole capabilities struct.\n // The frontend sends a partial capabilities object (typically omitting\n // audio); without this, every unrelated PUT (e.g. temperature change)\n // would silently reset audio capability to its serde default (false)\n // and a backend that the runtime had detected as audio-capable would\n // appear to lose it until the next capability refresh cycle.\n let saved_supports_audio = instance.capabilities.supports_audio;\n\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n\n // Restore preserved fields unless the caller explicitly cleared them\n // (sending `null` for the override). Since the request deserializer\n // can't distinguish \"absent\" from \"explicit null\" for an Option,\n // we always restore — the dedicated PATCH endpoint is the only way to\n // clear an override.\n //\n // Priority: user_override > runtime_api > name-based detection.\n // The frontend doesn't send multimodal_source, so adjust_capabilities_for_model\n // would overwrite a correct runtime_api detection with a less-accurate\n // name-based guess (e.g. qwen3.5 correctly detected by Ollama /api/show as\n // multimodal, but missed by both registry and heuristic). We must restore the\n // runtime_api source and its associated multimodal value.\n if capabilities.multimodal_user_override.is_none() {\n capabilities.multimodal_user_override = saved_user_override;\n if let Some(override_val) = saved_user_override {\n // Override restored → all three fields must reflect it:\n // supports_multimodal (effective value), source (provenance),\n // and multimodal_user_override (already set above). Without\n // syncing supports_multimodal, the runtime would read the\n // auto-detected value and ignore the user's override.\n capabilities.supports_multimodal = override_val;\n capabilities.multimodal_source = Some(\"user_override\".to_string());\n } else if saved_source.as_deref() == Some(\"runtime_api\") {\n // Runtime API detection (Ollama /api/show) is authoritative —\n // restore both the value and source so the correct detection\n // from the third-party backend isn't lost.\n capabilities.supports_multimodal = saved_multimodal;\n capabilities.multimodal_source = saved_source;\n }\n // else: leave source alone — adjust_capabilities_for_model set it.\n }\n\n instance.capabilities = capabilities;\n // Restore supports_audio — the frontend doesn't render an audio toggle\n // and the request body (deserialized as a whole capabilities struct)\n // would otherwise leave this at the serde default (false). The\n // centralized capability detector / runtime refresh loop is the only\n // path that should re-author this field.\n //\n // TODO(audio-override): once a PATCH `/capabilities` audio field is\n // added (symmetric with `multimodal`), this restore must switch to\n // the same conditional pattern used for `multimodal_user_override`\n // above — otherwise the restore would silently drop an audio user\n // override that the frontend finally learned how to send. Audio has\n // no override surface today (plumbing-only); see review Minor 1.\n instance.capabilities.supports_audio = saved_supports_audio;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Request body for the capabilities-override PATCH endpoint.\n///\n/// All fields are optional. `multimodal: null` clears the override (returns\n/// to auto-detection); `multimodal: true/false` pins the value.\n#[derive(Debug, Deserialize)]\npub struct UpdateCapabilitiesOverrideRequest {\n /// Override the multimodal/vision capability.\n ///\n /// - `Some(true)`/`Some(false)` — pin the value, skip auto-detection.\n /// - `None` — clear the override, resume auto-detection.\n ///\n /// JSON `null` and field-absence both deserialize to `None` (serde's\n /// default behavior for `Option`).\n #[serde(default)]\n pub multimodal: Option,\n}\n\n/// Set or clear a user override on backend capabilities.\n///\n/// `PATCH /api/llm-backends/:id/capabilities`\n///\n/// Currently only the `multimodal` (vision) capability is overridable — this\n/// is the main source of false positives in auto-detection, and is the field\n/// users most commonly need to correct manually.\npub async fn update_capabilities_override_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Apply override.\n instance.capabilities.multimodal_user_override = req.multimodal;\n if let Some(v) = req.multimodal {\n instance.capabilities.supports_multimodal = v;\n instance.capabilities.multimodal_source = Some(\"user_override\".to_string());\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n override_value = v,\n \"User set multimodal override\"\n );\n } else {\n // Override cleared — re-run layered detection immediately so the\n // stored value reflects the auto-detected truth.\n let detected = neomind_core::llm::detect_vision_capability(&instance.model);\n // Source attribution: 3-tier (registry / heuristic / unknown).\n // Use the same logic as `adjust_capabilities_for_model` so the source\n // is consistent across creation, update, and override-clear paths.\n let source: Option<&str> =\n if neomind_core::llm::registry::lookup_vision(&instance.model).is_some() {\n Some(\"registry\")\n } else if neomind_core::llm::registry::heuristic_vision_match(&instance.model) {\n Some(\"heuristic\")\n } else {\n None\n };\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n detected = detected,\n source = ?source,\n \"User cleared multimodal override, re-detected\"\n );\n instance.capabilities.supports_multimodal = detected;\n instance.capabilities.multimodal_source = source.map(str::to_string);\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"supports_multimodal\": instance.capabilities.supports_multimodal,\n \"multimodal_user_override\": instance.capabilities.multimodal_user_override,\n \"multimodal_source\": instance.capabilities.multimodal_source,\n \"message\": \"Capabilities override applied\",\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: storage_caps.supports_audio,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler (temp file cleanup)\n if let Err(e) = state.agents.start_memory_scheduler().await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n // Track that this value came from a live runtime probe, so that\n // `ensure_instance_capabilities` doesn't second-guess it with the\n // less-accurate name-based heuristic on next load.\n multimodal_source: Some(\"runtime_api\".to_string()),\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n // Source attribution matches `adjust_capabilities_for_model`.\n let multimodal_source = if neomind_core::llm::registry::lookup_vision(model_name).is_some() {\n Some(\"registry\".to_string())\n } else if neomind_core::llm::registry::heuristic_vision_match(model_name) {\n Some(\"heuristic\".to_string())\n } else {\n None\n };\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n multimodal_source,\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // GLM models (智谱)\n if name_lower.contains(\"glm\") {\n return 128000;\n }\n\n // MiniMax models\n if name_lower.contains(\"abab\") || name_lower.contains(\"minimax\") {\n return 512000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 128000\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n ..Default::default()\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n ..Default::default()\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n ..Default::default()\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n ..Default::default()\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n ..Default::default()\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n ..Default::default()\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n ..Default::default()\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection.\n // Also record provenance so the frontend and diagnostics can show WHERE\n // the value came from. We do NOT touch `multimodal_user_override` here —\n // that field is sacred and only set by the explicit override endpoint.\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n if capabilities.multimodal_user_override.is_none() {\n // Determine source: registry hit vs heuristic vs unknown.\n // We can't tell \"registry\" from \"heuristic\" from this call site without\n // re-running the layered lookup, so we use the more specific helper.\n if neomind_core::llm::registry::lookup_vision(&name_lower).is_some() {\n capabilities.multimodal_source = Some(\"registry\".to_string());\n } else if neomind_core::llm::registry::heuristic_vision_match(&name_lower) {\n capabilities.multimodal_source = Some(\"heuristic\".to_string());\n } else {\n capabilities.multimodal_source = None;\n }\n }\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models).\n // NOTE: do NOT couple this to `supports_multimodal` — modern multimodal\n // models (gpt-4o, qwen3.5-vl, gemini-2.0-flash-thinking, claude-opus-4)\n // support both vision and thinking. The old \"vision blocks thinking\" rule\n // was correct for 2024-era llava-class models but is now stale; users can\n // always override via PATCH /capabilities.\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\");\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n #[serde(default)]\n audio: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name = path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n // Audio from modalities field (llama.cpp server reports\n // `modalities.audio` when the loaded model has an audio\n // projector, e.g. Qwen2-Audio / Ultravox).\n if let Some(audio) = props.modalities.as_ref().and_then(|m| m.audio) {\n capabilities.supports_audio = audio;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "1e0f978f8eacd4a44700cc54ea7bf6c20562b4b9": {"t": 1785760838.6950455, "n": 63486, "h": "02c11470b557bc56", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Preserve fields that the frontend doesn't render.\n // The capabilities object sent by the frontend typically doesn't include\n // `multimodal_user_override` or `multimodal_source` (those are managed via\n // the dedicated PATCH /capabilities endpoint). Without this preservation,\n // any user override or runtime_api detection would be silently wiped every\n // time the user saved an unrelated field change like temperature.\n let saved_user_override = instance.capabilities.multimodal_user_override;\n let saved_source = instance.capabilities.multimodal_source.clone();\n let saved_multimodal = instance.capabilities.supports_multimodal;\n // supports_audio is also not rendered by the frontend, so save it\n // before the request body overwrites the whole capabilities struct.\n // The frontend sends a partial capabilities object (typically omitting\n // audio); without this, every unrelated PUT (e.g. temperature change)\n // would silently reset audio capability to its serde default (false)\n // and a backend that the runtime had detected as audio-capable would\n // appear to lose it until the next capability refresh cycle.\n let saved_supports_audio = instance.capabilities.supports_audio;\n\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n\n // Restore preserved fields unless the caller explicitly cleared them\n // (sending `null` for the override). Since the request deserializer\n // can't distinguish \"absent\" from \"explicit null\" for an Option,\n // we always restore — the dedicated PATCH endpoint is the only way to\n // clear an override.\n //\n // Priority: user_override > runtime_api > name-based detection.\n // The frontend doesn't send multimodal_source, so adjust_capabilities_for_model\n // would overwrite a correct runtime_api detection with a less-accurate\n // name-based guess (e.g. qwen3.5 correctly detected by Ollama /api/show as\n // multimodal, but missed by both registry and heuristic). We must restore the\n // runtime_api source and its associated multimodal value.\n if capabilities.multimodal_user_override.is_none() {\n capabilities.multimodal_user_override = saved_user_override;\n if let Some(override_val) = saved_user_override {\n // Override restored → all three fields must reflect it:\n // supports_multimodal (effective value), source (provenance),\n // and multimodal_user_override (already set above). Without\n // syncing supports_multimodal, the runtime would read the\n // auto-detected value and ignore the user's override.\n capabilities.supports_multimodal = override_val;\n capabilities.multimodal_source = Some(\"user_override\".to_string());\n } else if saved_source.as_deref() == Some(\"runtime_api\") {\n // Runtime API detection (Ollama /api/show) is authoritative —\n // restore both the value and source so the correct detection\n // from the third-party backend isn't lost.\n capabilities.supports_multimodal = saved_multimodal;\n capabilities.multimodal_source = saved_source;\n }\n // else: leave source alone — adjust_capabilities_for_model set it.\n }\n\n instance.capabilities = capabilities;\n // Restore supports_audio — the frontend doesn't render an audio toggle\n // and the request body (deserialized as a whole capabilities struct)\n // would otherwise leave this at the serde default (false). The\n // centralized capability detector / runtime refresh loop is the only\n // path that should re-author this field.\n //\n // TODO(audio-override): once a PATCH `/capabilities` audio field is\n // added (symmetric with `multimodal`), this restore must switch to\n // the same conditional pattern used for `multimodal_user_override`\n // above — otherwise the restore would silently drop an audio user\n // override that the frontend finally learned how to send. Audio has\n // no override surface today (plumbing-only); see review Minor 1.\n instance.capabilities.supports_audio = saved_supports_audio;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Request body for the capabilities-override PATCH endpoint.\n///\n/// All fields are optional. `multimodal: null` clears the override (returns\n/// to auto-detection); `multimodal: true/false` pins the value.\n#[derive(Debug, Deserialize)]\npub struct UpdateCapabilitiesOverrideRequest {\n /// Override the multimodal/vision capability.\n ///\n /// - `Some(true)`/`Some(false)` — pin the value, skip auto-detection.\n /// - `None` — clear the override, resume auto-detection.\n ///\n /// JSON `null` and field-absence both deserialize to `None` (serde's\n /// default behavior for `Option`).\n #[serde(default)]\n pub multimodal: Option,\n}\n\n/// Set or clear a user override on backend capabilities.\n///\n/// `PATCH /api/llm-backends/:id/capabilities`\n///\n/// Currently only the `multimodal` (vision) capability is overridable — this\n/// is the main source of false positives in auto-detection, and is the field\n/// users most commonly need to correct manually.\npub async fn update_capabilities_override_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Apply override.\n instance.capabilities.multimodal_user_override = req.multimodal;\n if let Some(v) = req.multimodal {\n instance.capabilities.supports_multimodal = v;\n instance.capabilities.multimodal_source = Some(\"user_override\".to_string());\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n override_value = v,\n \"User set multimodal override\"\n );\n } else {\n // Override cleared — re-run layered detection immediately so the\n // stored value reflects the auto-detected truth.\n let detected = neomind_core::llm::detect_vision_capability(&instance.model);\n // Source attribution: 3-tier (registry / heuristic / unknown).\n // Use the same logic as `adjust_capabilities_for_model` so the source\n // is consistent across creation, update, and override-clear paths.\n let source: Option<&str> =\n if neomind_core::llm::registry::lookup_vision(&instance.model).is_some() {\n Some(\"registry\")\n } else if neomind_core::llm::registry::heuristic_vision_match(&instance.model) {\n Some(\"heuristic\")\n } else {\n None\n };\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n detected = detected,\n source = ?source,\n \"User cleared multimodal override, re-detected\"\n );\n instance.capabilities.supports_multimodal = detected;\n instance.capabilities.multimodal_source = source.map(str::to_string);\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"supports_multimodal\": instance.capabilities.supports_multimodal,\n \"multimodal_user_override\": instance.capabilities.multimodal_user_override,\n \"multimodal_source\": instance.capabilities.multimodal_source,\n \"message\": \"Capabilities override applied\",\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: storage_caps.supports_audio,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler (temp file cleanup)\n if let Err(e) = state.agents.start_memory_scheduler().await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n // Track that this value came from a live runtime probe, so that\n // `ensure_instance_capabilities` doesn't second-guess it with the\n // less-accurate name-based heuristic on next load.\n multimodal_source: Some(\"runtime_api\".to_string()),\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n // Source attribution matches `adjust_capabilities_for_model`.\n let multimodal_source = if neomind_core::llm::registry::lookup_vision(model_name).is_some() {\n Some(\"registry\".to_string())\n } else if neomind_core::llm::registry::heuristic_vision_match(model_name) {\n Some(\"heuristic\".to_string())\n } else {\n None\n };\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n multimodal_source,\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // GLM models (智谱)\n if name_lower.contains(\"glm\") {\n return 128000;\n }\n\n // MiniMax models\n if name_lower.contains(\"abab\") || name_lower.contains(\"minimax\") {\n return 512000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 128000\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n ..Default::default()\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n ..Default::default()\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n ..Default::default()\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n ..Default::default()\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n ..Default::default()\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n ..Default::default()\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n ..Default::default()\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection.\n // Also record provenance so the frontend and diagnostics can show WHERE\n // the value came from. We do NOT touch `multimodal_user_override` here —\n // that field is sacred and only set by the explicit override endpoint.\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n if capabilities.multimodal_user_override.is_none() {\n // Determine source: registry hit vs heuristic vs unknown.\n // We can't tell \"registry\" from \"heuristic\" from this call site without\n // re-running the layered lookup, so we use the more specific helper.\n if neomind_core::llm::registry::lookup_vision(&name_lower).is_some() {\n capabilities.multimodal_source = Some(\"registry\".to_string());\n } else if neomind_core::llm::registry::heuristic_vision_match(&name_lower) {\n capabilities.multimodal_source = Some(\"heuristic\".to_string());\n } else {\n capabilities.multimodal_source = None;\n }\n }\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models).\n // NOTE: do NOT couple this to `supports_multimodal` — modern multimodal\n // models (gpt-4o, qwen3.5-vl, gemini-2.0-flash-thinking, claude-opus-4)\n // support both vision and thinking. The old \"vision blocks thinking\" rule\n // was correct for 2024-era llava-class models but is now stale; users can\n // always override via PATCH /capabilities.\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\");\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n #[serde(default)]\n audio: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name = path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n // Audio from modalities field (llama.cpp server reports\n // `modalities.audio` when the loaded model has an audio\n // projector, e.g. Qwen2-Audio / Ultravox).\n if let Some(audio) = props.modalities.as_ref().and_then(|m| m.audio) {\n capabilities.supports_audio = audio;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "c5d635b7e5e495eb16d052aab7188297bb11f82c": {"t": 1785760840.5509534, "n": 61498, "h": "fa47b27cc17edb78", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Preserve fields that the frontend doesn't render.\n // The capabilities object sent by the frontend typically doesn't include\n // `multimodal_user_override` or `multimodal_source` (those are managed via\n // the dedicated PATCH /capabilities endpoint). Without this preservation,\n // any user override or runtime_api detection would be silently wiped every\n // time the user saved an unrelated field change like temperature.\n let saved_user_override = instance.capabilities.multimodal_user_override;\n let saved_source = instance.capabilities.multimodal_source.clone();\n let saved_multimodal = instance.capabilities.supports_multimodal;\n\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n\n // Restore preserved fields unless the caller explicitly cleared them\n // (sending `null` for the override). Since the request deserializer\n // can't distinguish \"absent\" from \"explicit null\" for an Option,\n // we always restore — the dedicated PATCH endpoint is the only way to\n // clear an override.\n //\n // Priority: user_override > runtime_api > name-based detection.\n // The frontend doesn't send multimodal_source, so adjust_capabilities_for_model\n // would overwrite a correct runtime_api detection with a less-accurate\n // name-based guess (e.g. qwen3.5 correctly detected by Ollama /api/show as\n // multimodal, but missed by both registry and heuristic). We must restore the\n // runtime_api source and its associated multimodal value.\n if capabilities.multimodal_user_override.is_none() {\n capabilities.multimodal_user_override = saved_user_override;\n if let Some(override_val) = saved_user_override {\n // Override restored → all three fields must reflect it:\n // supports_multimodal (effective value), source (provenance),\n // and multimodal_user_override (already set above). Without\n // syncing supports_multimodal, the runtime would read the\n // auto-detected value and ignore the user's override.\n capabilities.supports_multimodal = override_val;\n capabilities.multimodal_source = Some(\"user_override\".to_string());\n } else if saved_source.as_deref() == Some(\"runtime_api\") {\n // Runtime API detection (Ollama /api/show) is authoritative —\n // restore both the value and source so the correct detection\n // from the third-party backend isn't lost.\n capabilities.supports_multimodal = saved_multimodal;\n capabilities.multimodal_source = saved_source;\n }\n // else: leave source alone — adjust_capabilities_for_model set it.\n }\n\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Request body for the capabilities-override PATCH endpoint.\n///\n/// All fields are optional. `multimodal: null` clears the override (returns\n/// to auto-detection); `multimodal: true/false` pins the value.\n#[derive(Debug, Deserialize)]\npub struct UpdateCapabilitiesOverrideRequest {\n /// Override the multimodal/vision capability.\n ///\n /// - `Some(true)`/`Some(false)` — pin the value, skip auto-detection.\n /// - `None` — clear the override, resume auto-detection.\n ///\n /// JSON `null` and field-absence both deserialize to `None` (serde's\n /// default behavior for `Option`).\n #[serde(default)]\n pub multimodal: Option,\n}\n\n/// Set or clear a user override on backend capabilities.\n///\n/// `PATCH /api/llm-backends/:id/capabilities`\n///\n/// Currently only the `multimodal` (vision) capability is overridable — this\n/// is the main source of false positives in auto-detection, and is the field\n/// users most commonly need to correct manually.\npub async fn update_capabilities_override_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Apply override.\n instance.capabilities.multimodal_user_override = req.multimodal;\n if let Some(v) = req.multimodal {\n instance.capabilities.supports_multimodal = v;\n instance.capabilities.multimodal_source = Some(\"user_override\".to_string());\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n override_value = v,\n \"User set multimodal override\"\n );\n } else {\n // Override cleared — re-run layered detection immediately so the\n // stored value reflects the auto-detected truth.\n let detected = neomind_core::llm::detect_vision_capability(&instance.model);\n // Source attribution: 3-tier (registry / heuristic / unknown).\n // Use the same logic as `adjust_capabilities_for_model` so the source\n // is consistent across creation, update, and override-clear paths.\n let source: Option<&str> =\n if neomind_core::llm::registry::lookup_vision(&instance.model).is_some() {\n Some(\"registry\")\n } else if neomind_core::llm::registry::heuristic_vision_match(&instance.model) {\n Some(\"heuristic\")\n } else {\n None\n };\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n detected = detected,\n source = ?source,\n \"User cleared multimodal override, re-detected\"\n );\n instance.capabilities.supports_multimodal = detected;\n instance.capabilities.multimodal_source = source.map(str::to_string);\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"supports_multimodal\": instance.capabilities.supports_multimodal,\n \"multimodal_user_override\": instance.capabilities.multimodal_user_override,\n \"multimodal_source\": instance.capabilities.multimodal_source,\n \"message\": \"Capabilities override applied\",\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler (temp file cleanup)\n if let Err(e) = state.agents.start_memory_scheduler().await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n // Track that this value came from a live runtime probe, so that\n // `ensure_instance_capabilities` doesn't second-guess it with the\n // less-accurate name-based heuristic on next load.\n multimodal_source: Some(\"runtime_api\".to_string()),\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n // Source attribution matches `adjust_capabilities_for_model`.\n let multimodal_source = if neomind_core::llm::registry::lookup_vision(model_name).is_some() {\n Some(\"registry\".to_string())\n } else if neomind_core::llm::registry::heuristic_vision_match(model_name) {\n Some(\"heuristic\".to_string())\n } else {\n None\n };\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n multimodal_source,\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // GLM models (智谱)\n if name_lower.contains(\"glm\") {\n return 128000;\n }\n\n // MiniMax models\n if name_lower.contains(\"abab\") || name_lower.contains(\"minimax\") {\n return 512000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 128000\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n ..Default::default()\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n ..Default::default()\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n ..Default::default()\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n ..Default::default()\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n ..Default::default()\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n ..Default::default()\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n ..Default::default()\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection.\n // Also record provenance so the frontend and diagnostics can show WHERE\n // the value came from. We do NOT touch `multimodal_user_override` here —\n // that field is sacred and only set by the explicit override endpoint.\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n if capabilities.multimodal_user_override.is_none() {\n // Determine source: registry hit vs heuristic vs unknown.\n // We can't tell \"registry\" from \"heuristic\" from this call site without\n // re-running the layered lookup, so we use the more specific helper.\n if neomind_core::llm::registry::lookup_vision(&name_lower).is_some() {\n capabilities.multimodal_source = Some(\"registry\".to_string());\n } else if neomind_core::llm::registry::heuristic_vision_match(&name_lower) {\n capabilities.multimodal_source = Some(\"heuristic\".to_string());\n } else {\n capabilities.multimodal_source = None;\n }\n }\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models).\n // NOTE: do NOT couple this to `supports_multimodal` — modern multimodal\n // models (gpt-4o, qwen3.5-vl, gemini-2.0-flash-thinking, claude-opus-4)\n // support both vision and thinking. The old \"vision blocks thinking\" rule\n // was correct for 2024-era llava-class models but is now stale; users can\n // always override via PATCH /capabilities.\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\");\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name = path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "daef86006a0de6636b823006dddfbeb9e169001d": {"t": 1785760842.48887, "n": 61234, "h": "2ef0f22ea587fb0d", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Preserve fields that the frontend doesn't render.\n // The capabilities object sent by the frontend typically doesn't include\n // `multimodal_user_override` or `multimodal_source` (those are managed via\n // the dedicated PATCH /capabilities endpoint). Without this preservation,\n // any user override or runtime_api detection would be silently wiped every\n // time the user saved an unrelated field change like temperature.\n let saved_user_override = instance.capabilities.multimodal_user_override;\n let saved_source = instance.capabilities.multimodal_source.clone();\n let saved_multimodal = instance.capabilities.supports_multimodal;\n\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n\n // Restore preserved fields unless the caller explicitly cleared them\n // (sending `null` for the override). Since the request deserializer\n // can't distinguish \"absent\" from \"explicit null\" for an Option,\n // we always restore — the dedicated PATCH endpoint is the only way to\n // clear an override.\n //\n // Priority: user_override > runtime_api > name-based detection.\n // The frontend doesn't send multimodal_source, so adjust_capabilities_for_model\n // would overwrite a correct runtime_api detection with a less-accurate\n // name-based guess (e.g. qwen3.5 correctly detected by Ollama /api/show as\n // multimodal, but missed by both registry and heuristic). We must restore the\n // runtime_api source and its associated multimodal value.\n if capabilities.multimodal_user_override.is_none() {\n capabilities.multimodal_user_override = saved_user_override;\n if let Some(override_val) = saved_user_override {\n // Override restored → all three fields must reflect it:\n // supports_multimodal (effective value), source (provenance),\n // and multimodal_user_override (already set above). Without\n // syncing supports_multimodal, the runtime would read the\n // auto-detected value and ignore the user's override.\n capabilities.supports_multimodal = override_val;\n capabilities.multimodal_source = Some(\"user_override\".to_string());\n } else if saved_source.as_deref() == Some(\"runtime_api\") {\n // Runtime API detection (Ollama /api/show) is authoritative —\n // restore both the value and source so the correct detection\n // from the third-party backend isn't lost.\n capabilities.supports_multimodal = saved_multimodal;\n capabilities.multimodal_source = saved_source;\n }\n // else: leave source alone — adjust_capabilities_for_model set it.\n }\n\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Request body for the capabilities-override PATCH endpoint.\n///\n/// All fields are optional. `multimodal: null` clears the override (returns\n/// to auto-detection); `multimodal: true/false` pins the value.\n#[derive(Debug, Deserialize)]\npub struct UpdateCapabilitiesOverrideRequest {\n /// Override the multimodal/vision capability.\n ///\n /// - `Some(true)`/`Some(false)` — pin the value, skip auto-detection.\n /// - `None` — clear the override, resume auto-detection.\n ///\n /// JSON `null` and field-absence both deserialize to `None` (serde's\n /// default behavior for `Option`).\n #[serde(default)]\n pub multimodal: Option,\n}\n\n/// Set or clear a user override on backend capabilities.\n///\n/// `PATCH /api/llm-backends/:id/capabilities`\n///\n/// Currently only the `multimodal` (vision) capability is overridable — this\n/// is the main source of false positives in auto-detection, and is the field\n/// users most commonly need to correct manually.\npub async fn update_capabilities_override_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Apply override.\n instance.capabilities.multimodal_user_override = req.multimodal;\n if let Some(v) = req.multimodal {\n instance.capabilities.supports_multimodal = v;\n instance.capabilities.multimodal_source = Some(\"user_override\".to_string());\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n override_value = v,\n \"User set multimodal override\"\n );\n } else {\n // Override cleared — re-run layered detection immediately so the\n // stored value reflects the auto-detected truth.\n let detected = neomind_core::llm::detect_vision_capability(&instance.model);\n // Source attribution: 3-tier (registry / heuristic / unknown).\n // Use the same logic as `adjust_capabilities_for_model` so the source\n // is consistent across creation, update, and override-clear paths.\n let source: Option<&str> =\n if neomind_core::llm::registry::lookup_vision(&instance.model).is_some() {\n Some(\"registry\")\n } else if neomind_core::llm::registry::heuristic_vision_match(&instance.model) {\n Some(\"heuristic\")\n } else {\n None\n };\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n detected = detected,\n source = ?source,\n \"User cleared multimodal override, re-detected\"\n );\n instance.capabilities.supports_multimodal = detected;\n instance.capabilities.multimodal_source = source.map(str::to_string);\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"supports_multimodal\": instance.capabilities.supports_multimodal,\n \"multimodal_user_override\": instance.capabilities.multimodal_user_override,\n \"multimodal_source\": instance.capabilities.multimodal_source,\n \"message\": \"Capabilities override applied\",\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler (temp file cleanup)\n if let Err(e) = state.agents.start_memory_scheduler().await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n // Track that this value came from a live runtime probe, so that\n // `ensure_instance_capabilities` doesn't second-guess it with the\n // less-accurate name-based heuristic on next load.\n multimodal_source: Some(\"runtime_api\".to_string()),\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n // Source attribution matches `adjust_capabilities_for_model`.\n let multimodal_source = if neomind_core::llm::registry::lookup_vision(model_name).is_some() {\n Some(\"registry\".to_string())\n } else if neomind_core::llm::registry::heuristic_vision_match(model_name) {\n Some(\"heuristic\".to_string())\n } else {\n None\n };\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n multimodal_source,\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // GLM models (智谱)\n if name_lower.contains(\"glm\") {\n return 128000;\n }\n\n // MiniMax models\n if name_lower.contains(\"abab\") || name_lower.contains(\"minimax\") {\n return 512000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 128000\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n ..Default::default()\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n ..Default::default()\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n ..Default::default()\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n ..Default::default()\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n ..Default::default()\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n ..Default::default()\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n ..Default::default()\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection.\n // Also record provenance so the frontend and diagnostics can show WHERE\n // the value came from. We do NOT touch `multimodal_user_override` here —\n // that field is sacred and only set by the explicit override endpoint.\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n if capabilities.multimodal_user_override.is_none() {\n // Determine source: registry hit vs heuristic vs unknown.\n // We can't tell \"registry\" from \"heuristic\" from this call site without\n // re-running the layered lookup, so we use the more specific helper.\n if neomind_core::llm::registry::lookup_vision(&name_lower).is_some() {\n capabilities.multimodal_source = Some(\"registry\".to_string());\n } else if neomind_core::llm::registry::heuristic_vision_match(&name_lower) {\n capabilities.multimodal_source = Some(\"heuristic\".to_string());\n } else {\n capabilities.multimodal_source = None;\n }\n }\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name = path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "c1fd8acbd285d6a7b57adb4fb1c251f831ad4ec9": {"t": 1785760846.0963511, "n": 61198, "h": "05ad6e0870052575", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Preserve fields that the frontend doesn't render.\n // The capabilities object sent by the frontend typically doesn't include\n // `multimodal_user_override` or `multimodal_source` (those are managed via\n // the dedicated PATCH /capabilities endpoint). Without this preservation,\n // any user override or runtime_api detection would be silently wiped every\n // time the user saved an unrelated field change like temperature.\n let saved_user_override = instance.capabilities.multimodal_user_override;\n let saved_source = instance.capabilities.multimodal_source.clone();\n let saved_multimodal = instance.capabilities.supports_multimodal;\n\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n\n // Restore preserved fields unless the caller explicitly cleared them\n // (sending `null` for the override). Since the request deserializer\n // can't distinguish \"absent\" from \"explicit null\" for an Option,\n // we always restore — the dedicated PATCH endpoint is the only way to\n // clear an override.\n //\n // Priority: user_override > runtime_api > name-based detection.\n // The frontend doesn't send multimodal_source, so adjust_capabilities_for_model\n // would overwrite a correct runtime_api detection with a less-accurate\n // name-based guess (e.g. qwen3.5 correctly detected by Ollama /api/show as\n // multimodal, but missed by both registry and heuristic). We must restore the\n // runtime_api source and its associated multimodal value.\n if capabilities.multimodal_user_override.is_none() {\n capabilities.multimodal_user_override = saved_user_override;\n if let Some(override_val) = saved_user_override {\n // Override restored → all three fields must reflect it:\n // supports_multimodal (effective value), source (provenance),\n // and multimodal_user_override (already set above). Without\n // syncing supports_multimodal, the runtime would read the\n // auto-detected value and ignore the user's override.\n capabilities.supports_multimodal = override_val;\n capabilities.multimodal_source = Some(\"user_override\".to_string());\n } else if saved_source.as_deref() == Some(\"runtime_api\") {\n // Runtime API detection (Ollama /api/show) is authoritative —\n // restore both the value and source so the correct detection\n // from the third-party backend isn't lost.\n capabilities.supports_multimodal = saved_multimodal;\n capabilities.multimodal_source = saved_source;\n }\n // else: leave source alone — adjust_capabilities_for_model set it.\n }\n\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Request body for the capabilities-override PATCH endpoint.\n///\n/// All fields are optional. `multimodal: null` clears the override (returns\n/// to auto-detection); `multimodal: true/false` pins the value.\n#[derive(Debug, Deserialize)]\npub struct UpdateCapabilitiesOverrideRequest {\n /// Override the multimodal/vision capability.\n ///\n /// - `Some(true)`/`Some(false)` — pin the value, skip auto-detection.\n /// - `None` — clear the override, resume auto-detection.\n ///\n /// JSON `null` and field-absence both deserialize to `None` (serde's\n /// default behavior for `Option`).\n #[serde(default)]\n pub multimodal: Option,\n}\n\n/// Set or clear a user override on backend capabilities.\n///\n/// `PATCH /api/llm-backends/:id/capabilities`\n///\n/// Currently only the `multimodal` (vision) capability is overridable — this\n/// is the main source of false positives in auto-detection, and is the field\n/// users most commonly need to correct manually.\npub async fn update_capabilities_override_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Apply override.\n instance.capabilities.multimodal_user_override = req.multimodal;\n if let Some(v) = req.multimodal {\n instance.capabilities.supports_multimodal = v;\n instance.capabilities.multimodal_source = Some(\"user_override\".to_string());\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n override_value = v,\n \"User set multimodal override\"\n );\n } else {\n // Override cleared — re-run layered detection immediately so the\n // stored value reflects the auto-detected truth.\n let detected = neomind_core::llm::detect_vision_capability(&instance.model);\n // Source attribution: 3-tier (registry / heuristic / unknown).\n // Use the same logic as `adjust_capabilities_for_model` so the source\n // is consistent across creation, update, and override-clear paths.\n let source: Option<&str> = if neomind_core::llm::registry::lookup_vision(&instance.model).is_some() {\n Some(\"registry\")\n } else if neomind_core::llm::registry::heuristic_vision_match(&instance.model) {\n Some(\"heuristic\")\n } else {\n None\n };\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n detected = detected,\n source = ?source,\n \"User cleared multimodal override, re-detected\"\n );\n instance.capabilities.supports_multimodal = detected;\n instance.capabilities.multimodal_source = source.map(str::to_string);\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"supports_multimodal\": instance.capabilities.supports_multimodal,\n \"multimodal_user_override\": instance.capabilities.multimodal_user_override,\n \"multimodal_source\": instance.capabilities.multimodal_source,\n \"message\": \"Capabilities override applied\",\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler (temp file cleanup)\n if let Err(e) = state.agents.start_memory_scheduler().await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n // Track that this value came from a live runtime probe, so that\n // `ensure_instance_capabilities` doesn't second-guess it with the\n // less-accurate name-based heuristic on next load.\n multimodal_source: Some(\"runtime_api\".to_string()),\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n // Source attribution matches `adjust_capabilities_for_model`.\n let multimodal_source = if neomind_core::llm::registry::lookup_vision(model_name).is_some() {\n Some(\"registry\".to_string())\n } else if neomind_core::llm::registry::heuristic_vision_match(model_name) {\n Some(\"heuristic\".to_string())\n } else {\n None\n };\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n multimodal_source,\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // GLM models (智谱)\n if name_lower.contains(\"glm\") {\n return 128000;\n }\n\n // MiniMax models\n if name_lower.contains(\"abab\") || name_lower.contains(\"minimax\") {\n return 512000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 128000\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n ..Default::default()\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n ..Default::default()\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n ..Default::default()\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n ..Default::default()\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n ..Default::default()\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n ..Default::default()\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n ..Default::default()\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection.\n // Also record provenance so the frontend and diagnostics can show WHERE\n // the value came from. We do NOT touch `multimodal_user_override` here —\n // that field is sacred and only set by the explicit override endpoint.\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n if capabilities.multimodal_user_override.is_none() {\n // Determine source: registry hit vs heuristic vs unknown.\n // We can't tell \"registry\" from \"heuristic\" from this call site without\n // re-running the layered lookup, so we use the more specific helper.\n if neomind_core::llm::registry::lookup_vision(&name_lower).is_some() {\n capabilities.multimodal_source = Some(\"registry\".to_string());\n } else if neomind_core::llm::registry::heuristic_vision_match(&name_lower) {\n capabilities.multimodal_source = Some(\"heuristic\".to_string());\n } else {\n capabilities.multimodal_source = None;\n }\n }\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name = path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "65725156a31a36f7dde328f8289a203ff1a133f5": {"t": 1785760849.3402293, "n": 60496, "h": "3a6f8542e080933d", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Preserve user-set fields that the frontend doesn't render.\n // The capabilities object sent by the frontend typically doesn't include\n // `multimodal_user_override` or `multimodal_source` (those are managed via\n // the dedicated PATCH /capabilities endpoint). Without this preservation,\n // any user override would be silently wiped every time the user saved an\n // unrelated field change like temperature.\n let saved_user_override = instance.capabilities.multimodal_user_override;\n\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n\n // Restore preserved fields unless the caller explicitly cleared them\n // (sending `null` for the override). Since the request deserializer\n // can't distinguish \"absent\" from \"explicit null\" for an Option,\n // we always restore — the dedicated PATCH endpoint is the only way to\n // clear an override.\n //\n // Source handling: we only restore source when an override is being\n // restored (since the override forces source = \"user_override\").\n // When there is no override, adjust_capabilities_for_model above\n // already set source correctly from the layered detection, so we\n // must NOT clobber it with the stale saved_source here.\n if capabilities.multimodal_user_override.is_none() {\n capabilities.multimodal_user_override = saved_user_override;\n if let Some(override_val) = saved_user_override {\n // Override restored → all three fields must reflect it:\n // supports_multimodal (effective value), source (provenance),\n // and multimodal_user_override (already set above). Without\n // syncing supports_multimodal, the runtime would read the\n // auto-detected value and ignore the user's override.\n capabilities.supports_multimodal = override_val;\n capabilities.multimodal_source = Some(\"user_override\".to_string());\n }\n // else: leave source alone — adjust_capabilities_for_model set it.\n }\n\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Request body for the capabilities-override PATCH endpoint.\n///\n/// All fields are optional. `multimodal: null` clears the override (returns\n/// to auto-detection); `multimodal: true/false` pins the value.\n#[derive(Debug, Deserialize)]\npub struct UpdateCapabilitiesOverrideRequest {\n /// Override the multimodal/vision capability.\n ///\n /// - `Some(true)`/`Some(false)` — pin the value, skip auto-detection.\n /// - `None` — clear the override, resume auto-detection.\n ///\n /// JSON `null` and field-absence both deserialize to `None` (serde's\n /// default behavior for `Option`).\n #[serde(default)]\n pub multimodal: Option,\n}\n\n/// Set or clear a user override on backend capabilities.\n///\n/// `PATCH /api/llm-backends/:id/capabilities`\n///\n/// Currently only the `multimodal` (vision) capability is overridable — this\n/// is the main source of false positives in auto-detection, and is the field\n/// users most commonly need to correct manually.\npub async fn update_capabilities_override_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Apply override.\n instance.capabilities.multimodal_user_override = req.multimodal;\n if let Some(v) = req.multimodal {\n instance.capabilities.supports_multimodal = v;\n instance.capabilities.multimodal_source = Some(\"user_override\".to_string());\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n override_value = v,\n \"User set multimodal override\"\n );\n } else {\n // Override cleared — re-run layered detection immediately so the\n // stored value reflects the auto-detected truth.\n let detected = neomind_core::llm::detect_vision_capability(&instance.model);\n // Source attribution: 3-tier (registry / heuristic / unknown).\n // Use the same logic as `adjust_capabilities_for_model` so the source\n // is consistent across creation, update, and override-clear paths.\n let source: Option<&str> = if neomind_core::llm::registry::lookup_vision(&instance.model).is_some() {\n Some(\"registry\")\n } else if neomind_core::llm::registry::heuristic_vision_match(&instance.model) {\n Some(\"heuristic\")\n } else {\n None\n };\n tracing::info!(\n backend_id = %id,\n model = %instance.model,\n detected = detected,\n source = ?source,\n \"User cleared multimodal override, re-detected\"\n );\n instance.capabilities.supports_multimodal = detected;\n instance.capabilities.multimodal_source = source.map(str::to_string);\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"supports_multimodal\": instance.capabilities.supports_multimodal,\n \"multimodal_user_override\": instance.capabilities.multimodal_user_override,\n \"multimodal_source\": instance.capabilities.multimodal_source,\n \"message\": \"Capabilities override applied\",\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler (temp file cleanup)\n if let Err(e) = state.agents.start_memory_scheduler().await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n // Track that this value came from a live runtime probe, so that\n // `ensure_instance_capabilities` doesn't second-guess it with the\n // less-accurate name-based heuristic on next load.\n multimodal_source: Some(\"runtime_api\".to_string()),\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n // Source attribution matches `adjust_capabilities_for_model`.\n let multimodal_source = if neomind_core::llm::registry::lookup_vision(model_name).is_some() {\n Some(\"registry\".to_string())\n } else if neomind_core::llm::registry::heuristic_vision_match(model_name) {\n Some(\"heuristic\".to_string())\n } else {\n None\n };\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n multimodal_source,\n supports_thinking,\n supports_tools,\n max_context,\n ..Default::default()\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // GLM models (智谱)\n if name_lower.contains(\"glm\") {\n return 128000;\n }\n\n // MiniMax models\n if name_lower.contains(\"abab\") || name_lower.contains(\"minimax\") {\n return 512000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 128000\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n ..Default::default()\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n ..Default::default()\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n ..Default::default()\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n ..Default::default()\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n ..Default::default()\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n ..Default::default()\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n ..Default::default()\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n ..Default::default()\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection.\n // Also record provenance so the frontend and diagnostics can show WHERE\n // the value came from. We do NOT touch `multimodal_user_override` here —\n // that field is sacred and only set by the explicit override endpoint.\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n if capabilities.multimodal_user_override.is_none() {\n // Determine source: registry hit vs heuristic vs unknown.\n // We can't tell \"registry\" from \"heuristic\" from this call site without\n // re-running the layered lookup, so we use the more specific helper.\n if neomind_core::llm::registry::lookup_vision(&name_lower).is_some() {\n capabilities.multimodal_source = Some(\"registry\".to_string());\n } else if neomind_core::llm::registry::heuristic_vision_match(&name_lower) {\n capabilities.multimodal_source = Some(\"heuristic\".to_string());\n } else {\n capabilities.multimodal_source = None;\n }\n }\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n ..Default::default()\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name = path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "118d2b008ccd9caab172211a2d9a9d99136a67df": {"t": 1785760850.9715, "n": 52932, "h": "8c17f10924ad5b13", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler (temp file cleanup)\n if let Err(e) = state.agents.start_memory_scheduler().await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // GLM models (智谱)\n if name_lower.contains(\"glm\") {\n return 128000;\n }\n\n // MiniMax models\n if name_lower.contains(\"abab\") || name_lower.contains(\"minimax\") {\n return 512000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 128000\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name = path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "5afff6c74bdeeb2f0906a60af99e22505e4d6a74": {"t": 1785760852.4429924, "n": 53182, "h": "53f9fbd88e139ab6", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler with the new LLM backend\n // Get the runtime from the instance manager\n if let Ok(instance_manager) = neomind_agent::get_instance_manager() {\n if let Ok(runtime) = instance_manager.get_active_runtime().await {\n if let Err(e) = state.agents.start_memory_scheduler(runtime).await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n }\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // GLM models (智谱)\n if name_lower.contains(\"glm\") {\n return 128000;\n }\n\n // MiniMax models\n if name_lower.contains(\"abab\") || name_lower.contains(\"minimax\") {\n return 512000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 128000\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name = path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "da20140535e412619841312f93d44a4bee704b95": {"t": 1785760853.8310432, "n": 53222, "h": "250dc9b29be58356", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler with the new LLM backend\n // Get the runtime from the instance manager\n if let Ok(instance_manager) = neomind_agent::get_instance_manager() {\n if let Ok(runtime) = instance_manager.get_active_runtime().await {\n if let Err(e) = state.agents.start_memory_scheduler(runtime).await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n }\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // GLM models (智谱)\n if name_lower.contains(\"glm\") {\n return 128000;\n }\n\n // MiniMax models\n if name_lower.contains(\"abab\") || name_lower.contains(\"minimax\") {\n return 512000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 128000\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name =\n path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "10411f330f4e2942311517986e398e77a507c439": {"t": 1785760855.290676, "n": 53008, "h": "dc415bb75d52260c", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler with the new LLM backend\n // Get the runtime from the instance manager\n if let Ok(instance_manager) = neomind_agent::get_instance_manager() {\n if let Ok(runtime) = instance_manager.get_active_runtime().await {\n if let Err(e) = state.agents.start_memory_scheduler(runtime).await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n }\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available.\n/// Uses neomind-core's unified detect_vision_capability for consistency.\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Use unified vision detection from neomind-core\n let supports_multimodal = detect_vision_capability(model_name);\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name =\n path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "5308a68069d89d0536c37053834b9a534aa1bcfa": {"t": 1785760856.9339874, "n": 53135, "h": "cc06c8688faa549a", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"llamacpp\" => LlmBackendType::LlamaCpp,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler with the new LLM backend\n // Get the runtime from the instance manager\n if let Ok(instance_manager) = neomind_agent::get_instance_manager() {\n if let Ok(runtime) = instance_manager.get_active_runtime().await {\n if let Err(e) = state.agents.start_memory_scheduler(runtime).await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n }\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n// ---------------------------------------------------------------------------\n// llama.cpp server info endpoint\n// ---------------------------------------------------------------------------\n\n/// Lightweight /props response — only fields we need, ignores the huge chat_template.\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsLight {\n #[serde(default)]\n model_alias: Option,\n #[serde(default)]\n model_path: Option,\n #[serde(default)]\n default_generation_settings: Option,\n #[serde(default)]\n total_slots: Option,\n #[serde(default)]\n modalities: Option,\n #[serde(default)]\n build_info: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsGenSettings {\n #[serde(default)]\n n_ctx: Option,\n #[serde(default)]\n params: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppPropsParams {\n #[serde(default)]\n n_ctx: Option,\n}\n\n#[derive(Debug, Deserialize)]\nstruct LlamaCppModalities {\n #[serde(default)]\n vision: Option,\n}\n\nimpl LlamaCppPropsLight {\n fn n_ctx(&self) -> Option {\n self.default_generation_settings\n .as_ref()\n .and_then(|s| s.n_ctx.or_else(|| s.params.as_ref().and_then(|p| p.n_ctx)))\n }\n}\n\n/// Query parameters for fetching llama.cpp server info\n#[derive(Debug, Deserialize)]\npub struct LlamaCppServerInfoQuery {\n /// llama.cpp server endpoint (default: http://127.0.0.1:8080)\n pub endpoint: Option,\n /// Optional API key for --api-key authentication\n pub api_key: Option,\n}\n\n/// llama.cpp server health response\n#[derive(Debug, Serialize)]\nstruct LlamaCppHealthInfo {\n status: String,\n latency_ms: u64,\n}\n\n/// llama.cpp server properties info\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerDetails {\n /// Loaded model file name (extracted from full path)\n model_name: Option,\n /// Context window size\n n_ctx: Option,\n /// Number of total slots\n total_slots: Option,\n /// Server version\n version: Option,\n}\n\n/// Combined llama.cpp server info response\n#[derive(Debug, Serialize)]\nstruct LlamaCppServerInfoResponse {\n status: String,\n health: LlamaCppHealthInfo,\n server: LlamaCppServerDetails,\n capabilities: BackendCapabilities,\n}\n\n/// GET /api/llm-backends/llamacpp/server-info?endpoint=http://127.0.0.1:8080\n///\n/// Fetches health check and server properties from a llama.cpp server,\n/// returns combined info with auto-detected capabilities.\npub async fn list_llamacpp_server_info_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(10))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // 1. Health check\n let health_url = format!(\"{}/health\", base_url);\n let mut req = client.get(&health_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n let start = std::time::Instant::now();\n let health_resp = req.send().await;\n let latency_ms = start.elapsed().as_millis() as u64;\n\n let health_info = match health_resp {\n Ok(resp) if resp.status().is_success() => LlamaCppHealthInfo {\n status: \"ok\".to_string(),\n latency_ms,\n },\n Ok(resp) => LlamaCppHealthInfo {\n status: format!(\"error: HTTP {}\", resp.status()),\n latency_ms,\n },\n Err(e) => LlamaCppHealthInfo {\n status: format!(\"unreachable: {}\", e),\n latency_ms,\n },\n };\n\n let is_healthy = health_info.status == \"ok\";\n\n // 2. Fetch server props (only if healthy)\n let mut server_details = LlamaCppServerDetails {\n model_name: None,\n n_ctx: None,\n total_slots: None,\n version: None,\n };\n\n let mut capabilities = BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n };\n\n if is_healthy {\n // 1. Fetch /v1/models FIRST — small, clean response for model name and n_ctx_train\n let models_url = format!(\"{}/v1/models\", base_url);\n let mut req = client.get(&models_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n if let Ok(models_resp) = serde_json::from_str::(&body) {\n // Model name from data[0].id\n if let Some(model_id) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"id\"))\n .and_then(|v| v.as_str())\n {\n if !model_id.is_empty() {\n server_details.model_name = Some(model_id.to_string());\n adjust_capabilities_for_model(model_id, &mut capabilities);\n }\n }\n\n // n_ctx_train from /v1/models is the model's trained context (more accurate)\n if let Some(n_ctx_train) = models_resp\n .get(\"data\")\n .and_then(|d| d.as_array())\n .and_then(|arr| arr.first())\n .and_then(|m| m.get(\"meta\"))\n .and_then(|m| m.get(\"n_ctx_train\"))\n .and_then(|v| v.as_u64())\n {\n capabilities.max_context = n_ctx_train as usize;\n }\n }\n }\n }\n }\n\n // 2. Fetch /props for runtime n_ctx, total_slots, modalities, and version\n // Use lightweight struct deserialization to avoid issues with the very large\n // chat_template field that can cause serde_json::Value parsing to fail.\n let props_url = format!(\"{}/props\", base_url);\n let mut req = client.get(&props_url);\n if let Some(ref key) = params.api_key {\n req = req.bearer_auth(key);\n }\n\n if let Ok(resp) = req.send().await {\n if resp.status().is_success() {\n if let Ok(body) = resp.text().await {\n // Try typed deserialization first (ignores chat_template)\n if let Ok(props) = serde_json::from_str::(&body) {\n // Fallback model name from model_alias or model_path\n if server_details.model_name.is_none() {\n if let Some(alias) = &props.model_alias {\n if !alias.is_empty() {\n server_details.model_name = Some(alias.clone());\n adjust_capabilities_for_model(alias, &mut capabilities);\n }\n }\n }\n if server_details.model_name.is_none() {\n if let Some(path) = &props.model_path {\n if !path.is_empty() {\n let name =\n path.rsplit('/').next().unwrap_or(path).to_string();\n server_details.model_name = Some(name.clone());\n adjust_capabilities_for_model(&name, &mut capabilities);\n }\n }\n }\n\n // n_ctx from runtime settings\n server_details.n_ctx = props.n_ctx();\n\n // Use runtime n_ctx as fallback if no n_ctx_train\n if capabilities.max_context == 4096 {\n if let Some(n_ctx) = server_details.n_ctx {\n capabilities.max_context = n_ctx;\n }\n }\n\n server_details.total_slots = props.total_slots;\n\n // Multimodal from modalities field\n if let Some(vision) = props.modalities.as_ref().and_then(|m| m.vision) {\n capabilities.supports_multimodal = vision;\n }\n\n // Version from build_info\n server_details.version = props.build_info.clone();\n }\n }\n }\n }\n }\n\n let response = LlamaCppServerInfoResponse {\n status: if is_healthy { \"ok\" } else { \"error\" }.to_string(),\n health: health_info,\n server: server_details,\n capabilities,\n };\n\n ok(json!(response))\n}\n"}, "e28d4b5451030318e2e646a1a545cc8e14d86ba5": {"t": 1785760860.4705708, "n": 43648, "h": "c48282b657fb9ccd", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::LlamaCpp => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://127.0.0.1:8080\".to_string());\n let model = instance.model.clone();\n LlmBackend::LlamaCpp {\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::LlamaCpp => crate::config::LlmSettingsRequest {\n backend: \"llamacpp\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler with the new LLM backend\n // Get the runtime from the instance manager\n if let Ok(instance_manager) = neomind_agent::get_instance_manager() {\n if let Ok(runtime) = instance_manager.get_active_runtime().await {\n if let Err(e) = state.agents.start_memory_scheduler(runtime).await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n }\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n LlmBackendType::LlamaCpp => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: true,\n supports_tools: true,\n max_context: 4096,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "fd1258e3bea085ed6c962579fbd42b1ad41a121c": {"t": 1785760863.2052155, "n": 42768, "h": "215ff84aa13591ed", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n // Start the memory scheduler with the new LLM backend\n // Get the runtime from the instance manager\n if let Ok(instance_manager) = neomind_agent::get_instance_manager() {\n if let Ok(runtime) = instance_manager.get_active_runtime().await {\n if let Err(e) = state.agents.start_memory_scheduler(runtime).await {\n tracing::warn!(category = \"memory\", error = %e, \"Failed to start memory scheduler\");\n }\n }\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "27579e25721073777e8d1902243ba6e494dc02bf": {"t": 1785760864.676932, "n": 42298, "h": "d60a33466a8a3a54", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update existing chat sessions and set as default for new sessions.\n // Note: SessionManager.sessions contains chat Agent instances (not AI agents).\n // AI agents use their own locked llm_backend_id via get_llm_runtime_for_agent.\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "505373dcec6e6bf1817059c70b92324137f61c64": {"t": 1785760866.1922219, "n": 42146, "h": "7d4117d6107566a9", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update the default backend for NEW sessions only.\n // Do NOT modify existing agents — each agent has its own independent LLM backend.\n state\n .agents\n .session_manager\n .set_default_llm_backend(backend.clone())\n .await;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "bad46c345989b4ae57e0182c9f68300fd5ace257": {"t": 1785760867.723634, "n": 42115, "h": "3adc760fa71a0934", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance\n .endpoint\n .as_deref()\n .unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await\n {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "2e99234e1692f33e2f712867d3d1c2ef409522af": {"t": 1785760869.2104921, "n": 41976, "h": "95e4904a3c90798f", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Get capabilities: prefer API detection for Ollama, fallback to name-based\n let capabilities = if matches!(backend_type, LlmBackendType::Ollama) {\n // For Ollama, try to get capabilities from /api/show endpoint\n let endpoint = req.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &req.model).await {\n Ok(caps) => {\n tracing::info!(\n model = %req.model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Detected Ollama model capabilities via API\"\n );\n caps\n }\n Err(e) => {\n tracing::warn!(\n model = %req.model,\n error = %e,\n \"Failed to get Ollama capabilities via API, using name-based detection\"\n );\n // Fallback to name-based detection\n let mut caps = req.capabilities.unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n }\n }\n } else {\n // For non-Ollama backends, use provided capabilities or defaults with name-based adjustment\n let mut caps = req.capabilities.unwrap_or_else(|| get_default_capabilities(&backend_type));\n adjust_capabilities_for_model(&req.model, &mut caps);\n caps\n };\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n // For Ollama, try API detection first\n if matches!(instance.backend_type, LlmBackendType::Ollama) {\n let endpoint = instance.endpoint.as_deref().unwrap_or(\"http://localhost:11434\");\n let show_url = format!(\"{}/api/show\", endpoint);\n match get_model_capabilities_from_show(&reqwest::Client::new(), &show_url, &model).await {\n Ok(caps) => {\n tracing::info!(\n model = %model,\n multimodal = caps.supports_multimodal,\n thinking = caps.supports_thinking,\n tools = caps.supports_tools,\n \"Updated Ollama model capabilities via API\"\n );\n instance.capabilities = caps;\n }\n Err(e) => {\n tracing::warn!(\n model = %model,\n error = %e,\n \"Failed to get Ollama capabilities, using name-based detection\"\n );\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n } else {\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "900d3c7aedac45058915877b627e8ab087496cbe": {"t": 1785760870.6849182, "n": 39414, "h": "dff2dd50b9cca40c", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(\n storage_caps: &neomind_storage::BackendCapabilities,\n ) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n\n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama {\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "32097a6510c33d840471b0dcf4aebe2b5427f21e": {"t": 1785760872.068914, "n": 39347, "h": "a5dc72d68da3a4bb", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_agent::llm_backends::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(storage_caps: &neomind_storage::BackendCapabilities) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n \n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n \n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama { endpoint, model, capabilities }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n"}, "ef71678e46060749c5a90d74b7b2f10888392e4d": {"t": 1785760873.7525277, "n": 39349, "h": "01bca68327dee1e8", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_llm::instance_manager::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(storage_caps: &neomind_storage::BackendCapabilities) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n \n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n \n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama { endpoint, model, capabilities }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n"}, "ebaa0990d1acc7381a583d554c8cdeb02e6c2d02": {"t": 1785760875.2443967, "n": 39424, "h": "c4f9b7784a626596", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_llm::instance_manager::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_core::llm::detect_vision_capability;\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(storage_caps: &neomind_storage::BackendCapabilities) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n \n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n \n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama { endpoint, model, capabilities }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else if name_lower.contains(\"qwen2\") {\n return 32000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Use the same logic as neomind-core capability detection\n capabilities.supports_multimodal = detect_vision_capability(&name_lower);\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = (name_lower.starts_with(\"qwen3\")\n || name_lower.starts_with(\"qwen2.5\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\")\n || name_lower.contains(\"o1\")\n || name_lower.contains(\"o3\"))\n && !capabilities.supports_multimodal; // Vision models typically don't support thinking\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // Detect max context from model name\n capabilities.max_context = detect_model_context(model_name);\n}\n\n"}, "65acd003b17009e64ab954b0c15ac22b62c915bc": {"t": 1785760876.5938947, "n": 40157, "h": "2ea835f52d947dde", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_llm::instance_manager::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n \"qwen\" => LlmBackendType::Qwen,\n \"deepseek\" => LlmBackendType::DeepSeek,\n \"glm\" => LlmBackendType::GLM,\n \"minimax\" => LlmBackendType::MiniMax,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(storage_caps: &neomind_storage::BackendCapabilities) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n \n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n \n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama { endpoint, model, capabilities }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else if name_lower.contains(\"qwen2\") {\n return 32000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Models with \"vl\", \"vision\", or \"-mm\" suffix support vision\n let explicit_vision = name_lower.contains(\"-vl\")\n || name_lower.contains(\":vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"-mm\")\n || name_lower.contains(\":mm\");\n\n // Vision models typically don't support extended thinking (they're optimized for speed)\n if explicit_vision {\n capabilities.supports_multimodal = true;\n capabilities.supports_thinking = false;\n } else {\n // For non-vision models, check if they might support vision\n // Most models don't, so default to false unless explicitly marked\n // Exception: some newer models might have vision but no explicit marker\n // For now, be conservative and assume no vision unless marked\n capabilities.supports_multimodal = false;\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n && !name_lower.contains(\"-vl\")\n && !name_lower.contains(\":vl\")\n || name_lower.starts_with(\"qwen2.5\")\n && !name_lower.contains(\"-vl\")\n && !name_lower.contains(\":vl\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\");\n }\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // === Context size adjustment ===\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "de1581f3c3463406929c596bb4aed1265864c44e": {"t": 1785760878.4866714, "n": 39985, "h": "2821eddae9c9e294", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_llm::instance_manager::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n /// Convert storage BackendCapabilities to core BackendCapabilities\n fn convert_capabilities(storage_caps: &neomind_storage::BackendCapabilities) -> neomind_core::BackendCapabilities {\n neomind_core::BackendCapabilities {\n streaming: storage_caps.supports_streaming,\n multimodal: storage_caps.supports_multimodal,\n function_calling: storage_caps.supports_tools,\n thinking_display: storage_caps.supports_thinking,\n max_context: Some(storage_caps.max_context),\n multiple_models: false,\n modalities: Vec::new(),\n supports_images: storage_caps.supports_multimodal,\n supports_audio: false,\n }\n }\n \n // Convert capabilities from storage type to core type\n let capabilities = Some(convert_capabilities(&instance.capabilities));\n \n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama { endpoint, model, capabilities }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Anthropic {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Google {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::XAi {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::Qwen {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::DeepSeek {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::GLM {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::MiniMax {\n api_key,\n endpoint,\n model,\n capabilities,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else if name_lower.contains(\"qwen2\") {\n return 32000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Models with \"vl\", \"vision\", or \"-mm\" suffix support vision\n let explicit_vision = name_lower.contains(\"-vl\")\n || name_lower.contains(\":vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"-mm\")\n || name_lower.contains(\":mm\");\n\n // Vision models typically don't support extended thinking (they're optimized for speed)\n if explicit_vision {\n capabilities.supports_multimodal = true;\n capabilities.supports_thinking = false;\n } else {\n // For non-vision models, check if they might support vision\n // Most models don't, so default to false unless explicitly marked\n // Exception: some newer models might have vision but no explicit marker\n // For now, be conservative and assume no vision unless marked\n capabilities.supports_multimodal = false;\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n && !name_lower.contains(\"-vl\")\n && !name_lower.contains(\":vl\")\n || name_lower.starts_with(\"qwen2.5\")\n && !name_lower.contains(\"-vl\")\n && !name_lower.contains(\":vl\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\");\n }\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // === Context size adjustment ===\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "a92cd80d7c861ee1f01b2f8423c597cb602234c9": {"t": 1785760880.3070054, "n": 38875, "h": "13edeefd1b816e38", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n extract::{Path, Query, State},\n Json,\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n common::{ok, HandlerResult},\n ServerState,\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_llm::instance_manager::{\n get_instance_manager, BackendTypeDefinition, LlmBackendInstanceManager,\n};\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama { endpoint, model }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else if name_lower.contains(\"qwen2\") {\n return 32000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Models with \"vl\", \"vision\", or \"-mm\" suffix support vision\n let explicit_vision = name_lower.contains(\"-vl\")\n || name_lower.contains(\":vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"-mm\")\n || name_lower.contains(\":mm\");\n\n // Vision models typically don't support extended thinking (they're optimized for speed)\n if explicit_vision {\n capabilities.supports_multimodal = true;\n capabilities.supports_thinking = false;\n } else {\n // For non-vision models, check if they might support vision\n // Most models don't, so default to false unless explicitly marked\n // Exception: some newer models might have vision but no explicit marker\n // For now, be conservative and assume no vision unless marked\n capabilities.supports_multimodal = false;\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n && !name_lower.contains(\"-vl\")\n && !name_lower.contains(\":vl\")\n || name_lower.starts_with(\"qwen2.5\")\n && !name_lower.contains(\"-vl\")\n && !name_lower.contains(\":vl\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\");\n }\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // === Context size adjustment ===\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "1173da122ac2c6c14ee7fe103e28492ffb492710": {"t": 1785760881.8089232, "n": 38875, "h": "0e28684616712288", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n Json,\n extract::{Path, Query, State},\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n ServerState,\n common::{HandlerResult, ok},\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_llm::instance_manager::{\n BackendTypeDefinition, LlmBackendInstanceManager, get_instance_manager,\n};\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance.validate().map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama { endpoint, model }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Qwen => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://dashscope.aliyuncs.com/compatible-mode/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::DeepSeek => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.deepseek.com\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::GLM => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://open.bigmodel.cn/api/paas/v4\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::MiniMax => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.minimax.chat/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Qwen => crate::config::LlmSettingsRequest {\n backend: \"qwen\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::DeepSeek => crate::config::LlmSettingsRequest {\n backend: \"deepseek\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::GLM => crate::config::LlmSettingsRequest {\n backend: \"glm\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::MiniMax => crate::config::LlmSettingsRequest {\n backend: \"minimax\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params\n .endpoint\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\n \"Failed to get capabilities for {}: {}, using fallback detection\",\n model.name,\n e\n );\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else if name_lower.contains(\"qwen2\") {\n return 32000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n LlmBackendType::Qwen => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 32768,\n },\n LlmBackendType::DeepSeek => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 65536,\n },\n LlmBackendType::GLM => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: true,\n max_context: 131072,\n },\n LlmBackendType::MiniMax => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 512000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Models with \"vl\", \"vision\", or \"-mm\" suffix support vision\n let explicit_vision = name_lower.contains(\"-vl\")\n || name_lower.contains(\":vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"-mm\")\n || name_lower.contains(\":mm\");\n\n // Vision models typically don't support extended thinking (they're optimized for speed)\n if explicit_vision {\n capabilities.supports_multimodal = true;\n capabilities.supports_thinking = false;\n } else {\n // For non-vision models, check if they might support vision\n // Most models don't, so default to false unless explicitly marked\n // Exception: some newer models might have vision but no explicit marker\n // For now, be conservative and assume no vision unless marked\n capabilities.supports_multimodal = false;\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n && !name_lower.contains(\"-vl\")\n && !name_lower.contains(\":vl\")\n || name_lower.starts_with(\"qwen2.5\")\n && !name_lower.contains(\"-vl\")\n && !name_lower.contains(\":vl\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\");\n }\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // === Context size adjustment ===\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "12386f563ee96c480b11892c713f1c74a910bae3": {"t": 1785760883.433695, "n": 34898, "h": "03115522b50e12bc", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n Json,\n extract::{Path, Query, State},\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n ServerState,\n common::{HandlerResult, ok},\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_llm::instance_manager::{\n BackendTypeDefinition, LlmBackendInstanceManager, get_instance_manager,\n};\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance\n .validate()\n .map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance\n .validate()\n .map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama { endpoint, model }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .agents.session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params.endpoint.unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\"Failed to get capabilities for {}: {}, using fallback detection\", model.name, e);\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else if name_lower.contains(\"qwen2\") {\n return 32000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Models with \"vl\", \"vision\", or \"-mm\" suffix support vision\n let explicit_vision = name_lower.contains(\"-vl\")\n || name_lower.contains(\":vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"-mm\")\n || name_lower.contains(\":mm\");\n\n // Vision models typically don't support extended thinking (they're optimized for speed)\n if explicit_vision {\n capabilities.supports_multimodal = true;\n capabilities.supports_thinking = false;\n } else {\n // For non-vision models, check if they might support vision\n // Most models don't, so default to false unless explicitly marked\n // Exception: some newer models might have vision but no explicit marker\n // For now, be conservative and assume no vision unless marked\n capabilities.supports_multimodal = false;\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n && !name_lower.contains(\"-vl\") && !name_lower.contains(\":vl\")\n || name_lower.starts_with(\"qwen2.5\")\n && !name_lower.contains(\"-vl\") && !name_lower.contains(\":vl\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\");\n }\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // === Context size adjustment ===\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "df04244a1a4d2c04c726408e94ee48771b9efbd1": {"t": 1785760885.0267267, "n": 34891, "h": "da78ea5126a92955", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n Json,\n extract::{Path, Query, State},\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n ServerState,\n common::{HandlerResult, ok},\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_llm::instance_manager::{\n BackendTypeDefinition, LlmBackendInstanceManager, get_instance_manager,\n};\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance\n .validate()\n .map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance\n .validate()\n .map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama { endpoint, model }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params.endpoint.unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n tracing::warn!(\"Failed to get capabilities for {}: {}, using fallback detection\", model.name, e);\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else if name_lower.contains(\"qwen2\") {\n return 32000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Models with \"vl\", \"vision\", or \"-mm\" suffix support vision\n let explicit_vision = name_lower.contains(\"-vl\")\n || name_lower.contains(\":vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"-mm\")\n || name_lower.contains(\":mm\");\n\n // Vision models typically don't support extended thinking (they're optimized for speed)\n if explicit_vision {\n capabilities.supports_multimodal = true;\n capabilities.supports_thinking = false;\n } else {\n // For non-vision models, check if they might support vision\n // Most models don't, so default to false unless explicitly marked\n // Exception: some newer models might have vision but no explicit marker\n // For now, be conservative and assume no vision unless marked\n capabilities.supports_multimodal = false;\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n && !name_lower.contains(\"-vl\") && !name_lower.contains(\":vl\")\n || name_lower.starts_with(\"qwen2.5\")\n && !name_lower.contains(\"-vl\") && !name_lower.contains(\":vl\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\");\n }\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // === Context size adjustment ===\n capabilities.max_context = detect_model_context(model_name);\n}\n"}, "9f5c110a1369a22b22c2132155f08045c9059ceb": {"t": 1785760886.6922958, "n": 34886, "h": "3b87eef3a1b2385f", "c": "//! LLM Backend Management API Handlers\n//!\n//! This module provides REST API endpoints for managing multiple LLM backend instances.\n\nuse axum::{\n Json,\n extract::{Path, Query, State},\n};\nuse serde::{Deserialize, Serialize};\nuse serde_json::json;\nuse std::sync::Arc;\n\nuse super::{\n ServerState,\n common::{HandlerResult, ok},\n};\nuse crate::models::ErrorResponse;\n\nuse neomind_llm::instance_manager::{\n BackendTypeDefinition, LlmBackendInstanceManager, get_instance_manager,\n};\nuse neomind_storage::{BackendCapabilities, LlmBackendInstance, LlmBackendStore, LlmBackendType};\n\n/// Query parameters for listing LLM backends\n#[derive(Debug, Deserialize)]\npub struct ListBackendsQuery {\n /// Filter by backend type\n pub r#type: Option,\n /// Show only active backend\n pub active_only: Option,\n}\n\n/// Request to create/update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct CreateBackendRequest {\n /// Display name\n pub name: String,\n\n /// Backend type\n pub backend_type: String,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: String,\n\n /// API key (for cloud providers)\n pub api_key: Option,\n\n /// Temperature (0.0 to 2.0)\n #[serde(default = \"default_temperature\")]\n pub temperature: f32,\n\n /// Top-P sampling (0.0 to 1.0)\n #[serde(default = \"default_top_p\")]\n pub top_p: f32,\n\n /// Top-K sampling (0 = disabled)\n #[serde(default = \"default_top_k\")]\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n #[serde(default = \"default_thinking_enabled\")]\n pub thinking_enabled: bool,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\nfn default_temperature() -> f32 {\n 0.6 // Lowered for faster responses\n}\nfn default_top_p() -> f32 {\n 0.85 // Lowered to reduce thinking time\n}\nfn default_top_k() -> Option {\n Some(20) // Lowered for faster sampling\n}\nfn default_max_tokens() -> usize {\n usize::MAX\n}\nfn default_thinking_enabled() -> bool {\n true\n}\n\n/// Request to update an LLM backend instance\n#[derive(Debug, Deserialize)]\npub struct UpdateBackendRequest {\n /// Display name\n pub name: Option,\n\n /// API endpoint URL\n pub endpoint: Option,\n\n /// Model name\n pub model: Option,\n\n /// API key\n pub api_key: Option,\n\n /// Temperature\n pub temperature: Option,\n\n /// Top-P sampling\n pub top_p: Option,\n\n /// Top-K sampling\n pub top_k: Option,\n\n /// Enable thinking/reasoning mode for models that support it\n pub thinking_enabled: Option,\n\n /// Model capabilities (optional, from Ollama model detection)\n #[serde(default)]\n pub capabilities: Option,\n}\n\n/// Backend instance DTO for API responses\n#[derive(Debug, Serialize)]\npub struct BackendInstanceDto {\n pub id: String,\n pub name: String,\n pub backend_type: String,\n pub endpoint: Option,\n pub model: String,\n pub api_key_configured: bool,\n pub is_active: bool,\n pub temperature: f32,\n pub top_p: f32,\n pub top_k: usize,\n pub max_tokens: usize,\n pub thinking_enabled: bool,\n pub capabilities: BackendCapabilities,\n pub updated_at: i64,\n pub healthy: Option,\n}\n\nimpl From for BackendInstanceDto {\n fn from(instance: LlmBackendInstance) -> Self {\n let backend_type = instance.backend_name().to_string();\n Self {\n id: instance.id,\n name: instance.name,\n backend_type,\n endpoint: instance.endpoint,\n model: instance.model,\n api_key_configured: instance.api_key.is_some()\n && !instance.api_key.as_ref().is_some_and(|k| k.is_empty()),\n is_active: instance.is_active,\n temperature: instance.temperature,\n top_p: instance.top_p,\n top_k: instance.top_k,\n max_tokens: instance.max_tokens,\n thinking_enabled: instance.thinking_enabled,\n capabilities: instance.capabilities,\n updated_at: instance.updated_at,\n healthy: None, // Populated separately\n }\n }\n}\n\n/// Backend type definition DTO\n#[derive(Debug, Serialize)]\npub struct BackendTypeDto {\n pub id: String,\n pub name: String,\n pub description: String,\n pub default_model: String,\n pub default_endpoint: Option,\n pub requires_api_key: bool,\n pub supports_streaming: bool,\n pub supports_thinking: bool,\n pub supports_multimodal: bool,\n /// Configuration schema for dynamic form generation\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub config_schema: Option,\n}\n\nimpl From for BackendTypeDto {\n fn from(def: BackendTypeDefinition) -> Self {\n Self {\n id: def.id.clone(),\n name: def.name,\n description: def.description,\n default_model: def.default_model,\n default_endpoint: def.default_endpoint,\n requires_api_key: def.requires_api_key,\n supports_streaming: def.supports_streaming,\n supports_thinking: def.supports_thinking,\n supports_multimodal: def.supports_multimodal,\n config_schema: None, // Will be populated by the handler\n }\n }\n}\n\nimpl BackendTypeDto {\n /// Create from BackendTypeDefinition and include config schema\n pub fn with_schema(def: BackendTypeDefinition, schema: serde_json::Value) -> Self {\n let mut dto: Self = def.into();\n dto.config_schema = Some(schema);\n dto\n }\n}\n\n/// Get the instance manager (returns error instead of panic)\nfn get_manager() -> Result, ErrorResponse> {\n get_instance_manager().map_err(|e| ErrorResponse::internal(e.to_string()))\n}\n\n/// Get backend statistics through the manager\nasync fn get_backend_stats() -> Result {\n let manager = get_manager()?;\n // Get stats from the instances\n let instances = manager.list_instances();\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n let stats = serde_json::json!({\n \"total\": instances.len(),\n \"active_id\": active_id,\n \"by_type\": {\n \"ollama\": instances.iter().filter(|i| i.backend_name() == \"ollama\").count(),\n \"openai\": instances.iter().filter(|i| i.backend_name() == \"openai\").count(),\n \"anthropic\": instances.iter().filter(|i| i.backend_name() == \"anthropic\").count(),\n \"google\": instances.iter().filter(|i| i.backend_name() == \"google\").count(),\n \"xai\": instances.iter().filter(|i| i.backend_name() == \"xai\").count(),\n }\n });\n\n Ok(stats)\n}\n\n/// List all LLM backend instances\n///\n/// GET /api/llm-backends\n///\n/// Query parameters:\n/// - type: Filter by backend type (e.g., \"ollama\", \"openai\")\n/// - active_only: Show only the active backend\npub async fn list_backends_handler(\n State(_state): State,\n Query(query): Query,\n) -> HandlerResult {\n let manager = get_manager()?;\n let mut instances = manager.list_instances();\n\n // Apply filters\n if let Some(type_filter) = query.r#type {\n instances.retain(|inst| inst.backend_name() == type_filter);\n }\n\n if query.active_only.unwrap_or(false) {\n instances.retain(|inst| inst.is_active);\n }\n\n // Convert to DTOs\n let mut dtos: Vec = instances\n .into_iter()\n .map(|inst| {\n let mut dto: BackendInstanceDto = inst.clone().into();\n // Add health status if available\n dto.healthy = manager.get_health_status(&inst.id);\n dto\n })\n .collect();\n\n // Mark active backend\n if let Some(active_id) = manager.get_active_instance().map(|inst| inst.id) {\n for dto in &mut dtos {\n dto.is_active = dto.id == active_id;\n }\n }\n\n let active_id = manager.get_active_instance().map(|i| i.id);\n\n ok(json!({\n \"backends\": dtos,\n \"count\": dtos.len(),\n \"active_id\": active_id,\n }))\n}\n\n/// Get a specific LLM backend instance\n///\n/// GET /api/llm-backends/:id\npub async fn get_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n let mut dto: BackendInstanceDto = instance.clone().into();\n dto.healthy = manager.get_health_status(&id);\n dto.is_active = manager\n .get_active_instance()\n .map(|a| a.id == id)\n .unwrap_or(false);\n\n ok(json!({\n \"backend\": dto,\n }))\n}\n\n/// Create a new LLM backend instance\n///\n/// POST /api/llm-backends\npub async fn create_backend_handler(\n State(_state): State,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Parse backend type\n let backend_type = match req.backend_type.as_str() {\n \"ollama\" => LlmBackendType::Ollama,\n \"openai\" => LlmBackendType::OpenAi,\n \"anthropic\" => LlmBackendType::Anthropic,\n \"google\" => LlmBackendType::Google,\n \"xai\" => LlmBackendType::XAi,\n _ => {\n return Err(ErrorResponse::bad_request(format!(\n \"Unknown backend type: {}\",\n req.backend_type\n )));\n }\n };\n\n // Generate unique ID\n let id = LlmBackendStore::generate_id(&req.backend_type);\n\n // Use provided capabilities or get defaults for the backend type\n let mut capabilities = req\n .capabilities\n .unwrap_or_else(|| get_default_capabilities(&backend_type));\n\n // Adjust capabilities based on actual model name\n adjust_capabilities_for_model(&req.model, &mut capabilities);\n\n let instance = LlmBackendInstance {\n id: id.clone(),\n name: req.name.clone(),\n backend_type,\n endpoint: req.endpoint,\n model: req.model,\n api_key: req.api_key,\n is_active: false,\n temperature: req.temperature,\n top_p: req.top_p,\n max_tokens: default_max_tokens(),\n top_k: req.top_k.unwrap_or(20), // Default to 20 for faster responses\n thinking_enabled: req.thinking_enabled,\n capabilities,\n updated_at: chrono::Utc::now().timestamp(),\n };\n\n // Validate\n instance\n .validate()\n .map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance created successfully\",\n }))\n}\n\n/// Update an LLM backend instance\n///\n/// PUT /api/llm-backends/:id\npub async fn update_backend_handler(\n State(_state): State,\n Path(id): Path,\n Json(req): Json,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get existing instance\n let mut instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Update fields\n if let Some(name) = req.name {\n instance.name = name;\n }\n if let Some(endpoint) = req.endpoint {\n instance.endpoint = Some(endpoint);\n }\n if let Some(model) = req.model {\n instance.model = model.clone();\n // Re-detect capabilities when model changes\n adjust_capabilities_for_model(&model, &mut instance.capabilities);\n }\n if let Some(api_key) = req.api_key {\n instance.api_key = Some(api_key);\n }\n if let Some(temperature) = req.temperature {\n instance.temperature = temperature;\n }\n if let Some(top_p) = req.top_p {\n instance.top_p = top_p;\n }\n if let Some(thinking_enabled) = req.thinking_enabled {\n instance.thinking_enabled = thinking_enabled;\n }\n if let Some(mut capabilities) = req.capabilities {\n // Adjust capabilities based on model name\n adjust_capabilities_for_model(&instance.model, &mut capabilities);\n instance.capabilities = capabilities;\n }\n instance.updated_at = chrono::Utc::now().timestamp();\n\n // Validate\n instance\n .validate()\n .map_err(ErrorResponse::bad_request)?;\n\n // Save\n manager\n .upsert_instance(instance.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Clear cache\n manager.clear_cache();\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend instance updated successfully\",\n }))\n}\n\n/// Delete an LLM backend instance\n///\n/// DELETE /api/llm-backends/:id\npub async fn delete_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n manager\n .remove_instance(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"message\": format!(\"Backend instance {} deleted\", id),\n }))\n}\n\n/// Set a backend as active\n///\n/// POST /api/llm-backends/:id/activate\npub async fn activate_backend_handler(\n State(state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n // Get the backend instance to extract its configuration\n let instance = manager\n .get_instance(&id)\n .ok_or_else(|| ErrorResponse::not_found(format!(\"Backend instance {}\", id)))?;\n\n // Set active in the instance manager\n manager\n .set_active(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also update the SessionManager's LLM backend for existing sessions\n use neomind_agent::LlmBackend;\n let backend = match instance.backend_type {\n LlmBackendType::Ollama => {\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let model = instance.model.clone();\n LlmBackend::Ollama { endpoint, model }\n }\n LlmBackendType::OpenAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.openai.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Anthropic => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.anthropic.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::Google => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://generativelanguage.googleapis.com/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n LlmBackendType::XAi => {\n let api_key = instance.api_key.clone().unwrap_or_default();\n let endpoint = instance\n .endpoint\n .clone()\n .unwrap_or_else(|| \"https://api.x.ai/v1\".to_string());\n let model = instance.model.clone();\n LlmBackend::OpenAi {\n api_key,\n endpoint,\n model,\n }\n }\n };\n\n // Update all existing sessions to use the new backend\n state\n .session_manager\n .set_llm_backend(backend.clone())\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n // Also save to SettingsStore for persistence across server restarts\n // This ensures init_llm() will load the correct backend on startup\n let settings_request = match instance.backend_type {\n LlmBackendType::Ollama => crate::config::LlmSettingsRequest {\n backend: \"ollama\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: None,\n },\n LlmBackendType::OpenAi => crate::config::LlmSettingsRequest {\n backend: \"openai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Anthropic => crate::config::LlmSettingsRequest {\n backend: \"anthropic\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::Google => crate::config::LlmSettingsRequest {\n backend: \"google\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n LlmBackendType::XAi => crate::config::LlmSettingsRequest {\n backend: \"xai\".to_string(),\n model: instance.model.clone(),\n endpoint: instance.endpoint.clone(),\n api_key: instance.api_key.clone(),\n },\n };\n\n if let Err(e) = state.save_llm_config(&settings_request).await {\n tracing::warn!(category = \"settings\", error = %e, \"Failed to save LLM config to SettingsStore after backend activation\");\n }\n\n ok(json!({\n \"id\": id,\n \"message\": \"Backend activated successfully\",\n }))\n}\n\n/// Test connection to a backend\n///\n/// POST /api/llm-backends/:id/test\npub async fn test_backend_handler(\n State(_state): State,\n Path(id): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n\n let result = manager\n .test_connection(&id)\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(json!({\n \"backend_id\": id,\n \"result\": result,\n }))\n}\n\n/// Get available backend types\n///\n/// GET /api/llm-backends/types\npub async fn list_backend_types_handler(\n State(_state): State,\n) -> HandlerResult {\n let manager = get_manager()?;\n let types = manager.get_available_types();\n\n let dtos: Vec = types\n .into_iter()\n .map(|def| {\n let schema = manager.get_config_schema(&def.id);\n BackendTypeDto::with_schema(def, schema)\n })\n .collect();\n\n ok(json!({\n \"types\": dtos,\n }))\n}\n\n/// Get configuration schema for a backend type\n///\n/// GET /api/llm-backends/types/:type/schema\npub async fn get_backend_schema_handler(\n State(_state): State,\n Path(backend_type): Path,\n) -> HandlerResult {\n let manager = get_manager()?;\n let schema = manager.get_config_schema(&backend_type);\n\n ok(json!({\n \"backend_type\": backend_type,\n \"schema\": schema,\n }))\n}\n\n/// Get backend statistics\n///\n/// GET /api/llm-backends/stats\npub async fn get_backend_stats_handler(\n State(_state): State,\n) -> HandlerResult {\n let stats = get_backend_stats()\n .await\n .map_err(|e| ErrorResponse::internal(e.to_string()))?;\n\n ok(stats)\n}\n\n/// Fetch available models from an Ollama server\n///\n/// GET /api/llm-backends/ollama/models?endpoint=http://localhost:11434\n///\n/// Returns the list of available models from the specified Ollama server,\n/// along with their detected capabilities (multimodal, thinking, tools, etc.)\n///\n/// Uses /api/show endpoint to get accurate capabilities from Ollama's response.\n/// The capabilities field contains \"vision\" for multimodal models.\npub async fn list_ollama_models_handler(\n Query(params): Query,\n) -> HandlerResult {\n use reqwest::Client;\n\n let endpoint = params.endpoint.unwrap_or_else(|| \"http://localhost:11434\".to_string());\n let base_url = endpoint.trim_end_matches('/');\n\n let client = Client::builder()\n .timeout(std::time::Duration::from_secs(30))\n .build()\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to create HTTP client: {}\", e)))?;\n\n // First, get the list of models from /api/tags\n let tags_url = format!(\"{}/api/tags\", base_url);\n let response = client\n .get(&tags_url)\n .send()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to connect to Ollama: {}\", e)))?;\n\n if !response.status().is_success() {\n return Err(ErrorResponse::internal(format!(\n \"Ollama returned status: {}\",\n response.status()\n )));\n }\n\n let ollama_response: OllamaTagsResponse = response\n .json()\n .await\n .map_err(|e| ErrorResponse::internal(format!(\"Failed to parse Ollama response: {}\", e)))?;\n\n // Enrich models with capabilities from /api/show\n let mut models_with_caps = Vec::new();\n for model in ollama_response.models {\n // Get detailed info including capabilities from /api/show\n let show_url = format!(\"{}/api/show\", base_url);\n let caps = match get_model_capabilities_from_show(&client, &show_url, &model.name).await {\n Ok(c) => c,\n Err(e) => {\n eprintln!(\"Failed to get capabilities for {}: {}, using fallback detection\", model.name, e);\n // Fallback to name-based detection if /api/show fails\n detect_ollama_model_capabilities_from_name(&model.name)\n }\n };\n\n models_with_caps.push(OllamaModelWithCapabilities {\n name: model.name,\n size: model.size,\n modified_at: model.modified_at,\n digest: model.digest,\n details: model.details,\n supports_multimodal: caps.supports_multimodal,\n supports_thinking: caps.supports_thinking,\n supports_tools: caps.supports_tools,\n max_context: caps.max_context,\n });\n }\n\n ok(json!({\n \"models\": models_with_caps,\n \"count\": models_with_caps.len(),\n }))\n}\n\n/// Get model capabilities from Ollama /api/show endpoint\n///\n/// The /api/show endpoint returns accurate capabilities including \"vision\" for multimodal models.\n/// Only thinking capability needs to be inferred from model name since Ollama doesn't provide it.\nasync fn get_model_capabilities_from_show(\n client: &reqwest::Client,\n show_url: &str,\n model_name: &str,\n) -> Result {\n use serde_json::Value;\n\n let response = client\n .post(show_url)\n .json(&serde_json::json!({ \"model\": model_name }))\n .send()\n .await\n .map_err(|e| format!(\"Request failed: {}\", e))?;\n\n if !response.status().is_success() {\n return Err(format!(\"HTTP status: {}\", response.status()));\n }\n\n let show_response: Value = response\n .json()\n .await\n .map_err(|e| format!(\"Failed to parse response: {}\", e))?;\n\n // Extract capabilities from the response\n // Ollama returns capabilities as an array like [\"completion\", \"vision\", \"tools\"]\n let supports_multimodal = show_response[\"capabilities\"]\n .as_array()\n .map(|caps| caps.iter().any(|c| c.as_str() == Some(\"vision\")))\n .unwrap_or(false);\n\n // Extract details for context size detection\n let _details = show_response[\"details\"].as_object();\n\n // Thinking capability is NOT provided by Ollama's API, need to infer from model name\n let supports_thinking = detect_thinking_from_name(model_name);\n\n // Tools support - most models support tools except very small ones\n let name_lower = model_name.to_lowercase();\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n // Detect max context from model info or details\n let max_context = if let Some(model_info) = show_response[\"model_info\"].as_object() {\n // Try to get context length from model_info\n model_info\n .iter()\n .find_map(|(k, v)| {\n if k.contains(\"context\") || k.contains(\"context_length\") {\n v.as_u64().map(|u| u as usize)\n } else {\n None\n }\n })\n .unwrap_or_else(|| detect_ollama_model_context(model_name))\n } else {\n detect_ollama_model_context(model_name)\n };\n\n Ok(BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n })\n}\n\n/// Detect thinking capability from model name only\n///\n/// Ollama's API doesn't provide thinking capability, so we infer it from model naming patterns.\nfn detect_thinking_from_name(model_name: &str) -> bool {\n let name_lower = model_name.to_lowercase();\n\n // Vision models typically don't support extended thinking\n if name_lower.contains(\"-vl\") || name_lower.ends_with(\"vl\") {\n return false;\n }\n\n name_lower.starts_with(\"qwen3\")\n || name_lower.contains(\"qwen3-\")\n || name_lower.contains(\"gpt-oss\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"deepseek-r\")\n || name_lower.contains(\"deepseek v3.1\")\n || name_lower.contains(\"deepseek-v3.1\")\n || name_lower.contains(\"thinking\")\n}\n\n/// Fallback: Detect capabilities from model name when /api/show is not available\nfn detect_ollama_model_capabilities_from_name(model_name: &str) -> BackendCapabilities {\n let name_lower = model_name.to_lowercase();\n\n // Vision capability from name patterns (fallback only)\n let supports_multimodal = name_lower.contains(\"-vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"llava\")\n || name_lower.contains(\"bakllava\")\n || name_lower.contains(\"minicpm-v\");\n\n let supports_thinking = detect_thinking_from_name(model_name);\n\n let supports_tools = !name_lower.contains(\"270m\")\n && !name_lower.contains(\"1b\")\n && !name_lower.contains(\"tiny\")\n && !name_lower.contains(\"micro\")\n && !name_lower.contains(\"nano\");\n\n let max_context = detect_ollama_model_context(model_name);\n\n BackendCapabilities {\n supports_streaming: true,\n supports_multimodal,\n supports_thinking,\n supports_tools,\n max_context,\n }\n}\n\n/// Query parameters for fetching Ollama models\n#[derive(Debug, Deserialize)]\npub struct OllamaModelsQuery {\n /// Ollama server endpoint (default: http://localhost:11434)\n pub endpoint: Option,\n}\n\n/// Ollama /api/tags response structure\n#[derive(Debug, Deserialize)]\nstruct OllamaTagsResponse {\n models: Vec,\n}\n\n#[derive(Debug, Deserialize)]\nstruct OllamaModel {\n name: String,\n #[serde(default)]\n size: Option,\n #[serde(default)]\n modified_at: Option,\n #[serde(default)]\n digest: Option,\n #[serde(default)]\n details: Option,\n}\n\n#[derive(Debug, Deserialize, Serialize)]\nstruct OllamaModelDetails {\n #[serde(default)]\n format: Option,\n #[serde(default)]\n family: Option,\n #[serde(default)]\n families: Option>,\n #[serde(default)]\n parameter_size: Option,\n #[serde(default)]\n quantization_level: Option,\n}\n\n/// Ollama model with detected capabilities\n#[derive(Debug, Serialize)]\nstruct OllamaModelWithCapabilities {\n name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n size: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n modified_at: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n digest: Option,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n details: Option,\n supports_multimodal: bool,\n supports_thinking: bool,\n supports_tools: bool,\n max_context: usize,\n}\n\n/// Detect maximum context window size for a model (works across all backends)\nfn detect_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // OpenAI models\n if name_lower.contains(\"gpt-4\") {\n if name_lower.contains(\"gpt-4-turbo\") || name_lower.contains(\"gpt-4o\") {\n return 128000;\n }\n if name_lower.contains(\"32k\") {\n return 32000;\n }\n return 8192;\n }\n\n if name_lower.contains(\"gpt-3.5\") {\n if name_lower.contains(\"16k\") {\n return 16000;\n }\n return 4096;\n }\n\n // Anthropic models\n if name_lower.contains(\"claude-3\") {\n if name_lower.contains(\"sonnet\") {\n return 200000;\n }\n if name_lower.contains(\"opus\") {\n return 200000;\n }\n if name_lower.contains(\"haiku\") {\n return 200000;\n }\n }\n\n if name_lower.contains(\"claude\") {\n return 100000;\n }\n\n // Google models\n if name_lower.contains(\"gemini-1.5\") {\n return 1000000;\n }\n\n if name_lower.contains(\"gemini\") {\n return 1000000;\n }\n\n // xAI models\n if name_lower.contains(\"grok\") {\n return 128000;\n }\n\n // Fallback to Ollama model detection for local models\n detect_ollama_model_context(model_name)\n}\n\n/// Detect maximum context window size for an Ollama model\nfn detect_ollama_model_context(model_name: &str) -> usize {\n let name_lower = model_name.to_lowercase();\n\n // Qwen family (qwen, qwen2, qwen2.5, qwen3, qwen3-vl)\n if name_lower.starts_with(\"qwen\") {\n // Qwen3 and Qwen3-VL support 32k context\n // Qwen2.5 supports 128k for some variants\n if name_lower.contains(\"qwen2\") && name_lower.contains(\"128\") {\n return 128000;\n } else if name_lower.contains(\"qwen2\") {\n return 32000;\n } else {\n return 32000;\n }\n }\n\n // Llama 3.x family\n if name_lower.contains(\"llama3\") || name_lower.contains(\"llama-3\") {\n return 8000;\n }\n\n // DeepSeek R1\n if name_lower.contains(\"deepseek-r1\") || name_lower.contains(\"deepseek-r \") {\n return 64000;\n }\n\n // Mistral family\n if name_lower.contains(\"mistral\") {\n return 32000;\n }\n\n // Gemma family\n if name_lower.contains(\"gemma\") {\n return 8000;\n }\n\n // Phi family\n if name_lower.contains(\"phi-3\") {\n return 32000;\n }\n\n // Default fallback\n 8192\n}\n\n/// Get default capabilities for a backend type\nfn get_default_capabilities(backend_type: &LlmBackendType) -> BackendCapabilities {\n match backend_type {\n LlmBackendType::Ollama => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: true,\n supports_tools: true,\n max_context: 8192,\n },\n LlmBackendType::OpenAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 128000,\n },\n LlmBackendType::Anthropic => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 200000,\n },\n LlmBackendType::Google => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: true,\n supports_thinking: false,\n supports_tools: true,\n max_context: 1000000,\n },\n LlmBackendType::XAi => BackendCapabilities {\n supports_streaming: true,\n supports_multimodal: false,\n supports_thinking: false,\n supports_tools: false,\n max_context: 128000,\n },\n }\n}\n\n/// Adjust capabilities based on the actual model name.\n/// This refines the default backend-type capabilities with model-specific knowledge.\nfn adjust_capabilities_for_model(model_name: &str, capabilities: &mut BackendCapabilities) {\n let name_lower = model_name.to_lowercase();\n\n // === Multimodal (vision) detection ===\n // Models with \"vl\", \"vision\", or \"-mm\" suffix support vision\n let explicit_vision = name_lower.contains(\"-vl\")\n || name_lower.contains(\":vl\")\n || name_lower.ends_with(\"vl\")\n || name_lower.contains(\"vision\")\n || name_lower.contains(\"-mm\")\n || name_lower.contains(\":mm\");\n\n // Vision models typically don't support extended thinking (they're optimized for speed)\n if explicit_vision {\n capabilities.supports_multimodal = true;\n capabilities.supports_thinking = false;\n } else {\n // For non-vision models, check if they might support vision\n // Most models don't, so default to false unless explicitly marked\n // Exception: some newer models might have vision but no explicit marker\n // For now, be conservative and assume no vision unless marked\n capabilities.supports_multimodal = false;\n\n // === Thinking detection ===\n // Models that support extended thinking (deepseek-r1, qwen thinking models)\n capabilities.supports_thinking = name_lower.starts_with(\"qwen3\")\n && !name_lower.contains(\"-vl\") && !name_lower.contains(\":vl\")\n || name_lower.starts_with(\"qwen2.5\")\n && !name_lower.contains(\"-vl\") && !name_lower.contains(\":vl\")\n || name_lower.contains(\"deepseek-r1\")\n || name_lower.contains(\"thinking\");\n }\n\n // === Tool support ===\n // Very small models (< 1B params) typically don't support tool calling\n if name_lower.contains(\":0.5\") || name_lower.contains(\":0.5b\") {\n capabilities.supports_tools = false;\n }\n\n // === Context size adjustment ===\n capabilities.max_context = detect_model_context(model_name);\n}\n"}}, "crates/neomind-core/src/config.rs": {"3aa27280d1dc3afc38cea7574b839852cef36274": {"t": 1785746249.009996, "n": 8494, "h": "2922f1427e61f2a3", "c": "//! 统一配置加载 - 消除重复代码\n//!\n//! 这个模块提供了项目中所有配置的默认值和辅助函数,\n//! 避免在多个 crate 中重复定义相同的常量和逻辑。\n\n/// LLM 提供商类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum LlmProvider {\n Ollama,\n OpenAi,\n Anthropic,\n Google,\n XAi,\n Qwen,\n DeepSeek,\n GLM,\n MiniMax,\n LlamaCpp,\n}\n\nimpl LlmProvider {\n /// 获取提供商的默认端点\n pub fn default_endpoint(&self) -> &str {\n match self {\n LlmProvider::Ollama => endpoints::OLLAMA,\n LlmProvider::OpenAi => endpoints::OPENAI,\n LlmProvider::Anthropic => endpoints::ANTHROPIC,\n LlmProvider::Google => endpoints::GOOGLE,\n LlmProvider::XAi => endpoints::XAI,\n LlmProvider::Qwen => endpoints::QWEN,\n LlmProvider::DeepSeek => endpoints::DEEPSEEK,\n LlmProvider::GLM => endpoints::GLM,\n LlmProvider::MiniMax => endpoints::MINIMAX,\n LlmProvider::LlamaCpp => endpoints::LLAMACPP,\n }\n }\n\n /// 获取提供商的默认模型\n pub fn default_model(&self) -> &str {\n match self {\n LlmProvider::Ollama => models::OLLAMA_DEFAULT,\n LlmProvider::OpenAi => models::OPENAI_DEFAULT,\n LlmProvider::Anthropic => models::ANTHROPIC_DEFAULT,\n LlmProvider::Google => models::GOOGLE_DEFAULT,\n LlmProvider::XAi => models::XAI_DEFAULT,\n LlmProvider::Qwen => models::QWEN_DEFAULT,\n LlmProvider::DeepSeek => models::DEEPSEEK_DEFAULT,\n LlmProvider::GLM => models::GLM_DEFAULT,\n LlmProvider::MiniMax => models::MINIMAX_DEFAULT,\n LlmProvider::LlamaCpp => models::LLAMACPP_DEFAULT,\n }\n }\n}\n\n/// 默认端点常量\npub mod endpoints {\n pub const OLLAMA: &str = \"http://localhost:11434\";\n pub const LLAMACPP: &str = \"http://127.0.0.1:8080\";\n pub const OPENAI: &str = \"https://api.openai.com/v1\";\n pub const ANTHROPIC: &str = \"https://api.anthropic.com/v1\";\n pub const GOOGLE: &str = \"https://generativelanguage.googleapis.com\";\n pub const XAI: &str = \"https://api.x.ai/v1\";\n pub const QWEN: &str = \"https://dashscope.aliyuncs.com/compatible-mode/v1\";\n pub const DEEPSEEK: &str = \"https://api.deepseek.com/v1\";\n pub const GLM: &str = \"https://open.bigmodel.cn/api/paas/v4\";\n pub const MINIMAX: &str = \"https://api.minimax.chat/v1\";\n}\n\n/// 默认模型常量\npub mod models {\n pub const OLLAMA_DEFAULT: &str = \"qwen3.5:4b\";\n pub const OPENAI_DEFAULT: &str = \"gpt-4o-mini\";\n pub const ANTHROPIC_DEFAULT: &str = \"claude-3-haiku\";\n pub const GOOGLE_DEFAULT: &str = \"gemini-pro\";\n pub const XAI_DEFAULT: &str = \"grok-beta\";\n pub const QWEN_DEFAULT: &str = \"qwen-plus\";\n pub const DEEPSEEK_DEFAULT: &str = \"deepseek-chat\";\n pub const GLM_DEFAULT: &str = \"glm-4-flash\";\n pub const MINIMAX_DEFAULT: &str = \"MiniMax-Text-01\";\n pub const LLAMACPP_DEFAULT: &str = \"\";\n}\n\n/// 环境变量名称\npub mod env_vars {\n pub const LLM_PROVIDER: &str = \"LLM_PROVIDER\";\n pub const LLM_MODEL: &str = \"LLM_MODEL\";\n pub const OLLAMA_ENDPOINT: &str = \"OLLAMA_ENDPOINT\";\n pub const OPENAI_API_KEY: &str = \"OPENAI_API_KEY\";\n pub const OPENAI_ENDPOINT: &str = \"OPENAI_ENDPOINT\";\n}\n\n/// Agent 配置常量\npub mod agent {\n /// 默认最大上下文 token 数\n pub const DEFAULT_MAX_CONTEXT_TOKENS: usize = 128000;\n /// 默认 LLM 温度(工具调用场景需要确定性)\n pub const DEFAULT_TEMPERATURE: f32 = 0.3;\n /// 默认 top-p 采样\n pub const DEFAULT_TOP_P: f32 = 0.7;\n /// 默认最大生成 token 数\n pub const DEFAULT_MAX_TOKENS: usize = 4096;\n /// 默认最大并发 LLM 请求数\n pub const DEFAULT_CONCURRENT_LIMIT: usize = 3;\n /// 默认上下文选择器 token 预算\n pub const DEFAULT_CONTEXT_SELECTOR_TOKENS: usize = 4000;\n}\n\n/// Agent 配置环境变量\npub mod agent_env_vars {\n use super::agent;\n\n pub const MAX_CONTEXT_TOKENS: &str = \"AGENT_MAX_CONTEXT_TOKENS\";\n pub const TEMPERATURE: &str = \"AGENT_TEMPERATURE\";\n pub const TOP_P: &str = \"AGENT_TOP_P\";\n pub const MAX_TOKENS: &str = \"AGENT_MAX_TOKENS\";\n pub const CONCURRENT_LIMIT: &str = \"AGENT_CONCURRENT_LIMIT\";\n pub const CONTEXT_SELECTOR_TOKENS: &str = \"AGENT_CONTEXT_SELECTOR_TOKENS\";\n /// LLM request timeout in seconds (for Ollama and OpenAI backends)\n pub const LLM_TIMEOUT_SECS: &str = \"AGENT_LLM_TIMEOUT_SECS\";\n\n /// 从环境变量获取最大上下文 token 数,或返回默认值\n pub fn max_context_tokens() -> usize {\n std::env::var(MAX_CONTEXT_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_CONTEXT_TOKENS)\n }\n\n /// 从环境变量获取温度,或返回默认值\n pub fn temperature() -> f32 {\n std::env::var(TEMPERATURE)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TEMPERATURE)\n }\n\n /// 从环境变量获取 top-p,或返回默认值\n pub fn top_p() -> f32 {\n std::env::var(TOP_P)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TOP_P)\n }\n\n /// 从环境变量获取最大生成 token 数,或返回默认值\n pub fn max_tokens() -> usize {\n std::env::var(MAX_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_TOKENS)\n }\n\n /// 从环境变量获取最大并发请求数,或返回默认值\n pub fn concurrent_limit() -> usize {\n std::env::var(CONCURRENT_LIMIT)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONCURRENT_LIMIT)\n }\n\n /// 从环境变量获取上下文选择器 token 预算,或返回默认值\n pub fn context_selector_tokens() -> usize {\n std::env::var(CONTEXT_SELECTOR_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONTEXT_SELECTOR_TOKENS)\n }\n\n /// 从环境变量获取 LLM 请求超时时间(秒),或返回默认值\n ///\n /// 默认值:\n /// - Ollama: 120 秒\n /// - OpenAI/Cloud: 60 秒\n ///\n /// 此环境变量会同时应用于两种后端\n pub fn llm_timeout_secs() -> Option {\n std::env::var(LLM_TIMEOUT_SECS)\n .ok()\n .and_then(|s| s.parse().ok())\n }\n\n /// 获取 Ollama 后端的超时时间(秒)\n pub fn ollama_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(120)\n }\n\n /// 获取 OpenAI/Cloud 后端的超时时间(秒)\n pub fn cloud_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(60)\n }\n}\n\n/// 标准化 Ollama 端点 (移除 /v1 后缀)\n///\n/// Ollama 使用原生 API,不需要 /v1 后缀\npub fn normalize_ollama_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint;\n // 移除 /v1 后缀\n if endpoint.ends_with(\"/v1\") || endpoint.ends_with(\"/v1/\") {\n endpoint = endpoint.replace(\"/v1\", \"\");\n }\n endpoint.trim_end_matches('/').to_string()\n}\n\n/// 标准化 OpenAI 兼容端点 (确保有 /v1 后缀)\n///\n/// 大多数云服务使用 OpenAI 兼容 API,需要 /v1 后缀\npub fn normalize_openai_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint.trim_end_matches('/').to_string();\n // 添加 /v1 后缀\n if !endpoint.ends_with(\"/v1\") && !endpoint.ends_with(\"/v1/\") {\n endpoint = format!(\"{}/v1\", endpoint);\n }\n endpoint\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_ollama_endpoint() {\n // 移除 /v1 后缀\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1\".to_string()),\n \"http://localhost:11434\"\n );\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1/\".to_string()),\n \"http://localhost:11434\"\n );\n // 无需修改\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434\".to_string()),\n \"http://localhost:11434\"\n );\n // 移除尾部斜杠\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/\".to_string()),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn test_normalize_openai_endpoint() {\n // 添加 /v1 后缀\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com\".to_string()),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/\".to_string()),\n \"https://api.openai.com/v1\"\n );\n // 已有 /v1 无需修改\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/v1\".to_string()),\n \"https://api.openai.com/v1\"\n );\n }\n\n #[test]\n fn test_llm_provider_defaults() {\n assert_eq!(LlmProvider::Ollama.default_endpoint(), endpoints::OLLAMA);\n assert_eq!(LlmProvider::Ollama.default_model(), models::OLLAMA_DEFAULT);\n assert_eq!(LlmProvider::OpenAi.default_endpoint(), endpoints::OPENAI);\n assert_eq!(LlmProvider::OpenAi.default_model(), models::OPENAI_DEFAULT);\n }\n}\n"}, "3d469ef786d0508561a01bd6cd6f41422e2019a3": {"t": 1785760837.6157656, "n": 8494, "h": "2922f1427e61f2a3", "c": "//! 统一配置加载 - 消除重复代码\n//!\n//! 这个模块提供了项目中所有配置的默认值和辅助函数,\n//! 避免在多个 crate 中重复定义相同的常量和逻辑。\n\n/// LLM 提供商类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum LlmProvider {\n Ollama,\n OpenAi,\n Anthropic,\n Google,\n XAi,\n Qwen,\n DeepSeek,\n GLM,\n MiniMax,\n LlamaCpp,\n}\n\nimpl LlmProvider {\n /// 获取提供商的默认端点\n pub fn default_endpoint(&self) -> &str {\n match self {\n LlmProvider::Ollama => endpoints::OLLAMA,\n LlmProvider::OpenAi => endpoints::OPENAI,\n LlmProvider::Anthropic => endpoints::ANTHROPIC,\n LlmProvider::Google => endpoints::GOOGLE,\n LlmProvider::XAi => endpoints::XAI,\n LlmProvider::Qwen => endpoints::QWEN,\n LlmProvider::DeepSeek => endpoints::DEEPSEEK,\n LlmProvider::GLM => endpoints::GLM,\n LlmProvider::MiniMax => endpoints::MINIMAX,\n LlmProvider::LlamaCpp => endpoints::LLAMACPP,\n }\n }\n\n /// 获取提供商的默认模型\n pub fn default_model(&self) -> &str {\n match self {\n LlmProvider::Ollama => models::OLLAMA_DEFAULT,\n LlmProvider::OpenAi => models::OPENAI_DEFAULT,\n LlmProvider::Anthropic => models::ANTHROPIC_DEFAULT,\n LlmProvider::Google => models::GOOGLE_DEFAULT,\n LlmProvider::XAi => models::XAI_DEFAULT,\n LlmProvider::Qwen => models::QWEN_DEFAULT,\n LlmProvider::DeepSeek => models::DEEPSEEK_DEFAULT,\n LlmProvider::GLM => models::GLM_DEFAULT,\n LlmProvider::MiniMax => models::MINIMAX_DEFAULT,\n LlmProvider::LlamaCpp => models::LLAMACPP_DEFAULT,\n }\n }\n}\n\n/// 默认端点常量\npub mod endpoints {\n pub const OLLAMA: &str = \"http://localhost:11434\";\n pub const LLAMACPP: &str = \"http://127.0.0.1:8080\";\n pub const OPENAI: &str = \"https://api.openai.com/v1\";\n pub const ANTHROPIC: &str = \"https://api.anthropic.com/v1\";\n pub const GOOGLE: &str = \"https://generativelanguage.googleapis.com\";\n pub const XAI: &str = \"https://api.x.ai/v1\";\n pub const QWEN: &str = \"https://dashscope.aliyuncs.com/compatible-mode/v1\";\n pub const DEEPSEEK: &str = \"https://api.deepseek.com/v1\";\n pub const GLM: &str = \"https://open.bigmodel.cn/api/paas/v4\";\n pub const MINIMAX: &str = \"https://api.minimax.chat/v1\";\n}\n\n/// 默认模型常量\npub mod models {\n pub const OLLAMA_DEFAULT: &str = \"qwen3.5:4b\";\n pub const OPENAI_DEFAULT: &str = \"gpt-4o-mini\";\n pub const ANTHROPIC_DEFAULT: &str = \"claude-3-haiku\";\n pub const GOOGLE_DEFAULT: &str = \"gemini-pro\";\n pub const XAI_DEFAULT: &str = \"grok-beta\";\n pub const QWEN_DEFAULT: &str = \"qwen-plus\";\n pub const DEEPSEEK_DEFAULT: &str = \"deepseek-chat\";\n pub const GLM_DEFAULT: &str = \"glm-4-flash\";\n pub const MINIMAX_DEFAULT: &str = \"MiniMax-Text-01\";\n pub const LLAMACPP_DEFAULT: &str = \"\";\n}\n\n/// 环境变量名称\npub mod env_vars {\n pub const LLM_PROVIDER: &str = \"LLM_PROVIDER\";\n pub const LLM_MODEL: &str = \"LLM_MODEL\";\n pub const OLLAMA_ENDPOINT: &str = \"OLLAMA_ENDPOINT\";\n pub const OPENAI_API_KEY: &str = \"OPENAI_API_KEY\";\n pub const OPENAI_ENDPOINT: &str = \"OPENAI_ENDPOINT\";\n}\n\n/// Agent 配置常量\npub mod agent {\n /// 默认最大上下文 token 数\n pub const DEFAULT_MAX_CONTEXT_TOKENS: usize = 128000;\n /// 默认 LLM 温度(工具调用场景需要确定性)\n pub const DEFAULT_TEMPERATURE: f32 = 0.3;\n /// 默认 top-p 采样\n pub const DEFAULT_TOP_P: f32 = 0.7;\n /// 默认最大生成 token 数\n pub const DEFAULT_MAX_TOKENS: usize = 4096;\n /// 默认最大并发 LLM 请求数\n pub const DEFAULT_CONCURRENT_LIMIT: usize = 3;\n /// 默认上下文选择器 token 预算\n pub const DEFAULT_CONTEXT_SELECTOR_TOKENS: usize = 4000;\n}\n\n/// Agent 配置环境变量\npub mod agent_env_vars {\n use super::agent;\n\n pub const MAX_CONTEXT_TOKENS: &str = \"AGENT_MAX_CONTEXT_TOKENS\";\n pub const TEMPERATURE: &str = \"AGENT_TEMPERATURE\";\n pub const TOP_P: &str = \"AGENT_TOP_P\";\n pub const MAX_TOKENS: &str = \"AGENT_MAX_TOKENS\";\n pub const CONCURRENT_LIMIT: &str = \"AGENT_CONCURRENT_LIMIT\";\n pub const CONTEXT_SELECTOR_TOKENS: &str = \"AGENT_CONTEXT_SELECTOR_TOKENS\";\n /// LLM request timeout in seconds (for Ollama and OpenAI backends)\n pub const LLM_TIMEOUT_SECS: &str = \"AGENT_LLM_TIMEOUT_SECS\";\n\n /// 从环境变量获取最大上下文 token 数,或返回默认值\n pub fn max_context_tokens() -> usize {\n std::env::var(MAX_CONTEXT_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_CONTEXT_TOKENS)\n }\n\n /// 从环境变量获取温度,或返回默认值\n pub fn temperature() -> f32 {\n std::env::var(TEMPERATURE)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TEMPERATURE)\n }\n\n /// 从环境变量获取 top-p,或返回默认值\n pub fn top_p() -> f32 {\n std::env::var(TOP_P)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TOP_P)\n }\n\n /// 从环境变量获取最大生成 token 数,或返回默认值\n pub fn max_tokens() -> usize {\n std::env::var(MAX_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_TOKENS)\n }\n\n /// 从环境变量获取最大并发请求数,或返回默认值\n pub fn concurrent_limit() -> usize {\n std::env::var(CONCURRENT_LIMIT)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONCURRENT_LIMIT)\n }\n\n /// 从环境变量获取上下文选择器 token 预算,或返回默认值\n pub fn context_selector_tokens() -> usize {\n std::env::var(CONTEXT_SELECTOR_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONTEXT_SELECTOR_TOKENS)\n }\n\n /// 从环境变量获取 LLM 请求超时时间(秒),或返回默认值\n ///\n /// 默认值:\n /// - Ollama: 120 秒\n /// - OpenAI/Cloud: 60 秒\n ///\n /// 此环境变量会同时应用于两种后端\n pub fn llm_timeout_secs() -> Option {\n std::env::var(LLM_TIMEOUT_SECS)\n .ok()\n .and_then(|s| s.parse().ok())\n }\n\n /// 获取 Ollama 后端的超时时间(秒)\n pub fn ollama_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(120)\n }\n\n /// 获取 OpenAI/Cloud 后端的超时时间(秒)\n pub fn cloud_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(60)\n }\n}\n\n/// 标准化 Ollama 端点 (移除 /v1 后缀)\n///\n/// Ollama 使用原生 API,不需要 /v1 后缀\npub fn normalize_ollama_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint;\n // 移除 /v1 后缀\n if endpoint.ends_with(\"/v1\") || endpoint.ends_with(\"/v1/\") {\n endpoint = endpoint.replace(\"/v1\", \"\");\n }\n endpoint.trim_end_matches('/').to_string()\n}\n\n/// 标准化 OpenAI 兼容端点 (确保有 /v1 后缀)\n///\n/// 大多数云服务使用 OpenAI 兼容 API,需要 /v1 后缀\npub fn normalize_openai_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint.trim_end_matches('/').to_string();\n // 添加 /v1 后缀\n if !endpoint.ends_with(\"/v1\") && !endpoint.ends_with(\"/v1/\") {\n endpoint = format!(\"{}/v1\", endpoint);\n }\n endpoint\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_ollama_endpoint() {\n // 移除 /v1 后缀\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1\".to_string()),\n \"http://localhost:11434\"\n );\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1/\".to_string()),\n \"http://localhost:11434\"\n );\n // 无需修改\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434\".to_string()),\n \"http://localhost:11434\"\n );\n // 移除尾部斜杠\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/\".to_string()),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn test_normalize_openai_endpoint() {\n // 添加 /v1 后缀\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com\".to_string()),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/\".to_string()),\n \"https://api.openai.com/v1\"\n );\n // 已有 /v1 无需修改\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/v1\".to_string()),\n \"https://api.openai.com/v1\"\n );\n }\n\n #[test]\n fn test_llm_provider_defaults() {\n assert_eq!(LlmProvider::Ollama.default_endpoint(), endpoints::OLLAMA);\n assert_eq!(LlmProvider::Ollama.default_model(), models::OLLAMA_DEFAULT);\n assert_eq!(LlmProvider::OpenAi.default_endpoint(), endpoints::OPENAI);\n assert_eq!(LlmProvider::OpenAi.default_model(), models::OPENAI_DEFAULT);\n }\n}\n"}, "6561b4b0a54ddbdefd2d55a469ec2bde495bbd66": {"t": 1785760838.9750998, "n": 7433, "h": "ebb11436ffed62a3", "c": "//! 统一配置加载 - 消除重复代码\n//!\n//! 这个模块提供了项目中所有配置的默认值和辅助函数,\n//! 避免在多个 crate 中重复定义相同的常量和逻辑。\n\n/// LLM 提供商类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum LlmProvider {\n Ollama,\n OpenAi,\n Anthropic,\n Google,\n XAi,\n}\n\nimpl LlmProvider {\n /// 获取提供商的默认端点\n pub fn default_endpoint(&self) -> &str {\n match self {\n LlmProvider::Ollama => endpoints::OLLAMA,\n LlmProvider::OpenAi => endpoints::OPENAI,\n LlmProvider::Anthropic => endpoints::ANTHROPIC,\n LlmProvider::Google => endpoints::GOOGLE,\n LlmProvider::XAi => endpoints::XAI,\n }\n }\n\n /// 获取提供商的默认模型\n pub fn default_model(&self) -> &str {\n match self {\n LlmProvider::Ollama => models::OLLAMA_DEFAULT,\n LlmProvider::OpenAi => models::OPENAI_DEFAULT,\n LlmProvider::Anthropic => \"claude-3-haiku\",\n LlmProvider::Google => \"gemini-pro\",\n LlmProvider::XAi => \"grok-beta\",\n }\n }\n}\n\n/// 默认端点常量\npub mod endpoints {\n pub const OLLAMA: &str = \"http://localhost:11434\";\n pub const LLAMACPP: &str = \"http://127.0.0.1:8080\";\n pub const OPENAI: &str = \"https://api.openai.com/v1\";\n pub const ANTHROPIC: &str = \"https://api.anthropic.com/v1\";\n pub const GOOGLE: &str = \"https://generativelanguage.googleapis.com\";\n pub const XAI: &str = \"https://api.x.ai/v1\";\n pub const QWEN: &str = \"https://dashscope.aliyuncs.com/compatible-mode/v1\";\n pub const DEEPSEEK: &str = \"https://api.deepseek.com/v1\";\n pub const GLM: &str = \"https://open.bigmodel.cn/api/paas/v4\";\n pub const MINIMAX: &str = \"https://api.minimax.chat/v1\";\n}\n\n/// 默认模型常量\npub mod models {\n pub const OLLAMA_DEFAULT: &str = \"qwen3.5:4b\";\n pub const OPENAI_DEFAULT: &str = \"gpt-4o-mini\";\n}\n\n/// 环境变量名称\npub mod env_vars {\n pub const LLM_PROVIDER: &str = \"LLM_PROVIDER\";\n pub const LLM_MODEL: &str = \"LLM_MODEL\";\n pub const OLLAMA_ENDPOINT: &str = \"OLLAMA_ENDPOINT\";\n pub const OPENAI_API_KEY: &str = \"OPENAI_API_KEY\";\n pub const OPENAI_ENDPOINT: &str = \"OPENAI_ENDPOINT\";\n}\n\n/// Agent 配置常量\npub mod agent {\n /// 默认最大上下文 token 数\n pub const DEFAULT_MAX_CONTEXT_TOKENS: usize = 128000;\n /// 默认 LLM 温度(工具调用场景需要确定性)\n pub const DEFAULT_TEMPERATURE: f32 = 0.3;\n /// 默认 top-p 采样\n pub const DEFAULT_TOP_P: f32 = 0.7;\n /// 默认最大生成 token 数\n pub const DEFAULT_MAX_TOKENS: usize = 4096;\n /// 默认最大并发 LLM 请求数\n pub const DEFAULT_CONCURRENT_LIMIT: usize = 3;\n /// 默认上下文选择器 token 预算\n pub const DEFAULT_CONTEXT_SELECTOR_TOKENS: usize = 4000;\n}\n\n/// Agent 配置环境变量\npub mod agent_env_vars {\n use super::agent;\n\n pub const MAX_CONTEXT_TOKENS: &str = \"AGENT_MAX_CONTEXT_TOKENS\";\n pub const TEMPERATURE: &str = \"AGENT_TEMPERATURE\";\n pub const TOP_P: &str = \"AGENT_TOP_P\";\n pub const MAX_TOKENS: &str = \"AGENT_MAX_TOKENS\";\n pub const CONCURRENT_LIMIT: &str = \"AGENT_CONCURRENT_LIMIT\";\n pub const CONTEXT_SELECTOR_TOKENS: &str = \"AGENT_CONTEXT_SELECTOR_TOKENS\";\n /// LLM request timeout in seconds (for Ollama and OpenAI backends)\n pub const LLM_TIMEOUT_SECS: &str = \"AGENT_LLM_TIMEOUT_SECS\";\n\n /// 从环境变量获取最大上下文 token 数,或返回默认值\n pub fn max_context_tokens() -> usize {\n std::env::var(MAX_CONTEXT_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_CONTEXT_TOKENS)\n }\n\n /// 从环境变量获取温度,或返回默认值\n pub fn temperature() -> f32 {\n std::env::var(TEMPERATURE)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TEMPERATURE)\n }\n\n /// 从环境变量获取 top-p,或返回默认值\n pub fn top_p() -> f32 {\n std::env::var(TOP_P)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TOP_P)\n }\n\n /// 从环境变量获取最大生成 token 数,或返回默认值\n pub fn max_tokens() -> usize {\n std::env::var(MAX_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_TOKENS)\n }\n\n /// 从环境变量获取最大并发请求数,或返回默认值\n pub fn concurrent_limit() -> usize {\n std::env::var(CONCURRENT_LIMIT)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONCURRENT_LIMIT)\n }\n\n /// 从环境变量获取上下文选择器 token 预算,或返回默认值\n pub fn context_selector_tokens() -> usize {\n std::env::var(CONTEXT_SELECTOR_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONTEXT_SELECTOR_TOKENS)\n }\n\n /// 从环境变量获取 LLM 请求超时时间(秒),或返回默认值\n ///\n /// 默认值:\n /// - Ollama: 180 秒\n /// - OpenAI/Cloud: 60 秒\n ///\n /// 此环境变量会同时应用于两种后端\n pub fn llm_timeout_secs() -> Option {\n std::env::var(LLM_TIMEOUT_SECS)\n .ok()\n .and_then(|s| s.parse().ok())\n }\n\n /// 获取 Ollama 后端的超时时间(秒)\n pub fn ollama_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(300)\n }\n\n /// 获取 OpenAI/Cloud 后端的超时时间(秒)\n pub fn cloud_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(60)\n }\n}\n\n/// 标准化 Ollama 端点 (移除 /v1 后缀)\n///\n/// Ollama 使用原生 API,不需要 /v1 后缀\npub fn normalize_ollama_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint;\n // 移除 /v1 后缀\n if endpoint.ends_with(\"/v1\") || endpoint.ends_with(\"/v1/\") {\n endpoint = endpoint.replace(\"/v1\", \"\");\n }\n endpoint.trim_end_matches('/').to_string()\n}\n\n/// 标准化 OpenAI 兼容端点 (确保有 /v1 后缀)\n///\n/// 大多数云服务使用 OpenAI 兼容 API,需要 /v1 后缀\npub fn normalize_openai_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint.trim_end_matches('/').to_string();\n // 添加 /v1 后缀\n if !endpoint.ends_with(\"/v1\") && !endpoint.ends_with(\"/v1/\") {\n endpoint = format!(\"{}/v1\", endpoint);\n }\n endpoint\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_ollama_endpoint() {\n // 移除 /v1 后缀\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1\".to_string()),\n \"http://localhost:11434\"\n );\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1/\".to_string()),\n \"http://localhost:11434\"\n );\n // 无需修改\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434\".to_string()),\n \"http://localhost:11434\"\n );\n // 移除尾部斜杠\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/\".to_string()),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn test_normalize_openai_endpoint() {\n // 添加 /v1 后缀\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com\".to_string()),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/\".to_string()),\n \"https://api.openai.com/v1\"\n );\n // 已有 /v1 无需修改\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/v1\".to_string()),\n \"https://api.openai.com/v1\"\n );\n }\n\n #[test]\n fn test_llm_provider_defaults() {\n assert_eq!(LlmProvider::Ollama.default_endpoint(), endpoints::OLLAMA);\n assert_eq!(LlmProvider::Ollama.default_model(), models::OLLAMA_DEFAULT);\n assert_eq!(LlmProvider::OpenAi.default_endpoint(), endpoints::OPENAI);\n assert_eq!(LlmProvider::OpenAi.default_model(), models::OPENAI_DEFAULT);\n }\n}\n"}, "aa4d3f97042a32b8ebab02a76ed2bc77aee53126": {"t": 1785760840.416075, "n": 7437, "h": "7556ab25d641472e", "c": "//! 统一配置加载 - 消除重复代码\n//!\n//! 这个模块提供了项目中所有配置的默认值和辅助函数,\n//! 避免在多个 crate 中重复定义相同的常量和逻辑。\n\n/// LLM 提供商类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum LlmProvider {\n Ollama,\n OpenAi,\n Anthropic,\n Google,\n XAi,\n}\n\nimpl LlmProvider {\n /// 获取提供商的默认端点\n pub fn default_endpoint(&self) -> &str {\n match self {\n LlmProvider::Ollama => endpoints::OLLAMA,\n LlmProvider::OpenAi => endpoints::OPENAI,\n LlmProvider::Anthropic => endpoints::ANTHROPIC,\n LlmProvider::Google => endpoints::GOOGLE,\n LlmProvider::XAi => endpoints::XAI,\n }\n }\n\n /// 获取提供商的默认模型\n pub fn default_model(&self) -> &str {\n match self {\n LlmProvider::Ollama => models::OLLAMA_DEFAULT,\n LlmProvider::OpenAi => models::OPENAI_DEFAULT,\n LlmProvider::Anthropic => \"claude-3-haiku\",\n LlmProvider::Google => \"gemini-pro\",\n LlmProvider::XAi => \"grok-beta\",\n }\n }\n}\n\n/// 默认端点常量\npub mod endpoints {\n pub const OLLAMA: &str = \"http://localhost:11434\";\n pub const LLAMACPP: &str = \"http://127.0.0.1:8080\";\n pub const OPENAI: &str = \"https://api.openai.com/v1\";\n pub const ANTHROPIC: &str = \"https://api.anthropic.com/v1\";\n pub const GOOGLE: &str = \"https://generativelanguage.googleapis.com\";\n pub const XAI: &str = \"https://api.x.ai/v1\";\n pub const QWEN: &str = \"https://dashscope.aliyuncs.com/compatible-mode/v1\";\n pub const DEEPSEEK: &str = \"https://api.deepseek.com/v1\";\n pub const GLM: &str = \"https://open.bigmodel.cn/api/paas/v4\";\n pub const MINIMAX: &str = \"https://api.minimax.chat/v1\";\n}\n\n/// 默认模型常量\npub mod models {\n pub const OLLAMA_DEFAULT: &str = \"ministral-3:3b\";\n pub const OPENAI_DEFAULT: &str = \"gpt-4o-mini\";\n}\n\n/// 环境变量名称\npub mod env_vars {\n pub const LLM_PROVIDER: &str = \"LLM_PROVIDER\";\n pub const LLM_MODEL: &str = \"LLM_MODEL\";\n pub const OLLAMA_ENDPOINT: &str = \"OLLAMA_ENDPOINT\";\n pub const OPENAI_API_KEY: &str = \"OPENAI_API_KEY\";\n pub const OPENAI_ENDPOINT: &str = \"OPENAI_ENDPOINT\";\n}\n\n/// Agent 配置常量\npub mod agent {\n /// 默认最大上下文 token 数\n pub const DEFAULT_MAX_CONTEXT_TOKENS: usize = 128000;\n /// 默认 LLM 温度(工具调用场景需要确定性)\n pub const DEFAULT_TEMPERATURE: f32 = 0.3;\n /// 默认 top-p 采样\n pub const DEFAULT_TOP_P: f32 = 0.7;\n /// 默认最大生成 token 数\n pub const DEFAULT_MAX_TOKENS: usize = 4096;\n /// 默认最大并发 LLM 请求数\n pub const DEFAULT_CONCURRENT_LIMIT: usize = 3;\n /// 默认上下文选择器 token 预算\n pub const DEFAULT_CONTEXT_SELECTOR_TOKENS: usize = 4000;\n}\n\n/// Agent 配置环境变量\npub mod agent_env_vars {\n use super::agent;\n\n pub const MAX_CONTEXT_TOKENS: &str = \"AGENT_MAX_CONTEXT_TOKENS\";\n pub const TEMPERATURE: &str = \"AGENT_TEMPERATURE\";\n pub const TOP_P: &str = \"AGENT_TOP_P\";\n pub const MAX_TOKENS: &str = \"AGENT_MAX_TOKENS\";\n pub const CONCURRENT_LIMIT: &str = \"AGENT_CONCURRENT_LIMIT\";\n pub const CONTEXT_SELECTOR_TOKENS: &str = \"AGENT_CONTEXT_SELECTOR_TOKENS\";\n /// LLM request timeout in seconds (for Ollama and OpenAI backends)\n pub const LLM_TIMEOUT_SECS: &str = \"AGENT_LLM_TIMEOUT_SECS\";\n\n /// 从环境变量获取最大上下文 token 数,或返回默认值\n pub fn max_context_tokens() -> usize {\n std::env::var(MAX_CONTEXT_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_CONTEXT_TOKENS)\n }\n\n /// 从环境变量获取温度,或返回默认值\n pub fn temperature() -> f32 {\n std::env::var(TEMPERATURE)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TEMPERATURE)\n }\n\n /// 从环境变量获取 top-p,或返回默认值\n pub fn top_p() -> f32 {\n std::env::var(TOP_P)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TOP_P)\n }\n\n /// 从环境变量获取最大生成 token 数,或返回默认值\n pub fn max_tokens() -> usize {\n std::env::var(MAX_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_TOKENS)\n }\n\n /// 从环境变量获取最大并发请求数,或返回默认值\n pub fn concurrent_limit() -> usize {\n std::env::var(CONCURRENT_LIMIT)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONCURRENT_LIMIT)\n }\n\n /// 从环境变量获取上下文选择器 token 预算,或返回默认值\n pub fn context_selector_tokens() -> usize {\n std::env::var(CONTEXT_SELECTOR_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONTEXT_SELECTOR_TOKENS)\n }\n\n /// 从环境变量获取 LLM 请求超时时间(秒),或返回默认值\n ///\n /// 默认值:\n /// - Ollama: 180 秒\n /// - OpenAI/Cloud: 60 秒\n ///\n /// 此环境变量会同时应用于两种后端\n pub fn llm_timeout_secs() -> Option {\n std::env::var(LLM_TIMEOUT_SECS)\n .ok()\n .and_then(|s| s.parse().ok())\n }\n\n /// 获取 Ollama 后端的超时时间(秒)\n pub fn ollama_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(300)\n }\n\n /// 获取 OpenAI/Cloud 后端的超时时间(秒)\n pub fn cloud_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(60)\n }\n}\n\n/// 标准化 Ollama 端点 (移除 /v1 后缀)\n///\n/// Ollama 使用原生 API,不需要 /v1 后缀\npub fn normalize_ollama_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint;\n // 移除 /v1 后缀\n if endpoint.ends_with(\"/v1\") || endpoint.ends_with(\"/v1/\") {\n endpoint = endpoint.replace(\"/v1\", \"\");\n }\n endpoint.trim_end_matches('/').to_string()\n}\n\n/// 标准化 OpenAI 兼容端点 (确保有 /v1 后缀)\n///\n/// 大多数云服务使用 OpenAI 兼容 API,需要 /v1 后缀\npub fn normalize_openai_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint.trim_end_matches('/').to_string();\n // 添加 /v1 后缀\n if !endpoint.ends_with(\"/v1\") && !endpoint.ends_with(\"/v1/\") {\n endpoint = format!(\"{}/v1\", endpoint);\n }\n endpoint\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_ollama_endpoint() {\n // 移除 /v1 后缀\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1\".to_string()),\n \"http://localhost:11434\"\n );\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1/\".to_string()),\n \"http://localhost:11434\"\n );\n // 无需修改\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434\".to_string()),\n \"http://localhost:11434\"\n );\n // 移除尾部斜杠\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/\".to_string()),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn test_normalize_openai_endpoint() {\n // 添加 /v1 后缀\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com\".to_string()),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/\".to_string()),\n \"https://api.openai.com/v1\"\n );\n // 已有 /v1 无需修改\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/v1\".to_string()),\n \"https://api.openai.com/v1\"\n );\n }\n\n #[test]\n fn test_llm_provider_defaults() {\n assert_eq!(LlmProvider::Ollama.default_endpoint(), endpoints::OLLAMA);\n assert_eq!(LlmProvider::Ollama.default_model(), models::OLLAMA_DEFAULT);\n assert_eq!(LlmProvider::OpenAi.default_endpoint(), endpoints::OPENAI);\n assert_eq!(LlmProvider::OpenAi.default_model(), models::OPENAI_DEFAULT);\n }\n}\n"}, "da20140535e412619841312f93d44a4bee704b95": {"t": 1785760841.5726292, "n": 7437, "h": "a5269ab092ea2b0b", "c": "//! 统一配置加载 - 消除重复代码\n//!\n//! 这个模块提供了项目中所有配置的默认值和辅助函数,\n//! 避免在多个 crate 中重复定义相同的常量和逻辑。\n\n/// LLM 提供商类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum LlmProvider {\n Ollama,\n OpenAi,\n Anthropic,\n Google,\n XAi,\n}\n\nimpl LlmProvider {\n /// 获取提供商的默认端点\n pub fn default_endpoint(&self) -> &str {\n match self {\n LlmProvider::Ollama => endpoints::OLLAMA,\n LlmProvider::OpenAi => endpoints::OPENAI,\n LlmProvider::Anthropic => endpoints::ANTHROPIC,\n LlmProvider::Google => endpoints::GOOGLE,\n LlmProvider::XAi => endpoints::XAI,\n }\n }\n\n /// 获取提供商的默认模型\n pub fn default_model(&self) -> &str {\n match self {\n LlmProvider::Ollama => models::OLLAMA_DEFAULT,\n LlmProvider::OpenAi => models::OPENAI_DEFAULT,\n LlmProvider::Anthropic => \"claude-3-haiku\",\n LlmProvider::Google => \"gemini-pro\",\n LlmProvider::XAi => \"grok-beta\",\n }\n }\n}\n\n/// 默认端点常量\npub mod endpoints {\n pub const OLLAMA: &str = \"http://localhost:11434\";\n pub const LLAMACPP: &str = \"http://127.0.0.1:8080\";\n pub const OPENAI: &str = \"https://api.openai.com/v1\";\n pub const ANTHROPIC: &str = \"https://api.anthropic.com/v1\";\n pub const GOOGLE: &str = \"https://generativelanguage.googleapis.com\";\n pub const XAI: &str = \"https://api.x.ai/v1\";\n pub const QWEN: &str = \"https://dashscope.aliyuncs.com/compatible-mode/v1\";\n pub const DEEPSEEK: &str = \"https://api.deepseek.com/v1\";\n pub const GLM: &str = \"https://open.bigmodel.cn/api/paas/v4\";\n pub const MINIMAX: &str = \"https://api.minimax.chat/v1\";\n}\n\n/// 默认模型常量\npub mod models {\n pub const OLLAMA_DEFAULT: &str = \"ministral-3:3b\";\n pub const OPENAI_DEFAULT: &str = \"gpt-4o-mini\";\n}\n\n/// 环境变量名称\npub mod env_vars {\n pub const LLM_PROVIDER: &str = \"LLM_PROVIDER\";\n pub const LLM_MODEL: &str = \"LLM_MODEL\";\n pub const OLLAMA_ENDPOINT: &str = \"OLLAMA_ENDPOINT\";\n pub const OPENAI_API_KEY: &str = \"OPENAI_API_KEY\";\n pub const OPENAI_ENDPOINT: &str = \"OPENAI_ENDPOINT\";\n}\n\n/// Agent 配置常量\npub mod agent {\n /// 默认最大上下文 token 数\n pub const DEFAULT_MAX_CONTEXT_TOKENS: usize = 128000;\n /// 默认 LLM 温度(工具调用场景需要确定性)\n pub const DEFAULT_TEMPERATURE: f32 = 0.3;\n /// 默认 top-p 采样\n pub const DEFAULT_TOP_P: f32 = 0.7;\n /// 默认最大生成 token 数\n pub const DEFAULT_MAX_TOKENS: usize = 4096;\n /// 默认最大并发 LLM 请求数\n pub const DEFAULT_CONCURRENT_LIMIT: usize = 3;\n /// 默认上下文选择器 token 预算\n pub const DEFAULT_CONTEXT_SELECTOR_TOKENS: usize = 4000;\n}\n\n/// Agent 配置环境变量\npub mod agent_env_vars {\n use super::agent;\n\n pub const MAX_CONTEXT_TOKENS: &str = \"AGENT_MAX_CONTEXT_TOKENS\";\n pub const TEMPERATURE: &str = \"AGENT_TEMPERATURE\";\n pub const TOP_P: &str = \"AGENT_TOP_P\";\n pub const MAX_TOKENS: &str = \"AGENT_MAX_TOKENS\";\n pub const CONCURRENT_LIMIT: &str = \"AGENT_CONCURRENT_LIMIT\";\n pub const CONTEXT_SELECTOR_TOKENS: &str = \"AGENT_CONTEXT_SELECTOR_TOKENS\";\n /// LLM request timeout in seconds (for Ollama and OpenAI backends)\n pub const LLM_TIMEOUT_SECS: &str = \"AGENT_LLM_TIMEOUT_SECS\";\n\n /// 从环境变量获取最大上下文 token 数,或返回默认值\n pub fn max_context_tokens() -> usize {\n std::env::var(MAX_CONTEXT_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_CONTEXT_TOKENS)\n }\n\n /// 从环境变量获取温度,或返回默认值\n pub fn temperature() -> f32 {\n std::env::var(TEMPERATURE)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TEMPERATURE)\n }\n\n /// 从环境变量获取 top-p,或返回默认值\n pub fn top_p() -> f32 {\n std::env::var(TOP_P)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TOP_P)\n }\n\n /// 从环境变量获取最大生成 token 数,或返回默认值\n pub fn max_tokens() -> usize {\n std::env::var(MAX_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_TOKENS)\n }\n\n /// 从环境变量获取最大并发请求数,或返回默认值\n pub fn concurrent_limit() -> usize {\n std::env::var(CONCURRENT_LIMIT)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONCURRENT_LIMIT)\n }\n\n /// 从环境变量获取上下文选择器 token 预算,或返回默认值\n pub fn context_selector_tokens() -> usize {\n std::env::var(CONTEXT_SELECTOR_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONTEXT_SELECTOR_TOKENS)\n }\n\n /// 从环境变量获取 LLM 请求超时时间(秒),或返回默认值\n ///\n /// 默认值:\n /// - Ollama: 180 秒\n /// - OpenAI/Cloud: 60 秒\n ///\n /// 此环境变量会同时应用于两种后端\n pub fn llm_timeout_secs() -> Option {\n std::env::var(LLM_TIMEOUT_SECS)\n .ok()\n .and_then(|s| s.parse().ok())\n }\n\n /// 获取 Ollama 后端的超时时间(秒)\n pub fn ollama_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(180)\n }\n\n /// 获取 OpenAI/Cloud 后端的超时时间(秒)\n pub fn cloud_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(60)\n }\n}\n\n/// 标准化 Ollama 端点 (移除 /v1 后缀)\n///\n/// Ollama 使用原生 API,不需要 /v1 后缀\npub fn normalize_ollama_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint;\n // 移除 /v1 后缀\n if endpoint.ends_with(\"/v1\") || endpoint.ends_with(\"/v1/\") {\n endpoint = endpoint.replace(\"/v1\", \"\");\n }\n endpoint.trim_end_matches('/').to_string()\n}\n\n/// 标准化 OpenAI 兼容端点 (确保有 /v1 后缀)\n///\n/// 大多数云服务使用 OpenAI 兼容 API,需要 /v1 后缀\npub fn normalize_openai_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint.trim_end_matches('/').to_string();\n // 添加 /v1 后缀\n if !endpoint.ends_with(\"/v1\") && !endpoint.ends_with(\"/v1/\") {\n endpoint = format!(\"{}/v1\", endpoint);\n }\n endpoint\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_ollama_endpoint() {\n // 移除 /v1 后缀\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1\".to_string()),\n \"http://localhost:11434\"\n );\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1/\".to_string()),\n \"http://localhost:11434\"\n );\n // 无需修改\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434\".to_string()),\n \"http://localhost:11434\"\n );\n // 移除尾部斜杠\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/\".to_string()),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn test_normalize_openai_endpoint() {\n // 添加 /v1 后缀\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com\".to_string()),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/\".to_string()),\n \"https://api.openai.com/v1\"\n );\n // 已有 /v1 无需修改\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/v1\".to_string()),\n \"https://api.openai.com/v1\"\n );\n }\n\n #[test]\n fn test_llm_provider_defaults() {\n assert_eq!(LlmProvider::Ollama.default_endpoint(), endpoints::OLLAMA);\n assert_eq!(LlmProvider::Ollama.default_model(), models::OLLAMA_DEFAULT);\n assert_eq!(LlmProvider::OpenAi.default_endpoint(), endpoints::OPENAI);\n assert_eq!(LlmProvider::OpenAi.default_model(), models::OPENAI_DEFAULT);\n }\n}\n"}, "e28d4b5451030318e2e646a1a545cc8e14d86ba5": {"t": 1785760866.121841, "n": 7422, "h": "ceb38fc7847bff30", "c": "//! 统一配置加载 - 消除重复代码\n//!\n//! 这个模块提供了项目中所有配置的默认值和辅助函数,\n//! 避免在多个 crate 中重复定义相同的常量和逻辑。\n\n/// LLM 提供商类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum LlmProvider {\n Ollama,\n OpenAi,\n Anthropic,\n Google,\n XAi,\n}\n\nimpl LlmProvider {\n /// 获取提供商的默认端点\n pub fn default_endpoint(&self) -> &str {\n match self {\n LlmProvider::Ollama => endpoints::OLLAMA,\n LlmProvider::OpenAi => endpoints::OPENAI,\n LlmProvider::Anthropic => endpoints::ANTHROPIC,\n LlmProvider::Google => endpoints::GOOGLE,\n LlmProvider::XAi => endpoints::XAI,\n }\n }\n\n /// 获取提供商的默认模型\n pub fn default_model(&self) -> &str {\n match self {\n LlmProvider::Ollama => models::OLLAMA_DEFAULT,\n LlmProvider::OpenAi => models::OPENAI_DEFAULT,\n LlmProvider::Anthropic => \"claude-3-haiku\",\n LlmProvider::Google => \"gemini-pro\",\n LlmProvider::XAi => \"grok-beta\",\n }\n }\n}\n\n/// 默认端点常量\npub mod endpoints {\n pub const OLLAMA: &str = \"http://localhost:11434\";\n pub const LLAMACPP: &str = \"http://127.0.0.1:8080\";\n pub const OPENAI: &str = \"https://api.openai.com/v1\";\n pub const ANTHROPIC: &str = \"https://api.anthropic.com/v1\";\n pub const GOOGLE: &str = \"https://generativelanguage.googleapis.com\";\n pub const XAI: &str = \"https://api.x.ai/v1\";\n pub const QWEN: &str = \"https://dashscope.aliyuncs.com/compatible-mode/v1\";\n pub const DEEPSEEK: &str = \"https://api.deepseek.com/v1\";\n pub const GLM: &str = \"https://open.bigmodel.cn/api/paas/v4\";\n pub const MINIMAX: &str = \"https://api.minimax.chat/v1\";\n}\n\n/// 默认模型常量\npub mod models {\n pub const OLLAMA_DEFAULT: &str = \"ministral-3:3b\";\n pub const OPENAI_DEFAULT: &str = \"gpt-4o-mini\";\n}\n\n/// 环境变量名称\npub mod env_vars {\n pub const LLM_PROVIDER: &str = \"LLM_PROVIDER\";\n pub const LLM_MODEL: &str = \"LLM_MODEL\";\n pub const OLLAMA_ENDPOINT: &str = \"OLLAMA_ENDPOINT\";\n pub const OPENAI_API_KEY: &str = \"OPENAI_API_KEY\";\n pub const OPENAI_ENDPOINT: &str = \"OPENAI_ENDPOINT\";\n}\n\n/// Agent 配置常量\npub mod agent {\n /// 默认最大上下文 token 数\n pub const DEFAULT_MAX_CONTEXT_TOKENS: usize = 8000;\n /// 默认 LLM 温度\n pub const DEFAULT_TEMPERATURE: f32 = 0.4;\n /// 默认 top-p 采样\n pub const DEFAULT_TOP_P: f32 = 0.7;\n /// 默认最大生成 token 数\n pub const DEFAULT_MAX_TOKENS: usize = 4096;\n /// 默认最大并发 LLM 请求数\n pub const DEFAULT_CONCURRENT_LIMIT: usize = 3;\n /// 默认上下文选择器 token 预算\n pub const DEFAULT_CONTEXT_SELECTOR_TOKENS: usize = 4000;\n}\n\n/// Agent 配置环境变量\npub mod agent_env_vars {\n use super::agent;\n\n pub const MAX_CONTEXT_TOKENS: &str = \"AGENT_MAX_CONTEXT_TOKENS\";\n pub const TEMPERATURE: &str = \"AGENT_TEMPERATURE\";\n pub const TOP_P: &str = \"AGENT_TOP_P\";\n pub const MAX_TOKENS: &str = \"AGENT_MAX_TOKENS\";\n pub const CONCURRENT_LIMIT: &str = \"AGENT_CONCURRENT_LIMIT\";\n pub const CONTEXT_SELECTOR_TOKENS: &str = \"AGENT_CONTEXT_SELECTOR_TOKENS\";\n /// LLM request timeout in seconds (for Ollama and OpenAI backends)\n pub const LLM_TIMEOUT_SECS: &str = \"AGENT_LLM_TIMEOUT_SECS\";\n\n /// 从环境变量获取最大上下文 token 数,或返回默认值\n pub fn max_context_tokens() -> usize {\n std::env::var(MAX_CONTEXT_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_CONTEXT_TOKENS)\n }\n\n /// 从环境变量获取温度,或返回默认值\n pub fn temperature() -> f32 {\n std::env::var(TEMPERATURE)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TEMPERATURE)\n }\n\n /// 从环境变量获取 top-p,或返回默认值\n pub fn top_p() -> f32 {\n std::env::var(TOP_P)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TOP_P)\n }\n\n /// 从环境变量获取最大生成 token 数,或返回默认值\n pub fn max_tokens() -> usize {\n std::env::var(MAX_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_TOKENS)\n }\n\n /// 从环境变量获取最大并发请求数,或返回默认值\n pub fn concurrent_limit() -> usize {\n std::env::var(CONCURRENT_LIMIT)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONCURRENT_LIMIT)\n }\n\n /// 从环境变量获取上下文选择器 token 预算,或返回默认值\n pub fn context_selector_tokens() -> usize {\n std::env::var(CONTEXT_SELECTOR_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONTEXT_SELECTOR_TOKENS)\n }\n\n /// 从环境变量获取 LLM 请求超时时间(秒),或返回默认值\n ///\n /// 默认值:\n /// - Ollama: 180 秒\n /// - OpenAI/Cloud: 60 秒\n ///\n /// 此环境变量会同时应用于两种后端\n pub fn llm_timeout_secs() -> Option {\n std::env::var(LLM_TIMEOUT_SECS)\n .ok()\n .and_then(|s| s.parse().ok())\n }\n\n /// 获取 Ollama 后端的超时时间(秒)\n pub fn ollama_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(180)\n }\n\n /// 获取 OpenAI/Cloud 后端的超时时间(秒)\n pub fn cloud_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(60)\n }\n}\n\n/// 标准化 Ollama 端点 (移除 /v1 后缀)\n///\n/// Ollama 使用原生 API,不需要 /v1 后缀\npub fn normalize_ollama_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint;\n // 移除 /v1 后缀\n if endpoint.ends_with(\"/v1\") || endpoint.ends_with(\"/v1/\") {\n endpoint = endpoint.replace(\"/v1\", \"\");\n }\n endpoint.trim_end_matches('/').to_string()\n}\n\n/// 标准化 OpenAI 兼容端点 (确保有 /v1 后缀)\n///\n/// 大多数云服务使用 OpenAI 兼容 API,需要 /v1 后缀\npub fn normalize_openai_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint.trim_end_matches('/').to_string();\n // 添加 /v1 后缀\n if !endpoint.ends_with(\"/v1\") && !endpoint.ends_with(\"/v1/\") {\n endpoint = format!(\"{}/v1\", endpoint);\n }\n endpoint\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_ollama_endpoint() {\n // 移除 /v1 后缀\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1\".to_string()),\n \"http://localhost:11434\"\n );\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1/\".to_string()),\n \"http://localhost:11434\"\n );\n // 无需修改\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434\".to_string()),\n \"http://localhost:11434\"\n );\n // 移除尾部斜杠\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/\".to_string()),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn test_normalize_openai_endpoint() {\n // 添加 /v1 后缀\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com\".to_string()),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/\".to_string()),\n \"https://api.openai.com/v1\"\n );\n // 已有 /v1 无需修改\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/v1\".to_string()),\n \"https://api.openai.com/v1\"\n );\n }\n\n #[test]\n fn test_llm_provider_defaults() {\n assert_eq!(LlmProvider::Ollama.default_endpoint(), endpoints::OLLAMA);\n assert_eq!(LlmProvider::Ollama.default_model(), models::OLLAMA_DEFAULT);\n assert_eq!(LlmProvider::OpenAi.default_endpoint(), endpoints::OPENAI);\n assert_eq!(LlmProvider::OpenAi.default_model(), models::OPENAI_DEFAULT);\n }\n}\n"}, "de1581f3c3463406929c596bb4aed1265864c44e": {"t": 1785760867.7217228, "n": 7366, "h": "48225db9b7ba37bb", "c": "//! 统一配置加载 - 消除重复代码\n//!\n//! 这个模块提供了项目中所有配置的默认值和辅助函数,\n//! 避免在多个 crate 中重复定义相同的常量和逻辑。\n\n/// LLM 提供商类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum LlmProvider {\n Ollama,\n OpenAi,\n Anthropic,\n Google,\n XAi,\n}\n\nimpl LlmProvider {\n /// 获取提供商的默认端点\n pub fn default_endpoint(&self) -> &str {\n match self {\n LlmProvider::Ollama => endpoints::OLLAMA,\n LlmProvider::OpenAi => endpoints::OPENAI,\n LlmProvider::Anthropic => endpoints::ANTHROPIC,\n LlmProvider::Google => endpoints::GOOGLE,\n LlmProvider::XAi => endpoints::XAI,\n }\n }\n\n /// 获取提供商的默认模型\n pub fn default_model(&self) -> &str {\n match self {\n LlmProvider::Ollama => models::OLLAMA_DEFAULT,\n LlmProvider::OpenAi => models::OPENAI_DEFAULT,\n LlmProvider::Anthropic => \"claude-3-haiku\",\n LlmProvider::Google => \"gemini-pro\",\n LlmProvider::XAi => \"grok-beta\",\n }\n }\n}\n\n/// 默认端点常量\npub mod endpoints {\n pub const OLLAMA: &str = \"http://localhost:11434\";\n pub const OPENAI: &str = \"https://api.openai.com/v1\";\n pub const ANTHROPIC: &str = \"https://api.anthropic.com/v1\";\n pub const GOOGLE: &str = \"https://generativelanguage.googleapis.com\";\n pub const XAI: &str = \"https://api.x.ai/v1\";\n pub const QWEN: &str = \"https://dashscope.aliyuncs.com/compatible-mode/v1\";\n pub const DEEPSEEK: &str = \"https://api.deepseek.com/v1\";\n pub const GLM: &str = \"https://open.bigmodel.cn/api/paas/v4\";\n pub const MINIMAX: &str = \"https://api.minimax.chat/v1\";\n}\n\n/// 默认模型常量\npub mod models {\n pub const OLLAMA_DEFAULT: &str = \"ministral-3:3b\";\n pub const OPENAI_DEFAULT: &str = \"gpt-4o-mini\";\n}\n\n/// 环境变量名称\npub mod env_vars {\n pub const LLM_PROVIDER: &str = \"LLM_PROVIDER\";\n pub const LLM_MODEL: &str = \"LLM_MODEL\";\n pub const OLLAMA_ENDPOINT: &str = \"OLLAMA_ENDPOINT\";\n pub const OPENAI_API_KEY: &str = \"OPENAI_API_KEY\";\n pub const OPENAI_ENDPOINT: &str = \"OPENAI_ENDPOINT\";\n}\n\n/// Agent 配置常量\npub mod agent {\n /// 默认最大上下文 token 数\n pub const DEFAULT_MAX_CONTEXT_TOKENS: usize = 8000;\n /// 默认 LLM 温度\n pub const DEFAULT_TEMPERATURE: f32 = 0.4;\n /// 默认 top-p 采样\n pub const DEFAULT_TOP_P: f32 = 0.7;\n /// 默认最大生成 token 数\n pub const DEFAULT_MAX_TOKENS: usize = 4096;\n /// 默认最大并发 LLM 请求数\n pub const DEFAULT_CONCURRENT_LIMIT: usize = 3;\n /// 默认上下文选择器 token 预算\n pub const DEFAULT_CONTEXT_SELECTOR_TOKENS: usize = 4000;\n}\n\n/// Agent 配置环境变量\npub mod agent_env_vars {\n use super::agent;\n\n pub const MAX_CONTEXT_TOKENS: &str = \"AGENT_MAX_CONTEXT_TOKENS\";\n pub const TEMPERATURE: &str = \"AGENT_TEMPERATURE\";\n pub const TOP_P: &str = \"AGENT_TOP_P\";\n pub const MAX_TOKENS: &str = \"AGENT_MAX_TOKENS\";\n pub const CONCURRENT_LIMIT: &str = \"AGENT_CONCURRENT_LIMIT\";\n pub const CONTEXT_SELECTOR_TOKENS: &str = \"AGENT_CONTEXT_SELECTOR_TOKENS\";\n /// LLM request timeout in seconds (for Ollama and OpenAI backends)\n pub const LLM_TIMEOUT_SECS: &str = \"AGENT_LLM_TIMEOUT_SECS\";\n\n /// 从环境变量获取最大上下文 token 数,或返回默认值\n pub fn max_context_tokens() -> usize {\n std::env::var(MAX_CONTEXT_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_CONTEXT_TOKENS)\n }\n\n /// 从环境变量获取温度,或返回默认值\n pub fn temperature() -> f32 {\n std::env::var(TEMPERATURE)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TEMPERATURE)\n }\n\n /// 从环境变量获取 top-p,或返回默认值\n pub fn top_p() -> f32 {\n std::env::var(TOP_P)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TOP_P)\n }\n\n /// 从环境变量获取最大生成 token 数,或返回默认值\n pub fn max_tokens() -> usize {\n std::env::var(MAX_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_TOKENS)\n }\n\n /// 从环境变量获取最大并发请求数,或返回默认值\n pub fn concurrent_limit() -> usize {\n std::env::var(CONCURRENT_LIMIT)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONCURRENT_LIMIT)\n }\n\n /// 从环境变量获取上下文选择器 token 预算,或返回默认值\n pub fn context_selector_tokens() -> usize {\n std::env::var(CONTEXT_SELECTOR_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONTEXT_SELECTOR_TOKENS)\n }\n\n /// 从环境变量获取 LLM 请求超时时间(秒),或返回默认值\n ///\n /// 默认值:\n /// - Ollama: 180 秒\n /// - OpenAI/Cloud: 60 秒\n ///\n /// 此环境变量会同时应用于两种后端\n pub fn llm_timeout_secs() -> Option {\n std::env::var(LLM_TIMEOUT_SECS)\n .ok()\n .and_then(|s| s.parse().ok())\n }\n\n /// 获取 Ollama 后端的超时时间(秒)\n pub fn ollama_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(180)\n }\n\n /// 获取 OpenAI/Cloud 后端的超时时间(秒)\n pub fn cloud_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(60)\n }\n}\n\n/// 标准化 Ollama 端点 (移除 /v1 后缀)\n///\n/// Ollama 使用原生 API,不需要 /v1 后缀\npub fn normalize_ollama_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint;\n // 移除 /v1 后缀\n if endpoint.ends_with(\"/v1\") || endpoint.ends_with(\"/v1/\") {\n endpoint = endpoint.replace(\"/v1\", \"\");\n }\n endpoint.trim_end_matches('/').to_string()\n}\n\n/// 标准化 OpenAI 兼容端点 (确保有 /v1 后缀)\n///\n/// 大多数云服务使用 OpenAI 兼容 API,需要 /v1 后缀\npub fn normalize_openai_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint.trim_end_matches('/').to_string();\n // 添加 /v1 后缀\n if !endpoint.ends_with(\"/v1\") && !endpoint.ends_with(\"/v1/\") {\n endpoint = format!(\"{}/v1\", endpoint);\n }\n endpoint\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_ollama_endpoint() {\n // 移除 /v1 后缀\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1\".to_string()),\n \"http://localhost:11434\"\n );\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1/\".to_string()),\n \"http://localhost:11434\"\n );\n // 无需修改\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434\".to_string()),\n \"http://localhost:11434\"\n );\n // 移除尾部斜杠\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/\".to_string()),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn test_normalize_openai_endpoint() {\n // 添加 /v1 后缀\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com\".to_string()),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/\".to_string()),\n \"https://api.openai.com/v1\"\n );\n // 已有 /v1 无需修改\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/v1\".to_string()),\n \"https://api.openai.com/v1\"\n );\n }\n\n #[test]\n fn test_llm_provider_defaults() {\n assert_eq!(LlmProvider::Ollama.default_endpoint(), endpoints::OLLAMA);\n assert_eq!(LlmProvider::Ollama.default_model(), models::OLLAMA_DEFAULT);\n assert_eq!(LlmProvider::OpenAi.default_endpoint(), endpoints::OPENAI);\n assert_eq!(LlmProvider::OpenAi.default_model(), models::OPENAI_DEFAULT);\n }\n}\n"}, "1173da122ac2c6c14ee7fe103e28492ffb492710": {"t": 1785760869.0120814, "n": 7363, "h": "e069689afa9b3e5d", "c": "//! 统一配置加载 - 消除重复代码\n//!\n//! 这个模块提供了项目中所有配置的默认值和辅助函数,\n//! 避免在多个 crate 中重复定义相同的常量和逻辑。\n\n/// LLM 提供商类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum LlmProvider {\n Ollama,\n OpenAi,\n Anthropic,\n Google,\n XAi,\n}\n\nimpl LlmProvider {\n /// 获取提供商的默认端点\n pub fn default_endpoint(&self) -> &str {\n match self {\n LlmProvider::Ollama => endpoints::OLLAMA,\n LlmProvider::OpenAi => endpoints::OPENAI,\n LlmProvider::Anthropic => endpoints::ANTHROPIC,\n LlmProvider::Google => endpoints::GOOGLE,\n LlmProvider::XAi => endpoints::XAI,\n }\n }\n\n /// 获取提供商的默认模型\n pub fn default_model(&self) -> &str {\n match self {\n LlmProvider::Ollama => models::OLLAMA_DEFAULT,\n LlmProvider::OpenAi => models::OPENAI_DEFAULT,\n LlmProvider::Anthropic => \"claude-3-haiku\",\n LlmProvider::Google => \"gemini-pro\",\n LlmProvider::XAi => \"grok-beta\",\n }\n }\n}\n\n/// 默认端点常量\npub mod endpoints {\n pub const OLLAMA: &str = \"http://localhost:11434\";\n pub const OPENAI: &str = \"https://api.openai.com/v1\";\n pub const ANTHROPIC: &str = \"https://api.anthropic.com/v1\";\n pub const GOOGLE: &str = \"https://generativelanguage.googleapis.com\";\n pub const XAI: &str = \"https://api.x.ai/v1\";\n pub const QWEN: &str = \"https://dashscope.aliyuncs.com/compatible-mode/v1\";\n pub const DEEPSEEK: &str = \"https://api.deepseek.com/v1\";\n pub const GLM: &str = \"https://open.bigmodel.cn/api/paas/v4\";\n pub const MINIMAX: &str = \"https://api.minimax.chat/v1\";\n}\n\n/// 默认模型常量\npub mod models {\n pub const OLLAMA_DEFAULT: &str = \"qwen3-vl:2b\";\n pub const OPENAI_DEFAULT: &str = \"gpt-4o-mini\";\n}\n\n/// 环境变量名称\npub mod env_vars {\n pub const LLM_PROVIDER: &str = \"LLM_PROVIDER\";\n pub const LLM_MODEL: &str = \"LLM_MODEL\";\n pub const OLLAMA_ENDPOINT: &str = \"OLLAMA_ENDPOINT\";\n pub const OPENAI_API_KEY: &str = \"OPENAI_API_KEY\";\n pub const OPENAI_ENDPOINT: &str = \"OPENAI_ENDPOINT\";\n}\n\n/// Agent 配置常量\npub mod agent {\n /// 默认最大上下文 token 数\n pub const DEFAULT_MAX_CONTEXT_TOKENS: usize = 8000;\n /// 默认 LLM 温度\n pub const DEFAULT_TEMPERATURE: f32 = 0.4;\n /// 默认 top-p 采样\n pub const DEFAULT_TOP_P: f32 = 0.7;\n /// 默认最大生成 token 数\n pub const DEFAULT_MAX_TOKENS: usize = 4096;\n /// 默认最大并发 LLM 请求数\n pub const DEFAULT_CONCURRENT_LIMIT: usize = 3;\n /// 默认上下文选择器 token 预算\n pub const DEFAULT_CONTEXT_SELECTOR_TOKENS: usize = 4000;\n}\n\n/// Agent 配置环境变量\npub mod agent_env_vars {\n use super::agent;\n\n pub const MAX_CONTEXT_TOKENS: &str = \"AGENT_MAX_CONTEXT_TOKENS\";\n pub const TEMPERATURE: &str = \"AGENT_TEMPERATURE\";\n pub const TOP_P: &str = \"AGENT_TOP_P\";\n pub const MAX_TOKENS: &str = \"AGENT_MAX_TOKENS\";\n pub const CONCURRENT_LIMIT: &str = \"AGENT_CONCURRENT_LIMIT\";\n pub const CONTEXT_SELECTOR_TOKENS: &str = \"AGENT_CONTEXT_SELECTOR_TOKENS\";\n /// LLM request timeout in seconds (for Ollama and OpenAI backends)\n pub const LLM_TIMEOUT_SECS: &str = \"AGENT_LLM_TIMEOUT_SECS\";\n\n /// 从环境变量获取最大上下文 token 数,或返回默认值\n pub fn max_context_tokens() -> usize {\n std::env::var(MAX_CONTEXT_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_CONTEXT_TOKENS)\n }\n\n /// 从环境变量获取温度,或返回默认值\n pub fn temperature() -> f32 {\n std::env::var(TEMPERATURE)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TEMPERATURE)\n }\n\n /// 从环境变量获取 top-p,或返回默认值\n pub fn top_p() -> f32 {\n std::env::var(TOP_P)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TOP_P)\n }\n\n /// 从环境变量获取最大生成 token 数,或返回默认值\n pub fn max_tokens() -> usize {\n std::env::var(MAX_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_TOKENS)\n }\n\n /// 从环境变量获取最大并发请求数,或返回默认值\n pub fn concurrent_limit() -> usize {\n std::env::var(CONCURRENT_LIMIT)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONCURRENT_LIMIT)\n }\n\n /// 从环境变量获取上下文选择器 token 预算,或返回默认值\n pub fn context_selector_tokens() -> usize {\n std::env::var(CONTEXT_SELECTOR_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONTEXT_SELECTOR_TOKENS)\n }\n\n /// 从环境变量获取 LLM 请求超时时间(秒),或返回默认值\n ///\n /// 默认值:\n /// - Ollama: 180 秒\n /// - OpenAI/Cloud: 60 秒\n ///\n /// 此环境变量会同时应用于两种后端\n pub fn llm_timeout_secs() -> Option {\n std::env::var(LLM_TIMEOUT_SECS)\n .ok()\n .and_then(|s| s.parse().ok())\n }\n\n /// 获取 Ollama 后端的超时时间(秒)\n pub fn ollama_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(180)\n }\n\n /// 获取 OpenAI/Cloud 后端的超时时间(秒)\n pub fn cloud_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(60)\n }\n}\n\n/// 标准化 Ollama 端点 (移除 /v1 后缀)\n///\n/// Ollama 使用原生 API,不需要 /v1 后缀\npub fn normalize_ollama_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint;\n // 移除 /v1 后缀\n if endpoint.ends_with(\"/v1\") || endpoint.ends_with(\"/v1/\") {\n endpoint = endpoint.replace(\"/v1\", \"\");\n }\n endpoint.trim_end_matches('/').to_string()\n}\n\n/// 标准化 OpenAI 兼容端点 (确保有 /v1 后缀)\n///\n/// 大多数云服务使用 OpenAI 兼容 API,需要 /v1 后缀\npub fn normalize_openai_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint.trim_end_matches('/').to_string();\n // 添加 /v1 后缀\n if !endpoint.ends_with(\"/v1\") && !endpoint.ends_with(\"/v1/\") {\n endpoint = format!(\"{}/v1\", endpoint);\n }\n endpoint\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_ollama_endpoint() {\n // 移除 /v1 后缀\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1\".to_string()),\n \"http://localhost:11434\"\n );\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1/\".to_string()),\n \"http://localhost:11434\"\n );\n // 无需修改\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434\".to_string()),\n \"http://localhost:11434\"\n );\n // 移除尾部斜杠\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/\".to_string()),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn test_normalize_openai_endpoint() {\n // 添加 /v1 后缀\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com\".to_string()),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/\".to_string()),\n \"https://api.openai.com/v1\"\n );\n // 已有 /v1 无需修改\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/v1\".to_string()),\n \"https://api.openai.com/v1\"\n );\n }\n\n #[test]\n fn test_llm_provider_defaults() {\n assert_eq!(LlmProvider::Ollama.default_endpoint(), endpoints::OLLAMA);\n assert_eq!(LlmProvider::Ollama.default_model(), models::OLLAMA_DEFAULT);\n assert_eq!(LlmProvider::OpenAi.default_endpoint(), endpoints::OPENAI);\n assert_eq!(LlmProvider::OpenAi.default_model(), models::OPENAI_DEFAULT);\n }\n}\n"}, "9f5c110a1369a22b22c2132155f08045c9059ceb": {"t": 1785760870.3009188, "n": 7094, "h": "9500f7444f7ad3e3", "c": "//! 统一配置加载 - 消除重复代码\n//!\n//! 这个模块提供了项目中所有配置的默认值和辅助函数,\n//! 避免在多个 crate 中重复定义相同的常量和逻辑。\n\n/// LLM 提供商类型\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum LlmProvider {\n Ollama,\n OpenAi,\n Anthropic,\n Google,\n XAi,\n}\n\nimpl LlmProvider {\n /// 获取提供商的默认端点\n pub fn default_endpoint(&self) -> &str {\n match self {\n LlmProvider::Ollama => endpoints::OLLAMA,\n LlmProvider::OpenAi => endpoints::OPENAI,\n LlmProvider::Anthropic => endpoints::ANTHROPIC,\n LlmProvider::Google => endpoints::GOOGLE,\n LlmProvider::XAi => endpoints::XAI,\n }\n }\n\n /// 获取提供商的默认模型\n pub fn default_model(&self) -> &str {\n match self {\n LlmProvider::Ollama => models::OLLAMA_DEFAULT,\n LlmProvider::OpenAi => models::OPENAI_DEFAULT,\n LlmProvider::Anthropic => \"claude-3-haiku\",\n LlmProvider::Google => \"gemini-pro\",\n LlmProvider::XAi => \"grok-beta\",\n }\n }\n}\n\n/// 默认端点常量\npub mod endpoints {\n pub const OLLAMA: &str = \"http://localhost:11434\";\n pub const OPENAI: &str = \"https://api.openai.com/v1\";\n pub const ANTHROPIC: &str = \"https://api.anthropic.com/v1\";\n pub const GOOGLE: &str = \"https://generativelanguage.googleapis.com\";\n pub const XAI: &str = \"https://api.x.ai/v1\";\n}\n\n/// 默认模型常量\npub mod models {\n pub const OLLAMA_DEFAULT: &str = \"qwen3-vl:2b\";\n pub const OPENAI_DEFAULT: &str = \"gpt-4o-mini\";\n}\n\n/// 环境变量名称\npub mod env_vars {\n pub const LLM_PROVIDER: &str = \"LLM_PROVIDER\";\n pub const LLM_MODEL: &str = \"LLM_MODEL\";\n pub const OLLAMA_ENDPOINT: &str = \"OLLAMA_ENDPOINT\";\n pub const OPENAI_API_KEY: &str = \"OPENAI_API_KEY\";\n pub const OPENAI_ENDPOINT: &str = \"OPENAI_ENDPOINT\";\n}\n\n/// Agent 配置常量\npub mod agent {\n /// 默认最大上下文 token 数\n pub const DEFAULT_MAX_CONTEXT_TOKENS: usize = 8000;\n /// 默认 LLM 温度\n pub const DEFAULT_TEMPERATURE: f32 = 0.4;\n /// 默认 top-p 采样\n pub const DEFAULT_TOP_P: f32 = 0.7;\n /// 默认最大生成 token 数\n pub const DEFAULT_MAX_TOKENS: usize = 4096;\n /// 默认最大并发 LLM 请求数\n pub const DEFAULT_CONCURRENT_LIMIT: usize = 3;\n /// 默认上下文选择器 token 预算\n pub const DEFAULT_CONTEXT_SELECTOR_TOKENS: usize = 4000;\n}\n\n/// Agent 配置环境变量\npub mod agent_env_vars {\n use super::agent;\n\n pub const MAX_CONTEXT_TOKENS: &str = \"AGENT_MAX_CONTEXT_TOKENS\";\n pub const TEMPERATURE: &str = \"AGENT_TEMPERATURE\";\n pub const TOP_P: &str = \"AGENT_TOP_P\";\n pub const MAX_TOKENS: &str = \"AGENT_MAX_TOKENS\";\n pub const CONCURRENT_LIMIT: &str = \"AGENT_CONCURRENT_LIMIT\";\n pub const CONTEXT_SELECTOR_TOKENS: &str = \"AGENT_CONTEXT_SELECTOR_TOKENS\";\n /// LLM request timeout in seconds (for Ollama and OpenAI backends)\n pub const LLM_TIMEOUT_SECS: &str = \"AGENT_LLM_TIMEOUT_SECS\";\n\n /// 从环境变量获取最大上下文 token 数,或返回默认值\n pub fn max_context_tokens() -> usize {\n std::env::var(MAX_CONTEXT_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_CONTEXT_TOKENS)\n }\n\n /// 从环境变量获取温度,或返回默认值\n pub fn temperature() -> f32 {\n std::env::var(TEMPERATURE)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TEMPERATURE)\n }\n\n /// 从环境变量获取 top-p,或返回默认值\n pub fn top_p() -> f32 {\n std::env::var(TOP_P)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_TOP_P)\n }\n\n /// 从环境变量获取最大生成 token 数,或返回默认值\n pub fn max_tokens() -> usize {\n std::env::var(MAX_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_MAX_TOKENS)\n }\n\n /// 从环境变量获取最大并发请求数,或返回默认值\n pub fn concurrent_limit() -> usize {\n std::env::var(CONCURRENT_LIMIT)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONCURRENT_LIMIT)\n }\n\n /// 从环境变量获取上下文选择器 token 预算,或返回默认值\n pub fn context_selector_tokens() -> usize {\n std::env::var(CONTEXT_SELECTOR_TOKENS)\n .ok()\n .and_then(|s| s.parse().ok())\n .unwrap_or(agent::DEFAULT_CONTEXT_SELECTOR_TOKENS)\n }\n\n /// 从环境变量获取 LLM 请求超时时间(秒),或返回默认值\n ///\n /// 默认值:\n /// - Ollama: 180 秒\n /// - OpenAI/Cloud: 60 秒\n ///\n /// 此环境变量会同时应用于两种后端\n pub fn llm_timeout_secs() -> Option {\n std::env::var(LLM_TIMEOUT_SECS)\n .ok()\n .and_then(|s| s.parse().ok())\n }\n\n /// 获取 Ollama 后端的超时时间(秒)\n pub fn ollama_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(180)\n }\n\n /// 获取 OpenAI/Cloud 后端的超时时间(秒)\n pub fn cloud_timeout_secs() -> u64 {\n llm_timeout_secs().unwrap_or(60)\n }\n}\n\n/// 标准化 Ollama 端点 (移除 /v1 后缀)\n///\n/// Ollama 使用原生 API,不需要 /v1 后缀\npub fn normalize_ollama_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint;\n // 移除 /v1 后缀\n if endpoint.ends_with(\"/v1\") || endpoint.ends_with(\"/v1/\") {\n endpoint = endpoint.replace(\"/v1\", \"\");\n }\n endpoint.trim_end_matches('/').to_string()\n}\n\n/// 标准化 OpenAI 兼容端点 (确保有 /v1 后缀)\n///\n/// 大多数云服务使用 OpenAI 兼容 API,需要 /v1 后缀\npub fn normalize_openai_endpoint(endpoint: String) -> String {\n let mut endpoint = endpoint.trim_end_matches('/').to_string();\n // 添加 /v1 后缀\n if !endpoint.ends_with(\"/v1\") && !endpoint.ends_with(\"/v1/\") {\n endpoint = format!(\"{}/v1\", endpoint);\n }\n endpoint\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn test_normalize_ollama_endpoint() {\n // 移除 /v1 后缀\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1\".to_string()),\n \"http://localhost:11434\"\n );\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/v1/\".to_string()),\n \"http://localhost:11434\"\n );\n // 无需修改\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434\".to_string()),\n \"http://localhost:11434\"\n );\n // 移除尾部斜杠\n assert_eq!(\n normalize_ollama_endpoint(\"http://localhost:11434/\".to_string()),\n \"http://localhost:11434\"\n );\n }\n\n #[test]\n fn test_normalize_openai_endpoint() {\n // 添加 /v1 后缀\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com\".to_string()),\n \"https://api.openai.com/v1\"\n );\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/\".to_string()),\n \"https://api.openai.com/v1\"\n );\n // 已有 /v1 无需修改\n assert_eq!(\n normalize_openai_endpoint(\"https://api.openai.com/v1\".to_string()),\n \"https://api.openai.com/v1\"\n );\n }\n\n #[test]\n fn test_llm_provider_defaults() {\n assert_eq!(LlmProvider::Ollama.default_endpoint(), endpoints::OLLAMA);\n assert_eq!(LlmProvider::Ollama.default_model(), models::OLLAMA_DEFAULT);\n assert_eq!(LlmProvider::OpenAi.default_endpoint(), endpoints::OPENAI);\n assert_eq!(LlmProvider::OpenAi.default_model(), models::OPENAI_DEFAULT);\n }\n}\n"}}}}