diff --git a/backend/src/services/llm/anthropic.rs b/backend/src/services/llm/anthropic.rs new file mode 100644 index 0000000..7064504 --- /dev/null +++ b/backend/src/services/llm/anthropic.rs @@ -0,0 +1,562 @@ +//! Anthropic LLM provider implementation. +//! +//! Implements the `LlmProvider` trait using the Anthropic Messages API. +//! - **Pass 1 (search)**: Messages API with `web_search_20250305` tool +//! - **Pass 2 (rewrite)**: Messages API without tools, JSON via prompt instructions + +use async_trait::async_trait; +use serde_json::Value; + +use super::LlmProvider; +use crate::errors::AppError; + +/// Anthropic API version header value. +const ANTHROPIC_VERSION: &str = "2023-06-01"; + +/// Default max tokens for Anthropic responses. +const DEFAULT_MAX_TOKENS: u32 = 16384; + +/// Maximum web search uses per request. +const WEB_SEARCH_MAX_USES: u32 = 10; + +/// Anthropic provider. +/// +/// Holds the API key and an HTTP client for making requests +/// to the Anthropic Messages API. +pub struct AnthropicProvider { + api_key: String, + http_client: reqwest::Client, +} + +impl AnthropicProvider { + /// Create a new Anthropic provider with the given API key and HTTP client. + pub fn new(api_key: String, http_client: reqwest::Client) -> Self { + Self { + api_key, + http_client, + } + } + + /// Execute a request to the Anthropic Messages API. + /// + /// Sends a POST to `https://api.anthropic.com/v1/messages` with the + /// appropriate headers and body. When `include_web_search` is true, + /// the `web_search_20250305` tool is included. + async fn call_messages_api( + &self, + model: &str, + system_prompt: &str, + user_prompt: &str, + response_schema: &Value, + include_web_search: bool, + ) -> Result { + // Anthropic doesn't have native JSON schema enforcement like OpenAI/Gemini. + // We embed the schema in the system prompt to instruct Claude to respond with + // valid JSON matching the schema. + let schema_instruction = format!( + "\n\nYou MUST respond with valid JSON and nothing else. \ + No markdown, no code fences, no explanations. \ + The JSON must match this exact schema:\n{}", + serde_json::to_string_pretty(response_schema).unwrap_or_default() + ); + + let full_system_prompt = format!("{}{}", system_prompt, schema_instruction); + + let mut body = serde_json::json!({ + "model": model, + "max_tokens": DEFAULT_MAX_TOKENS, + "system": full_system_prompt, + "messages": [{ + "role": "user", + "content": user_prompt + }] + }); + + if include_web_search { + body["tools"] = serde_json::json!([{ + "type": "web_search_20250305", + "name": "web_search", + "max_uses": WEB_SEARCH_MAX_USES + }]); + } + + let response = self + .http_client + .post("https://api.anthropic.com/v1/messages") + .header("x-api-key", &self.api_key) + .header("anthropic-version", ANTHROPIC_VERSION) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| { + let kind = if e.is_timeout() { + "timeout" + } else if e.is_connect() { + "connection error" + } else { + "network error" + }; + tracing::error!("Anthropic API request failed: {}", kind); + AppError::Internal(anyhow::anyhow!("Failed to connect to Anthropic API")) + })?; + + let status = response.status(); + let response_body: Value = response.json().await.map_err(|e| { + tracing::error!("Failed to parse Anthropic response body: {}", e); + AppError::Internal(anyhow::anyhow!("Failed to parse Anthropic API response")) + })?; + + if !status.is_success() { + return Err(map_anthropic_error(status.as_u16(), &response_body)); + } + + extract_content(&response_body) + } +} + +#[async_trait] +impl LlmProvider for AnthropicProvider { + fn provider_id(&self) -> &str { + "anthropic" + } + + async fn generate_search_pass( + &self, + model: &str, + system_prompt: &str, + user_prompt: &str, + response_schema: &Value, + ) -> Result { + self.call_messages_api(model, system_prompt, user_prompt, response_schema, true) + .await + } + + async fn generate_rewrite_pass( + &self, + model: &str, + system_prompt: &str, + user_prompt: &str, + response_schema: &Value, + ) -> Result { + self.call_messages_api(model, system_prompt, user_prompt, response_schema, false) + .await + } + + fn supports_web_search(&self) -> bool { + true + } +} + +/// Extract the text content from an Anthropic Messages API response. +/// +/// The response structure is: +/// ```json +/// { +/// "content": [ +/// { "type": "text", "text": "..." }, +/// { "type": "web_search_tool_result", ... }, +/// { "type": "text", "text": "{...json...}" } +/// ] +/// } +/// ``` +/// +/// We scan for the last `text` block (which typically contains the final answer) +/// and parse it as JSON. +fn extract_content(response: &Value) -> Result { + let content = response + .get("content") + .and_then(|c| c.as_array()) + .ok_or_else(|| { + tracing::error!("Unexpected Anthropic response structure: missing 'content' array"); + AppError::Internal(anyhow::anyhow!( + "Anthropic API returned an unexpected response structure" + )) + })?; + + // Find the last text block — the final answer after any tool results + let mut last_text: Option<&str> = None; + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("text") { + if let Some(text) = block.get("text").and_then(|t| t.as_str()) { + if !text.trim().is_empty() { + last_text = Some(text); + } + } + } + } + + let text = last_text.ok_or_else(|| { + tracing::error!("No text content found in Anthropic response"); + AppError::Internal(anyhow::anyhow!( + "Anthropic API returned no text content" + )) + })?; + + // Claude sometimes wraps JSON in markdown code fences — strip them + let cleaned = strip_code_fences(text); + + serde_json::from_str(cleaned).map_err(|e| { + tracing::error!("Failed to parse Anthropic JSON output: {}", e); + AppError::Internal(anyhow::anyhow!( + "Anthropic returned invalid JSON in structured output" + )) + }) +} + +/// Strip markdown code fences from a string if present. +/// +/// Handles patterns like: +/// - ```json\n{...}\n``` +/// - ```\n{...}\n``` +fn strip_code_fences(text: &str) -> &str { + let trimmed = text.trim(); + + // Check for ```json or ``` prefix + let without_prefix = if trimmed.starts_with("```json") { + &trimmed[7..] + } else if trimmed.starts_with("```") { + &trimmed[3..] + } else { + return trimmed; + }; + + // Strip the closing ``` + let without_suffix = if without_prefix.trim_end().ends_with("```") { + let end = without_prefix.trim_end(); + &end[..end.len() - 3] + } else { + without_prefix + }; + + without_suffix.trim() +} + +/// Map Anthropic API error responses to appropriate `AppError` variants. +/// +/// Handles common error codes without exposing internal details. +fn map_anthropic_error(status: u16, body: &Value) -> AppError { + let error_message = body + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); + + let error_type = body + .get("error") + .and_then(|e| e.get("type")) + .and_then(|t| t.as_str()) + .unwrap_or(""); + + // Log error details but NEVER the API key + tracing::error!( + "Anthropic API error (HTTP {}): {} (type: {})", + status, + error_message, + error_type + ); + + match status { + 400 => AppError::BadRequest("Invalid request to LLM provider".into()), + 401 => AppError::BadRequest("Invalid or unauthorized API key".into()), + 403 => AppError::BadRequest("Access denied by LLM provider".into()), + 404 => AppError::BadRequest("Model not found or not available".into()), + 429 => AppError::RateLimited( + "LLM provider rate limit exceeded. Please try again later.".into(), + ), + 529 => AppError::RateLimited( + "LLM provider is overloaded. Please try again later.".into(), + ), + _ => AppError::Internal(anyhow::anyhow!("LLM provider returned an error")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Provider metadata ─────────────────────────────────────── + + #[test] + fn anthropic_provider_metadata() { + let provider = AnthropicProvider::new("test-key".into(), reqwest::Client::new()); + assert_eq!(provider.provider_id(), "anthropic"); + assert!(provider.supports_web_search()); + } + + // ── Content extraction ────────────────────────────────────── + + #[test] + fn extract_content_valid_simple_text() { + let response = serde_json::json!({ + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "{\"category_0\": [{\"title\": \"Test\", \"url\": \"https://example.com\", \"summary\": \"A test article\"}]}" + } + ], + "stop_reason": "end_turn" + }); + + let result = extract_content(&response).unwrap(); + assert!(result["category_0"].is_array()); + assert_eq!(result["category_0"][0]["title"].as_str().unwrap(), "Test"); + } + + #[test] + fn extract_content_with_web_search_results() { + // When web_search is used, the response may contain tool results interleaved with text + let response = serde_json::json!({ + "content": [ + { + "type": "text", + "text": "Let me search for that information." + }, + { + "type": "server_tool_use", + "id": "srvtoolu_123", + "name": "web_search", + "input": { "query": "AI news this week" } + }, + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_123", + "content": [ + { + "type": "web_search_result", + "url": "https://example.com/ai-news", + "title": "AI News" + } + ] + }, + { + "type": "text", + "text": "{\"category_0\": [{\"title\": \"AI Breakthrough\", \"url\": \"https://example.com/ai-news\", \"summary\": \"Major AI advancement\"}]}" + } + ] + }); + + let result = extract_content(&response).unwrap(); + assert!(result["category_0"].is_array()); + assert_eq!( + result["category_0"][0]["title"].as_str().unwrap(), + "AI Breakthrough" + ); + } + + #[test] + fn extract_content_with_code_fences() { + let response = serde_json::json!({ + "content": [ + { + "type": "text", + "text": "```json\n{\"category_0\": [{\"title\": \"Test\", \"url\": \"https://example.com\", \"summary\": \"Sum\"}]}\n```" + } + ] + }); + + let result = extract_content(&response).unwrap(); + assert!(result["category_0"].is_array()); + } + + #[test] + fn extract_content_missing_content_array() { + let response = serde_json::json!({}); + assert!(extract_content(&response).is_err()); + } + + #[test] + fn extract_content_empty_content_array() { + let response = serde_json::json!({"content": []}); + assert!(extract_content(&response).is_err()); + } + + #[test] + fn extract_content_no_text_blocks() { + let response = serde_json::json!({ + "content": [ + { + "type": "web_search_tool_result", + "tool_use_id": "srvtoolu_123", + "content": [] + } + ] + }); + assert!(extract_content(&response).is_err()); + } + + #[test] + fn extract_content_invalid_json_text() { + let response = serde_json::json!({ + "content": [ + { + "type": "text", + "text": "This is not valid JSON at all" + } + ] + }); + assert!(extract_content(&response).is_err()); + } + + // ── Code fence stripping ──────────────────────────────────── + + #[test] + fn strip_code_fences_json_fences() { + let input = "```json\n{\"key\": \"value\"}\n```"; + assert_eq!(strip_code_fences(input), "{\"key\": \"value\"}"); + } + + #[test] + fn strip_code_fences_plain_fences() { + let input = "```\n{\"key\": \"value\"}\n```"; + assert_eq!(strip_code_fences(input), "{\"key\": \"value\"}"); + } + + #[test] + fn strip_code_fences_no_fences() { + let input = "{\"key\": \"value\"}"; + assert_eq!(strip_code_fences(input), "{\"key\": \"value\"}"); + } + + #[test] + fn strip_code_fences_with_whitespace() { + let input = " ```json\n {\"key\": \"value\"} \n ``` "; + let result = strip_code_fences(input); + // After stripping fences, we get trimmed JSON + assert!(result.contains("\"key\"")); + } + + // ── Error mapping tests ───────────────────────────────────── + + #[test] + fn map_anthropic_error_invalid_key() { + let body = serde_json::json!({ + "type": "error", + "error": { + "type": "authentication_error", + "message": "invalid x-api-key" + } + }); + + let err = map_anthropic_error(401, &body); + match err { + AppError::BadRequest(msg) => assert!(msg.contains("unauthorized")), + _ => panic!("Expected BadRequest for 401"), + } + } + + #[test] + fn map_anthropic_error_rate_limited() { + let body = serde_json::json!({ + "type": "error", + "error": { + "type": "rate_limit_error", + "message": "Rate limit exceeded" + } + }); + + let err = map_anthropic_error(429, &body); + match err { + AppError::RateLimited(msg) => assert!(msg.contains("rate limit")), + _ => panic!("Expected RateLimited for 429"), + } + } + + #[test] + fn map_anthropic_error_overloaded() { + let body = serde_json::json!({ + "type": "error", + "error": { + "type": "overloaded_error", + "message": "Overloaded" + } + }); + + let err = map_anthropic_error(529, &body); + match err { + AppError::RateLimited(msg) => assert!(msg.contains("overloaded")), + _ => panic!("Expected RateLimited for 529 (overloaded)"), + } + } + + #[test] + fn map_anthropic_error_bad_request() { + let body = serde_json::json!({ + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "max_tokens must be positive" + } + }); + + let err = map_anthropic_error(400, &body); + match err { + AppError::BadRequest(msg) => assert!(msg.contains("Invalid request")), + _ => panic!("Expected BadRequest for 400"), + } + } + + #[test] + fn map_anthropic_error_model_not_found() { + let body = serde_json::json!({ + "type": "error", + "error": { + "type": "not_found_error", + "message": "model: claude-unknown not found" + } + }); + + let err = map_anthropic_error(404, &body); + match err { + AppError::BadRequest(msg) => assert!(msg.contains("not found")), + _ => panic!("Expected BadRequest for 404"), + } + } + + #[test] + fn map_anthropic_error_server_error() { + let body = serde_json::json!({ + "type": "error", + "error": { + "type": "api_error", + "message": "Internal server error" + } + }); + + let err = map_anthropic_error(500, &body); + match err { + AppError::Internal(_) => {} // expected + _ => panic!("Expected Internal for 500"), + } + } + + #[test] + fn map_anthropic_error_forbidden() { + let body = serde_json::json!({ + "type": "error", + "error": { + "type": "permission_error", + "message": "Forbidden" + } + }); + + let err = map_anthropic_error(403, &body); + match err { + AppError::BadRequest(msg) => assert!(msg.contains("Access denied")), + _ => panic!("Expected BadRequest for 403"), + } + } + + #[test] + fn map_anthropic_error_unknown_body() { + let body = serde_json::json!({}); + let err = map_anthropic_error(503, &body); + match err { + AppError::Internal(_) => {} // expected + _ => panic!("Expected Internal for 503"), + } + } +} diff --git a/backend/src/services/llm/factory.rs b/backend/src/services/llm/factory.rs index 818d505..9e985be 100644 --- a/backend/src/services/llm/factory.rs +++ b/backend/src/services/llm/factory.rs @@ -1,7 +1,9 @@ //! Provider factory: creates the correct `LlmProvider` implementation //! based on the provider name and the user's decrypted API key. +use super::anthropic::AnthropicProvider; use super::gemini::GeminiProvider; +use super::openai::OpenAiProvider; use super::LlmProvider; use crate::errors::AppError; @@ -13,7 +15,7 @@ use crate::errors::AppError; /// * `http_client` — Shared HTTP client for making API calls /// /// # Errors -/// Returns `AppError::BadRequest` if the provider is not yet supported. +/// Returns `AppError::BadRequest` if the provider name is unknown. pub fn create_provider( provider_name: &str, api_key: String, @@ -21,12 +23,11 @@ pub fn create_provider( ) -> Result, AppError> { match provider_name { "gemini" => Ok(Box::new(GeminiProvider::new(api_key, http_client.clone()))), - "openai" => Err(AppError::BadRequest( - "OpenAI provider is not yet implemented (planned for Phase 6)".into(), - )), - "anthropic" => Err(AppError::BadRequest( - "Anthropic provider is not yet implemented (planned for Phase 6)".into(), - )), + "openai" => Ok(Box::new(OpenAiProvider::new(api_key, http_client.clone()))), + "anthropic" => Ok(Box::new(AnthropicProvider::new( + api_key, + http_client.clone(), + ))), _ => Err(AppError::BadRequest(format!( "Unknown provider: '{}'", provider_name @@ -47,25 +48,19 @@ mod tests { } #[test] - fn factory_rejects_openai() { + fn factory_creates_openai_provider() { let client = reqwest::Client::new(); - let result = create_provider("openai", "test-key".into(), &client); - match result { - Err(AppError::BadRequest(msg)) => assert!(msg.contains("not yet implemented")), - Err(_) => panic!("Expected BadRequest variant"), - Ok(_) => panic!("Expected error for openai"), - } + let provider = create_provider("openai", "test-key".into(), &client).unwrap(); + assert_eq!(provider.provider_id(), "openai"); + assert!(provider.supports_web_search()); } #[test] - fn factory_rejects_anthropic() { + fn factory_creates_anthropic_provider() { let client = reqwest::Client::new(); - let result = create_provider("anthropic", "test-key".into(), &client); - match result { - Err(AppError::BadRequest(msg)) => assert!(msg.contains("not yet implemented")), - Err(_) => panic!("Expected BadRequest variant"), - Ok(_) => panic!("Expected error for anthropic"), - } + let provider = create_provider("anthropic", "test-key".into(), &client).unwrap(); + assert_eq!(provider.provider_id(), "anthropic"); + assert!(provider.supports_web_search()); } #[test] @@ -78,4 +73,15 @@ mod tests { Ok(_) => panic!("Expected error for unknown provider"), } } + + #[test] + fn factory_rejects_empty_provider() { + let client = reqwest::Client::new(); + let result = create_provider("", "test-key".into(), &client); + match result { + Err(AppError::BadRequest(msg)) => assert!(msg.contains("Unknown provider")), + Err(_) => panic!("Expected BadRequest variant"), + Ok(_) => panic!("Expected error for empty provider"), + } + } } diff --git a/backend/src/services/llm/mod.rs b/backend/src/services/llm/mod.rs index 09dda88..6105627 100644 --- a/backend/src/services/llm/mod.rs +++ b/backend/src/services/llm/mod.rs @@ -3,8 +3,10 @@ //! Defines the `LlmProvider` trait that all LLM providers implement, //! along with shared types and the provider factory function. +pub mod anthropic; pub mod factory; pub mod gemini; +pub mod openai; pub mod schema; use async_trait::async_trait; diff --git a/backend/src/services/llm/openai.rs b/backend/src/services/llm/openai.rs new file mode 100644 index 0000000..8d0dbfc --- /dev/null +++ b/backend/src/services/llm/openai.rs @@ -0,0 +1,577 @@ +//! OpenAI LLM provider implementation. +//! +//! Implements the `LlmProvider` trait using two OpenAI APIs: +//! - **Pass 1 (search)**: Responses API (`/v1/responses`) with `web_search_preview` tool +//! - **Pass 2 (rewrite)**: Chat Completions API (`/v1/chat/completions`) with structured output + +use async_trait::async_trait; +use serde_json::Value; + +use super::LlmProvider; +use crate::errors::AppError; + +/// OpenAI provider. +/// +/// Holds the API key and an HTTP client for making requests +/// to the OpenAI Responses and Chat Completions APIs. +pub struct OpenAiProvider { + api_key: String, + http_client: reqwest::Client, +} + +impl OpenAiProvider { + /// Create a new OpenAI provider with the given API key and HTTP client. + pub fn new(api_key: String, http_client: reqwest::Client) -> Self { + Self { + api_key, + http_client, + } + } + + /// Execute a request to the OpenAI Responses API (Pass 1). + /// + /// Uses the Responses API with `web_search_preview` tool for grounded search results + /// and structured output via `json_schema` text format. + async fn call_responses_api( + &self, + model: &str, + system_prompt: &str, + user_prompt: &str, + response_schema: &Value, + include_web_search: bool, + ) -> Result { + let mut body = serde_json::json!({ + "model": model, + "instructions": system_prompt, + "input": user_prompt, + "text": { + "format": { + "type": "json_schema", + "name": "synthesis", + "strict": true, + "schema": response_schema + } + } + }); + + if include_web_search { + body["tools"] = serde_json::json!([{ + "type": "web_search_preview" + }]); + } + + let response = self + .http_client + .post("https://api.openai.com/v1/responses") + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| { + let kind = if e.is_timeout() { + "timeout" + } else if e.is_connect() { + "connection error" + } else { + "network error" + }; + tracing::error!("OpenAI Responses API request failed: {}", kind); + AppError::Internal(anyhow::anyhow!("Failed to connect to OpenAI API")) + })?; + + let status = response.status(); + let response_body: Value = response.json().await.map_err(|e| { + tracing::error!("Failed to parse OpenAI response body: {}", e); + AppError::Internal(anyhow::anyhow!("Failed to parse OpenAI API response")) + })?; + + if !status.is_success() { + return Err(map_openai_error(status.as_u16(), &response_body)); + } + + extract_responses_api_content(&response_body) + } + + /// Execute a request to the OpenAI Chat Completions API (Pass 2). + /// + /// Uses the Chat Completions API with `json_schema` response format + /// for structured output without web search. + async fn call_chat_completions_api( + &self, + model: &str, + system_prompt: &str, + user_prompt: &str, + response_schema: &Value, + ) -> Result { + let body = serde_json::json!({ + "model": model, + "messages": [ + { + "role": "system", + "content": system_prompt + }, + { + "role": "user", + "content": user_prompt + } + ], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "synthesis", + "strict": true, + "schema": response_schema + } + } + }); + + let response = self + .http_client + .post("https://api.openai.com/v1/chat/completions") + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| { + let kind = if e.is_timeout() { + "timeout" + } else if e.is_connect() { + "connection error" + } else { + "network error" + }; + tracing::error!("OpenAI Chat Completions API request failed: {}", kind); + AppError::Internal(anyhow::anyhow!("Failed to connect to OpenAI API")) + })?; + + let status = response.status(); + let response_body: Value = response.json().await.map_err(|e| { + tracing::error!("Failed to parse OpenAI response body: {}", e); + AppError::Internal(anyhow::anyhow!("Failed to parse OpenAI API response")) + })?; + + if !status.is_success() { + return Err(map_openai_error(status.as_u16(), &response_body)); + } + + extract_chat_completions_content(&response_body) + } +} + +#[async_trait] +impl LlmProvider for OpenAiProvider { + fn provider_id(&self) -> &str { + "openai" + } + + async fn generate_search_pass( + &self, + model: &str, + system_prompt: &str, + user_prompt: &str, + response_schema: &Value, + ) -> Result { + self.call_responses_api(model, system_prompt, user_prompt, response_schema, true) + .await + } + + async fn generate_rewrite_pass( + &self, + model: &str, + system_prompt: &str, + user_prompt: &str, + response_schema: &Value, + ) -> Result { + self.call_chat_completions_api(model, system_prompt, user_prompt, response_schema) + .await + } + + fn supports_web_search(&self) -> bool { + true + } +} + +/// Extract the text content from an OpenAI Responses API response. +/// +/// The Responses API returns: +/// ```json +/// { +/// "output": [ +/// { "type": "web_search_call", ... }, +/// { "type": "message", "content": [{ "type": "output_text", "text": "..." }] } +/// ] +/// } +/// ``` +/// +/// We scan for `output_text` blocks and parse the first one as JSON. +fn extract_responses_api_content(response: &Value) -> Result { + let output = response + .get("output") + .and_then(|o| o.as_array()) + .ok_or_else(|| { + tracing::error!( + "Unexpected OpenAI Responses API structure: missing 'output' array" + ); + AppError::Internal(anyhow::anyhow!( + "OpenAI Responses API returned an unexpected response structure" + )) + })?; + + // Scan output items for type "message" with content containing "output_text" + for item in output { + if item.get("type").and_then(|t| t.as_str()) != Some("message") { + continue; + } + + let content = match item.get("content").and_then(|c| c.as_array()) { + Some(c) => c, + None => continue, + }; + + for block in content { + if block.get("type").and_then(|t| t.as_str()) == Some("output_text") { + let text = block.get("text").and_then(|t| t.as_str()).ok_or_else(|| { + tracing::error!("OpenAI output_text block missing 'text' field"); + AppError::Internal(anyhow::anyhow!( + "OpenAI Responses API returned output_text without text" + )) + })?; + + return serde_json::from_str(text).map_err(|e| { + tracing::error!("Failed to parse OpenAI JSON output: {}", e); + AppError::Internal(anyhow::anyhow!( + "OpenAI returned invalid JSON in structured output" + )) + }); + } + } + } + + tracing::error!("No output_text found in OpenAI Responses API response"); + Err(AppError::Internal(anyhow::anyhow!( + "OpenAI Responses API returned no text output" + ))) +} + +/// Extract the text content from an OpenAI Chat Completions API response. +/// +/// The response structure is: +/// ```json +/// { "choices": [{ "message": { "content": "..." } }] } +/// ``` +fn extract_chat_completions_content(response: &Value) -> Result { + let text = response + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|t| t.as_str()) + .ok_or_else(|| { + tracing::error!("Unexpected OpenAI Chat Completions response structure"); + AppError::Internal(anyhow::anyhow!( + "OpenAI Chat Completions API returned an unexpected response structure" + )) + })?; + + serde_json::from_str(text).map_err(|e| { + tracing::error!("Failed to parse OpenAI Chat Completions JSON output: {}", e); + AppError::Internal(anyhow::anyhow!( + "OpenAI returned invalid JSON in structured output" + )) + }) +} + +/// Map OpenAI API error responses to appropriate `AppError` variants. +/// +/// Handles common error codes without exposing internal details. +fn map_openai_error(status: u16, body: &Value) -> AppError { + let error_message = body + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("Unknown error"); + + let error_type = body + .get("error") + .and_then(|e| e.get("type")) + .and_then(|t| t.as_str()) + .unwrap_or(""); + + // Log error details but NEVER the API key + tracing::error!( + "OpenAI API error (HTTP {}): {} (type: {})", + status, + error_message, + error_type + ); + + match status { + 400 => AppError::BadRequest("Invalid request to LLM provider".into()), + 401 => AppError::BadRequest("Invalid or unauthorized API key".into()), + 403 => AppError::BadRequest("Access denied by LLM provider".into()), + 404 => AppError::BadRequest("Model not found or not available".into()), + 429 => AppError::RateLimited( + "LLM provider rate limit exceeded. Please try again later.".into(), + ), + _ => AppError::Internal(anyhow::anyhow!("LLM provider returned an error")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── Request body tests ────────────────────────────────────── + + #[test] + fn openai_provider_metadata() { + let provider = OpenAiProvider::new("test-key".into(), reqwest::Client::new()); + assert_eq!(provider.provider_id(), "openai"); + assert!(provider.supports_web_search()); + } + + // ── Responses API response parsing ────────────────────────── + + #[test] + fn extract_responses_api_content_valid() { + let response = serde_json::json!({ + "id": "resp_123", + "output": [ + { + "type": "web_search_call", + "id": "ws_1", + "status": "completed" + }, + { + "type": "message", + "content": [ + { + "type": "output_text", + "text": "{\"category_0\": [{\"title\": \"Test\", \"url\": \"https://example.com\", \"summary\": \"A test article\"}]}" + } + ] + } + ] + }); + + let result = extract_responses_api_content(&response).unwrap(); + assert!(result["category_0"].is_array()); + assert_eq!(result["category_0"][0]["title"].as_str().unwrap(), "Test"); + } + + #[test] + fn extract_responses_api_content_multiple_output_items() { + // Multiple web_search_call items before the message + let response = serde_json::json!({ + "output": [ + { "type": "web_search_call", "id": "ws_1", "status": "completed" }, + { "type": "web_search_call", "id": "ws_2", "status": "completed" }, + { + "type": "message", + "content": [ + { + "type": "output_text", + "text": "{\"category_0\": []}" + } + ] + } + ] + }); + + let result = extract_responses_api_content(&response).unwrap(); + assert!(result["category_0"].is_array()); + } + + #[test] + fn extract_responses_api_content_missing_output() { + let response = serde_json::json!({}); + assert!(extract_responses_api_content(&response).is_err()); + } + + #[test] + fn extract_responses_api_content_no_message_item() { + let response = serde_json::json!({ + "output": [ + { "type": "web_search_call", "id": "ws_1", "status": "completed" } + ] + }); + assert!(extract_responses_api_content(&response).is_err()); + } + + #[test] + fn extract_responses_api_content_invalid_json_text() { + let response = serde_json::json!({ + "output": [ + { + "type": "message", + "content": [ + { + "type": "output_text", + "text": "not valid json" + } + ] + } + ] + }); + assert!(extract_responses_api_content(&response).is_err()); + } + + // ── Chat Completions response parsing ─────────────────────── + + #[test] + fn extract_chat_completions_content_valid() { + let response = serde_json::json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": "{\"category_0\": [{\"title\": \"Rewritten\", \"url\": \"https://example.com\", \"summary\": \"Rewritten summary\"}]}" + }, + "finish_reason": "stop" + }] + }); + + let result = extract_chat_completions_content(&response).unwrap(); + assert!(result["category_0"].is_array()); + assert_eq!( + result["category_0"][0]["title"].as_str().unwrap(), + "Rewritten" + ); + } + + #[test] + fn extract_chat_completions_content_missing_choices() { + let response = serde_json::json!({}); + assert!(extract_chat_completions_content(&response).is_err()); + } + + #[test] + fn extract_chat_completions_content_empty_choices() { + let response = serde_json::json!({"choices": []}); + assert!(extract_chat_completions_content(&response).is_err()); + } + + #[test] + fn extract_chat_completions_content_invalid_json() { + let response = serde_json::json!({ + "choices": [{ + "message": { + "content": "this is not json" + } + }] + }); + assert!(extract_chat_completions_content(&response).is_err()); + } + + // ── Error mapping tests ───────────────────────────────────── + + #[test] + fn map_openai_error_invalid_key() { + let body = serde_json::json!({ + "error": { + "message": "Incorrect API key provided", + "type": "invalid_request_error", + "code": "invalid_api_key" + } + }); + + let err = map_openai_error(401, &body); + match err { + AppError::BadRequest(msg) => assert!(msg.contains("unauthorized")), + _ => panic!("Expected BadRequest for 401"), + } + } + + #[test] + fn map_openai_error_rate_limited() { + let body = serde_json::json!({ + "error": { + "message": "Rate limit reached for model gpt-4o", + "type": "rate_limit_error", + "code": "rate_limit_exceeded" + } + }); + + let err = map_openai_error(429, &body); + match err { + AppError::RateLimited(msg) => assert!(msg.contains("rate limit")), + _ => panic!("Expected RateLimited for 429"), + } + } + + #[test] + fn map_openai_error_bad_request() { + let body = serde_json::json!({ + "error": { + "message": "Invalid model specified", + "type": "invalid_request_error" + } + }); + + let err = map_openai_error(400, &body); + match err { + AppError::BadRequest(msg) => assert!(msg.contains("Invalid request")), + _ => panic!("Expected BadRequest for 400"), + } + } + + #[test] + fn map_openai_error_model_not_found() { + let body = serde_json::json!({ + "error": { + "message": "The model does not exist", + "type": "invalid_request_error" + } + }); + + let err = map_openai_error(404, &body); + match err { + AppError::BadRequest(msg) => assert!(msg.contains("not found")), + _ => panic!("Expected BadRequest for 404"), + } + } + + #[test] + fn map_openai_error_server_error() { + let body = serde_json::json!({ + "error": { + "message": "Internal server error", + "type": "server_error" + } + }); + + let err = map_openai_error(500, &body); + match err { + AppError::Internal(_) => {} // expected + _ => panic!("Expected Internal for 500"), + } + } + + #[test] + fn map_openai_error_forbidden() { + let body = serde_json::json!({ + "error": { + "message": "Access denied", + "type": "forbidden" + } + }); + + let err = map_openai_error(403, &body); + match err { + AppError::BadRequest(msg) => assert!(msg.contains("Access denied")), + _ => panic!("Expected BadRequest for 403"), + } + } + + #[test] + fn map_openai_error_unknown_body() { + // Sometimes the body may lack the standard error structure + let body = serde_json::json!({}); + let err = map_openai_error(502, &body); + match err { + AppError::Internal(_) => {} // expected + _ => panic!("Expected Internal for 502"), + } + } +} diff --git a/backend/src/services/synthesis.rs b/backend/src/services/synthesis.rs index a9148b3..cfa96dc 100644 --- a/backend/src/services/synthesis.rs +++ b/backend/src/services/synthesis.rs @@ -299,28 +299,47 @@ async fn run_generation_inner( emit_progress(tx, "parsing", "Analyse des resultats...", 40); let parsed = parse_llm_output(&raw_results, &settings.categories)?; - // Step 8: Validate/scrape URLs (parallel, bounded concurrency) - emit_progress(tx, "scraping", "Verification des sources...", 45); - let scraped = scrape_articles(state, &parsed, settings.max_age_days as i64, tx).await; - - // Step 9: Rate limit check (pass 2) - if !state.provider_rate_limiter.check(&provider_name) { - return Err(AppError::RateLimited( - "Limite de requetes atteinte pour la passe de reecriture. Veuillez reessayer.".into(), - )); - } + // Step 8: Adaptive pipeline — decide whether to scrape+rewrite or use search results directly + // + // If the provider supports native web search and the search pass produced high-quality + // results (>70% valid URLs starting with http), we can skip the expensive scrape+rewrite + // pass and use the search results directly. + let final_sections = if provider.supports_web_search() && url_quality_sufficient(&parsed) { + tracing::info!( + provider = provider.provider_id(), + "Search pass URL quality sufficient, skipping scrape+rewrite pass" + ); + emit_progress( + tx, + "finalizing", + "Resultats de recherche de bonne qualite, finalisation directe...", + 85, + ); + build_final_sections(&raw_results, &settings.categories)? + } else { + // Full pipeline: scrape + rewrite + emit_progress(tx, "scraping", "Verification des sources...", 45); + let scraped = scrape_articles(state, &parsed, settings.max_age_days as i64, tx).await; + + // Rate limit check (pass 2) + if !state.provider_rate_limiter.check(&provider_name) { + return Err(AppError::RateLimited( + "Limite de requetes atteinte pour la passe de reecriture. Veuillez reessayer." + .into(), + )); + } - // Step 10: LLM rewrite pass - emit_progress(tx, "rewrite", "Redaction des resumes...", 80); - let (rewrite_system, rewrite_user) = prompts::build_rewrite_prompt(&scraped); + // LLM rewrite pass + emit_progress(tx, "rewrite", "Redaction des resumes...", 80); + let (rewrite_system, rewrite_user) = prompts::build_rewrite_prompt(&scraped); - let final_results = provider - .generate_rewrite_pass(&model, &rewrite_system, &rewrite_user, &schema) - .await?; + let final_results = provider + .generate_rewrite_pass(&model, &rewrite_system, &rewrite_user, &schema) + .await?; - // Step 11: Parse final output - emit_progress(tx, "finalizing", "Finalisation...", 90); - let final_sections = build_final_sections(&final_results, &settings.categories)?; + emit_progress(tx, "finalizing", "Finalisation...", 90); + build_final_sections(&final_results, &settings.categories)? + }; // Step 12: Save synthesis to DB emit_progress(tx, "saving", "Sauvegarde de la synthese...", 95); @@ -575,6 +594,47 @@ fn build_final_sections( Ok(sections) } +/// Minimum ratio of valid URLs (starting with `http`) required to skip the +/// scrape+rewrite pass and use the search pass results directly. +const URL_QUALITY_THRESHOLD: f64 = 0.70; + +/// Check whether the search pass produced sufficiently high-quality URLs. +/// +/// Returns `true` if more than 70% of the URLs across all categories start +/// with `http` (indicating they are real web URLs rather than hallucinated +/// or malformed references). +/// +/// If there are no articles at all, returns `false` to fall through to the +/// full pipeline. +fn url_quality_sufficient(parsed: &[(String, Vec)]) -> bool { + let mut total = 0usize; + let mut valid = 0usize; + + for (_cat_key, items) in parsed { + for item in items { + total += 1; + if item.url.starts_with("http") { + valid += 1; + } + } + } + + if total == 0 { + return false; + } + + let ratio = valid as f64 / total as f64; + tracing::debug!( + total_urls = total, + valid_urls = valid, + ratio = ratio, + threshold = URL_QUALITY_THRESHOLD, + "URL quality check" + ); + + ratio >= URL_QUALITY_THRESHOLD +} + /// Sanitize error messages to prevent leaking sensitive information. /// /// Removes potential API keys, internal paths, and other sensitive data. @@ -584,6 +644,7 @@ fn sanitize_error_message(msg: &str) -> String { || msg.contains("api_key") || msg.contains("AIza") || msg.contains("sk-") + || msg.contains("sk-ant-") || msg.contains("PERMISSION_DENIED") { return "Erreur d'authentification avec le fournisseur IA. Verifiez votre cle API.".into(); @@ -859,4 +920,146 @@ mod tests { let sanitized = sanitize_error_message(msg); assert_eq!(sanitized, msg); } + + // ── url_quality_sufficient tests ──────────────────────────── + + #[test] + fn url_quality_all_valid_urls() { + let parsed = vec![ + ( + "category_0".into(), + vec![ + NewsItem { + title: "A".into(), + url: "https://example.com/a".into(), + summary: "Sum A".into(), + }, + NewsItem { + title: "B".into(), + url: "https://example.com/b".into(), + summary: "Sum B".into(), + }, + ], + ), + ( + "category_1".into(), + vec![NewsItem { + title: "C".into(), + url: "http://example.org/c".into(), + summary: "Sum C".into(), + }], + ), + ]; + + // 3/3 = 100% valid -> true + assert!(url_quality_sufficient(&parsed)); + } + + #[test] + fn url_quality_above_threshold() { + // 8 valid out of 10 = 80% > 70% + let mut items = Vec::new(); + for i in 0..8 { + items.push(NewsItem { + title: format!("Art {}", i), + url: format!("https://example.com/{}", i), + summary: "Sum".into(), + }); + } + for i in 8..10 { + items.push(NewsItem { + title: format!("Art {}", i), + url: format!("bad-url-{}", i), + summary: "Sum".into(), + }); + } + + let parsed = vec![("category_0".into(), items)]; + assert!(url_quality_sufficient(&parsed)); + } + + #[test] + fn url_quality_exactly_at_threshold() { + // 7 valid out of 10 = 70% >= 70% + let mut items = Vec::new(); + for i in 0..7 { + items.push(NewsItem { + title: format!("Art {}", i), + url: format!("https://example.com/{}", i), + summary: "Sum".into(), + }); + } + for i in 7..10 { + items.push(NewsItem { + title: format!("Art {}", i), + url: format!("bad-url-{}", i), + summary: "Sum".into(), + }); + } + + let parsed = vec![("category_0".into(), items)]; + assert!(url_quality_sufficient(&parsed)); + } + + #[test] + fn url_quality_below_threshold() { + // 6 valid out of 10 = 60% < 70% + let mut items = Vec::new(); + for i in 0..6 { + items.push(NewsItem { + title: format!("Art {}", i), + url: format!("https://example.com/{}", i), + summary: "Sum".into(), + }); + } + for i in 6..10 { + items.push(NewsItem { + title: format!("Art {}", i), + url: format!("no-protocol-{}", i), + summary: "Sum".into(), + }); + } + + let parsed = vec![("category_0".into(), items)]; + assert!(!url_quality_sufficient(&parsed)); + } + + #[test] + fn url_quality_all_invalid_urls() { + let parsed = vec![( + "category_0".into(), + vec![ + NewsItem { + title: "A".into(), + url: "not-a-url".into(), + summary: "Sum".into(), + }, + NewsItem { + title: "B".into(), + url: "also-not-a-url".into(), + summary: "Sum".into(), + }, + ], + )]; + + // 0/2 = 0% -> false + assert!(!url_quality_sufficient(&parsed)); + } + + #[test] + fn url_quality_empty_articles() { + let parsed: Vec<(String, Vec)> = vec![ + ("category_0".into(), vec![]), + ("category_1".into(), vec![]), + ]; + + // No articles -> false (fall through to full pipeline) + assert!(!url_quality_sufficient(&parsed)); + } + + #[test] + fn url_quality_empty_categories() { + let parsed: Vec<(String, Vec)> = vec![]; + assert!(!url_quality_sufficient(&parsed)); + } } diff --git a/frontend/src/__tests__/provider-info.test.ts b/frontend/src/__tests__/provider-info.test.ts new file mode 100644 index 0000000..82e7897 --- /dev/null +++ b/frontend/src/__tests__/provider-info.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { + providerSupportsWebSearch, + getProviderInfoKey, + getWebSearchBadgeKey, +} from '~/utils/providers'; + +describe('Provider info utilities', () => { + describe('providerSupportsWebSearch', () => { + it('should return true for gemini', () => { + expect(providerSupportsWebSearch('gemini')).toBe(true); + }); + + it('should return true for openai', () => { + expect(providerSupportsWebSearch('openai')).toBe(true); + }); + + it('should return true for anthropic', () => { + expect(providerSupportsWebSearch('anthropic')).toBe(true); + }); + + it('should return false for unknown provider', () => { + expect(providerSupportsWebSearch('unknown')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(providerSupportsWebSearch('')).toBe(false); + }); + }); + + describe('getProviderInfoKey', () => { + it('should return gemini-specific key for gemini', () => { + expect(getProviderInfoKey('gemini')).toBe('settings.provider.geminiInfo'); + }); + + it('should return openai-specific key for openai', () => { + expect(getProviderInfoKey('openai')).toBe('settings.provider.openaiInfo'); + }); + + it('should return anthropic-specific key for anthropic', () => { + expect(getProviderInfoKey('anthropic')).toBe( + 'settings.provider.anthropicInfo', + ); + }); + + it('should return noWebSearchInfo key for unknown provider', () => { + expect(getProviderInfoKey('unknown')).toBe( + 'settings.provider.noWebSearchInfo', + ); + }); + }); + + describe('getWebSearchBadgeKey', () => { + it('should return webSearchInfo for providers with web search', () => { + expect(getWebSearchBadgeKey('gemini')).toBe( + 'settings.provider.webSearchInfo', + ); + expect(getWebSearchBadgeKey('openai')).toBe( + 'settings.provider.webSearchInfo', + ); + expect(getWebSearchBadgeKey('anthropic')).toBe( + 'settings.provider.webSearchInfo', + ); + }); + + it('should return noWebSearchInfo for providers without web search', () => { + expect(getWebSearchBadgeKey('unknown')).toBe( + 'settings.provider.noWebSearchInfo', + ); + }); + }); +}); diff --git a/frontend/src/i18n/fr.ts b/frontend/src/i18n/fr.ts index 50bf1c4..1ca7023 100644 --- a/frontend/src/i18n/fr.ts +++ b/frontend/src/i18n/fr.ts @@ -79,6 +79,10 @@ const fr = { 'generate.canLeave': 'Vous pouvez quitter cette page. La generation continuera en arriere-plan.', 'generate.retry': 'Reessayer', 'generate.alreadyInProgress': 'Une generation est deja en cours.', + 'generate.provider': 'Fournisseur', + 'generate.model': 'Modele', + 'generate.noWebSearchWarning': + 'Le fournisseur selectionne ne supporte pas la recherche web. Les resultats seront bases uniquement sur les connaissances du modele.', // Synthesis Detail 'synthesis.title': 'Synthese de la Semaine {week}', @@ -196,6 +200,17 @@ const fr = { 'settings.modelPlaceholder': 'Selectionnez un modele', 'settings.providerUnavailable': "Le fournisseur que vous utilisiez n'est plus disponible. Veuillez en selectionner un autre.", + 'settings.provider.webSearchInfo': + 'La recherche web en temps reel est disponible avec ce fournisseur.', + 'settings.provider.noWebSearchInfo': + 'Les resultats seront bases sur les connaissances du modele, sans recherche web.', + 'settings.provider.geminiInfo': + 'Google Gemini avec recherche Google integree.', + 'settings.provider.openaiInfo': 'OpenAI avec recherche web integree.', + 'settings.provider.anthropicInfo': + 'Anthropic Claude avec recherche web integree.', + 'settings.provider.webSearchBadge': 'Recherche web', + 'settings.provider.noWebSearchBadge': 'Sans recherche web', // Admin - Navigation 'admin.title': 'Administration', diff --git a/frontend/src/pages/GenerateSynthesis.tsx b/frontend/src/pages/GenerateSynthesis.tsx index f398aea..f7c28a1 100644 --- a/frontend/src/pages/GenerateSynthesis.tsx +++ b/frontend/src/pages/GenerateSynthesis.tsx @@ -7,13 +7,15 @@ import { For, } from 'solid-js'; import { useNavigate } from '@solidjs/router'; -import { AlertCircle, CheckCircle, Circle, Loader2 } from 'lucide-solid'; +import { AlertCircle, AlertTriangle, CheckCircle, Circle, Loader2 } from 'lucide-solid'; import { useI18n } from '~/i18n'; import { synthesesApi } from '~/api/syntheses'; import { settingsApi } from '~/api/settings'; +import { configApi } from '~/api/config'; import { isApiError, DEFAULT_SETTINGS } from '~/types'; -import type { UserSettings, ProgressEvent } from '~/types'; +import type { UserSettings, ProviderConfig, ProgressEvent } from '~/types'; import { createSSEConnection, type SSEConnection, type SSEStatus } from '~/utils/sse'; +import { providerSupportsWebSearch } from '~/utils/providers'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; interface StepInfo { @@ -33,6 +35,7 @@ const GenerateSynthesis: Component = () => { const navigate = useNavigate(); const [settings, setSettings] = createSignal({ ...DEFAULT_SETTINGS }); + const [providers, setProviders] = createSignal([]); const [loadingSettings, setLoadingSettings] = createSignal(true); const [generating, setGenerating] = createSignal(false); const [error, setError] = createSignal(null); @@ -41,17 +44,41 @@ const GenerateSynthesis: Component = () => { onMount(async () => { try { - const data = await settingsApi.get(); - setSettings(data); - } catch (err) { - if (isApiError(err) && err.status !== 404) { - // Non-404 means a real error; 404 means no settings yet, use defaults - } + const [data, providerList] = await Promise.all([ + settingsApi.get().catch((err) => { + if (isApiError(err) && err.status === 404) return null; + throw err; + }), + configApi.listProviders().catch(() => [] as ProviderConfig[]), + ]); + if (data) setSettings(data); + setProviders(providerList); + } catch { + // Non-404 settings error — use defaults silently } finally { setLoadingSettings(false); } }); + const selectedProvider = (): ProviderConfig | undefined => { + return providers().find((p) => p.provider_name === settings().ai_provider); + }; + + const providerDisplayName = (): string => { + return selectedProvider()?.display_name ?? settings().ai_provider; + }; + + const modelDisplayName = (): string => { + const provider = selectedProvider(); + if (!provider) return settings().ai_model; + const model = provider.models.find((m) => m.model_id === settings().ai_model); + return model?.display_name ?? settings().ai_model; + }; + + const hasWebSearch = (): boolean => { + return providerSupportsWebSearch(settings().ai_provider); + }; + const currentStep = (): string | null => { const conn = sseConnection(); if (!conn) return null; @@ -207,11 +234,36 @@ const GenerateSynthesis: Component = () => { days: String(settings().max_age_days), theme: settings().theme, })} /> + +

+ {t('generate.provider')}{' '} + {providerDisplayName()} + {' · '} + {t('generate.model')}{' '} + {modelDisplayName()} +

+

{t('generate.note')}

+ {/* No web search warning */} + +
+
+
+
+
+

+ {t('generate.noWebSearchWarning')} +

+
+
+
+
+ {/* Error display */}
diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 887ce1a..7efe373 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -7,7 +7,7 @@ import { For, createEffect, } from 'solid-js'; -import { Settings as SettingsIcon, Save, Plus, Trash2 } from 'lucide-solid'; +import { Settings as SettingsIcon, Save, Plus, Trash2, Info } from 'lucide-solid'; import { settingsApi } from '~/api/settings'; import { configApi } from '~/api/config'; import { useI18n } from '~/i18n'; @@ -15,6 +15,7 @@ import { DEFAULT_SETTINGS, isApiError } from '~/types'; import type { UserSettings, ProviderConfig } from '~/types'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; import ApiKeyManager from '~/components/ApiKeyManager'; +import { getProviderInfoKey, providerSupportsWebSearch } from '~/utils/providers'; const Settings: Component = () => { const { t } = useI18n(); @@ -380,6 +381,32 @@ const Settings: Component = () => { {t('settings.modelHelp')}

+ + {/* Provider info text + web search badge */} + + {(provider) => ( +
+ +
+

+ {t(getProviderInfoKey(provider().provider_name))} +

+ + {t('settings.provider.noWebSearchBadge')} + + } + > + + {t('settings.provider.webSearchBadge')} + + +
+
+ )} +
{/* Categories */} diff --git a/frontend/src/utils/providers.ts b/frontend/src/utils/providers.ts new file mode 100644 index 0000000..47aaacc --- /dev/null +++ b/frontend/src/utils/providers.ts @@ -0,0 +1,46 @@ +import type { TranslationKey } from '~/i18n/fr'; + +/** + * Known provider names that support native web search grounding. + * + * All three currently supported providers (Gemini, OpenAI, Anthropic) + * support web search. This set can be extended or modified if a new + * provider without web search support is added in the future. + */ +const WEB_SEARCH_PROVIDERS = new Set(['gemini', 'openai', 'anthropic']); + +/** + * Returns whether a given provider supports native web search. + */ +export function providerSupportsWebSearch(providerName: string): boolean { + return WEB_SEARCH_PROVIDERS.has(providerName); +} + +/** + * Returns the i18n key for provider-specific info text. + * Falls back to the generic web search info key if the provider + * is not one of the known providers. + */ +export function getProviderInfoKey(providerName: string): TranslationKey { + switch (providerName) { + case 'gemini': + return 'settings.provider.geminiInfo'; + case 'openai': + return 'settings.provider.openaiInfo'; + case 'anthropic': + return 'settings.provider.anthropicInfo'; + default: + return providerSupportsWebSearch(providerName) + ? 'settings.provider.webSearchInfo' + : 'settings.provider.noWebSearchInfo'; + } +} + +/** + * Returns the i18n key for the web search support badge. + */ +export function getWebSearchBadgeKey(providerName: string): TranslationKey { + return providerSupportsWebSearch(providerName) + ? 'settings.provider.webSearchInfo' + : 'settings.provider.noWebSearchInfo'; +}