Phase 6: Multi-provider support with OpenAI and Anthropic

Backend:
- OpenAiProvider: Responses API with web_search_preview (pass 1),
  Chat Completions with json_schema structured output (pass 2)
- AnthropicProvider: Messages API with web_search tool (pass 1),
  schema-in-prompt for structured output, code fence stripping (pass 2)
- Pipeline adaptation: skip scrape+rewrite when >70% of search URLs are valid
- Provider factory updated for all three providers
- Error sanitization extended for Anthropic key patterns (sk-ant-)
- 44 new unit tests (OpenAI, Anthropic, factory, pipeline heuristic)

Frontend:
- Provider-specific info text below model selection
- Web search support badges (green/gray)
- Generate page shows selected provider and model
- Warning banner when provider lacks web search
- Provider utility module with 10 tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
master
oabrivard 3 months ago
parent aa6f1ba76b
commit 631bd43b9f

@ -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<Value, AppError> {
// 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<Value, AppError> {
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<Value, AppError> {
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<Value, AppError> {
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"),
}
}
}

@ -1,7 +1,9 @@
//! Provider factory: creates the correct `LlmProvider` implementation //! Provider factory: creates the correct `LlmProvider` implementation
//! based on the provider name and the user's decrypted API key. //! based on the provider name and the user's decrypted API key.
use super::anthropic::AnthropicProvider;
use super::gemini::GeminiProvider; use super::gemini::GeminiProvider;
use super::openai::OpenAiProvider;
use super::LlmProvider; use super::LlmProvider;
use crate::errors::AppError; use crate::errors::AppError;
@ -13,7 +15,7 @@ use crate::errors::AppError;
/// * `http_client` — Shared HTTP client for making API calls /// * `http_client` — Shared HTTP client for making API calls
/// ///
/// # Errors /// # Errors
/// Returns `AppError::BadRequest` if the provider is not yet supported. /// Returns `AppError::BadRequest` if the provider name is unknown.
pub fn create_provider( pub fn create_provider(
provider_name: &str, provider_name: &str,
api_key: String, api_key: String,
@ -21,12 +23,11 @@ pub fn create_provider(
) -> Result<Box<dyn LlmProvider>, AppError> { ) -> Result<Box<dyn LlmProvider>, AppError> {
match provider_name { match provider_name {
"gemini" => Ok(Box::new(GeminiProvider::new(api_key, http_client.clone()))), "gemini" => Ok(Box::new(GeminiProvider::new(api_key, http_client.clone()))),
"openai" => Err(AppError::BadRequest( "openai" => Ok(Box::new(OpenAiProvider::new(api_key, http_client.clone()))),
"OpenAI provider is not yet implemented (planned for Phase 6)".into(), "anthropic" => Ok(Box::new(AnthropicProvider::new(
)), api_key,
"anthropic" => Err(AppError::BadRequest( http_client.clone(),
"Anthropic provider is not yet implemented (planned for Phase 6)".into(), ))),
)),
_ => Err(AppError::BadRequest(format!( _ => Err(AppError::BadRequest(format!(
"Unknown provider: '{}'", "Unknown provider: '{}'",
provider_name provider_name
@ -47,25 +48,19 @@ mod tests {
} }
#[test] #[test]
fn factory_rejects_openai() { fn factory_creates_openai_provider() {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let result = create_provider("openai", "test-key".into(), &client); let provider = create_provider("openai", "test-key".into(), &client).unwrap();
match result { assert_eq!(provider.provider_id(), "openai");
Err(AppError::BadRequest(msg)) => assert!(msg.contains("not yet implemented")), assert!(provider.supports_web_search());
Err(_) => panic!("Expected BadRequest variant"),
Ok(_) => panic!("Expected error for openai"),
}
} }
#[test] #[test]
fn factory_rejects_anthropic() { fn factory_creates_anthropic_provider() {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let result = create_provider("anthropic", "test-key".into(), &client); let provider = create_provider("anthropic", "test-key".into(), &client).unwrap();
match result { assert_eq!(provider.provider_id(), "anthropic");
Err(AppError::BadRequest(msg)) => assert!(msg.contains("not yet implemented")), assert!(provider.supports_web_search());
Err(_) => panic!("Expected BadRequest variant"),
Ok(_) => panic!("Expected error for anthropic"),
}
} }
#[test] #[test]
@ -78,4 +73,15 @@ mod tests {
Ok(_) => panic!("Expected error for unknown provider"), 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"),
}
}
} }

@ -3,8 +3,10 @@
//! Defines the `LlmProvider` trait that all LLM providers implement, //! Defines the `LlmProvider` trait that all LLM providers implement,
//! along with shared types and the provider factory function. //! along with shared types and the provider factory function.
pub mod anthropic;
pub mod factory; pub mod factory;
pub mod gemini; pub mod gemini;
pub mod openai;
pub mod schema; pub mod schema;
use async_trait::async_trait; use async_trait::async_trait;

@ -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<Value, AppError> {
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<Value, AppError> {
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<Value, AppError> {
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<Value, AppError> {
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<Value, AppError> {
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<Value, AppError> {
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"),
}
}
}

@ -299,28 +299,47 @@ async fn run_generation_inner(
emit_progress(tx, "parsing", "Analyse des resultats...", 40); emit_progress(tx, "parsing", "Analyse des resultats...", 40);
let parsed = parse_llm_output(&raw_results, &settings.categories)?; let parsed = parse_llm_output(&raw_results, &settings.categories)?;
// Step 8: Validate/scrape URLs (parallel, bounded concurrency) // Step 8: Adaptive pipeline — decide whether to scrape+rewrite or use search results directly
emit_progress(tx, "scraping", "Verification des sources...", 45); //
let scraped = scrape_articles(state, &parsed, settings.max_age_days as i64, tx).await; // 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
// Step 9: Rate limit check (pass 2) // pass and use the search results directly.
if !state.provider_rate_limiter.check(&provider_name) { let final_sections = if provider.supports_web_search() && url_quality_sufficient(&parsed) {
return Err(AppError::RateLimited( tracing::info!(
"Limite de requetes atteinte pour la passe de reecriture. Veuillez reessayer.".into(), 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 // LLM rewrite pass
emit_progress(tx, "rewrite", "Redaction des resumes...", 80); emit_progress(tx, "rewrite", "Redaction des resumes...", 80);
let (rewrite_system, rewrite_user) = prompts::build_rewrite_prompt(&scraped); let (rewrite_system, rewrite_user) = prompts::build_rewrite_prompt(&scraped);
let final_results = provider let final_results = provider
.generate_rewrite_pass(&model, &rewrite_system, &rewrite_user, &schema) .generate_rewrite_pass(&model, &rewrite_system, &rewrite_user, &schema)
.await?; .await?;
// Step 11: Parse final output emit_progress(tx, "finalizing", "Finalisation...", 90);
emit_progress(tx, "finalizing", "Finalisation...", 90); build_final_sections(&final_results, &settings.categories)?
let final_sections = build_final_sections(&final_results, &settings.categories)?; };
// Step 12: Save synthesis to DB // Step 12: Save synthesis to DB
emit_progress(tx, "saving", "Sauvegarde de la synthese...", 95); emit_progress(tx, "saving", "Sauvegarde de la synthese...", 95);
@ -575,6 +594,47 @@ fn build_final_sections(
Ok(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<NewsItem>)]) -> 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. /// Sanitize error messages to prevent leaking sensitive information.
/// ///
/// Removes potential API keys, internal paths, and other sensitive data. /// 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("api_key")
|| msg.contains("AIza") || msg.contains("AIza")
|| msg.contains("sk-") || msg.contains("sk-")
|| msg.contains("sk-ant-")
|| msg.contains("PERMISSION_DENIED") || msg.contains("PERMISSION_DENIED")
{ {
return "Erreur d'authentification avec le fournisseur IA. Verifiez votre cle API.".into(); 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); let sanitized = sanitize_error_message(msg);
assert_eq!(sanitized, 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<NewsItem>)> = 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<NewsItem>)> = vec![];
assert!(!url_quality_sufficient(&parsed));
}
} }

@ -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',
);
});
});
});

@ -79,6 +79,10 @@ const fr = {
'generate.canLeave': 'Vous pouvez quitter cette page. La generation continuera en arriere-plan.', 'generate.canLeave': 'Vous pouvez quitter cette page. La generation continuera en arriere-plan.',
'generate.retry': 'Reessayer', 'generate.retry': 'Reessayer',
'generate.alreadyInProgress': 'Une generation est deja en cours.', '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 Detail
'synthesis.title': 'Synthese de la Semaine {week}', 'synthesis.title': 'Synthese de la Semaine {week}',
@ -196,6 +200,17 @@ const fr = {
'settings.modelPlaceholder': 'Selectionnez un modele', 'settings.modelPlaceholder': 'Selectionnez un modele',
'settings.providerUnavailable': 'settings.providerUnavailable':
"Le fournisseur que vous utilisiez n'est plus disponible. Veuillez en selectionner un autre.", "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 - Navigation
'admin.title': 'Administration', 'admin.title': 'Administration',

@ -7,13 +7,15 @@ import {
For, For,
} from 'solid-js'; } from 'solid-js';
import { useNavigate } from '@solidjs/router'; 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 { useI18n } from '~/i18n';
import { synthesesApi } from '~/api/syntheses'; import { synthesesApi } from '~/api/syntheses';
import { settingsApi } from '~/api/settings'; import { settingsApi } from '~/api/settings';
import { configApi } from '~/api/config';
import { isApiError, DEFAULT_SETTINGS } from '~/types'; 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 { createSSEConnection, type SSEConnection, type SSEStatus } from '~/utils/sse';
import { providerSupportsWebSearch } from '~/utils/providers';
import LoadingSpinner from '~/components/ui/LoadingSpinner'; import LoadingSpinner from '~/components/ui/LoadingSpinner';
interface StepInfo { interface StepInfo {
@ -33,6 +35,7 @@ const GenerateSynthesis: Component = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [settings, setSettings] = createSignal<UserSettings>({ ...DEFAULT_SETTINGS }); const [settings, setSettings] = createSignal<UserSettings>({ ...DEFAULT_SETTINGS });
const [providers, setProviders] = createSignal<ProviderConfig[]>([]);
const [loadingSettings, setLoadingSettings] = createSignal(true); const [loadingSettings, setLoadingSettings] = createSignal(true);
const [generating, setGenerating] = createSignal(false); const [generating, setGenerating] = createSignal(false);
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
@ -41,17 +44,41 @@ const GenerateSynthesis: Component = () => {
onMount(async () => { onMount(async () => {
try { try {
const data = await settingsApi.get(); const [data, providerList] = await Promise.all([
setSettings(data); settingsApi.get().catch((err) => {
} catch (err) { if (isApiError(err) && err.status === 404) return null;
if (isApiError(err) && err.status !== 404) { throw err;
// Non-404 means a real error; 404 means no settings yet, use defaults }),
} configApi.listProviders().catch(() => [] as ProviderConfig[]),
]);
if (data) setSettings(data);
setProviders(providerList);
} catch {
// Non-404 settings error — use defaults silently
} finally { } finally {
setLoadingSettings(false); 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 currentStep = (): string | null => {
const conn = sseConnection(); const conn = sseConnection();
if (!conn) return null; if (!conn) return null;
@ -207,11 +234,36 @@ const GenerateSynthesis: Component = () => {
days: String(settings().max_age_days), days: String(settings().max_age_days),
theme: settings().theme, theme: settings().theme,
})} /> })} />
<Show when={settings().ai_provider}>
<p class="mt-2 text-sm text-gray-500">
<span class="font-medium text-gray-600">{t('generate.provider')}</span>{' '}
{providerDisplayName()}
{' · '}
<span class="font-medium text-gray-600">{t('generate.model')}</span>{' '}
{modelDisplayName()}
</p>
</Show>
<p class="mt-2 text-xs text-gray-400"> <p class="mt-2 text-xs text-gray-400">
{t('generate.note')} {t('generate.note')}
</p> </p>
</div> </div>
{/* No web search warning */}
<Show when={settings().ai_provider && !hasWebSearch()}>
<div class="mt-4 bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<AlertTriangle class="h-5 w-5 text-yellow-600" aria-hidden="true" />
</div>
<div class="ml-3">
<p class="text-sm text-yellow-800">
{t('generate.noWebSearchWarning')}
</p>
</div>
</div>
</div>
</Show>
{/* Error display */} {/* Error display */}
<Show when={error()}> <Show when={error()}>
<div class="mt-4 bg-red-50 border-l-4 border-red-400 p-4"> <div class="mt-4 bg-red-50 border-l-4 border-red-400 p-4">

@ -7,7 +7,7 @@ import {
For, For,
createEffect, createEffect,
} from 'solid-js'; } 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 { settingsApi } from '~/api/settings';
import { configApi } from '~/api/config'; import { configApi } from '~/api/config';
import { useI18n } from '~/i18n'; import { useI18n } from '~/i18n';
@ -15,6 +15,7 @@ import { DEFAULT_SETTINGS, isApiError } from '~/types';
import type { UserSettings, ProviderConfig } from '~/types'; import type { UserSettings, ProviderConfig } from '~/types';
import LoadingSpinner from '~/components/ui/LoadingSpinner'; import LoadingSpinner from '~/components/ui/LoadingSpinner';
import ApiKeyManager from '~/components/ApiKeyManager'; import ApiKeyManager from '~/components/ApiKeyManager';
import { getProviderInfoKey, providerSupportsWebSearch } from '~/utils/providers';
const Settings: Component = () => { const Settings: Component = () => {
const { t } = useI18n(); const { t } = useI18n();
@ -380,6 +381,32 @@ const Settings: Component = () => {
{t('settings.modelHelp')} {t('settings.modelHelp')}
</p> </p>
</div> </div>
{/* Provider info text + web search badge */}
<Show when={selectedProvider()}>
{(provider) => (
<div class="mt-3 flex items-start gap-2">
<Info class="h-4 w-4 text-gray-400 mt-0.5 flex-shrink-0" />
<div class="space-y-1">
<p class="text-sm text-gray-500">
{t(getProviderInfoKey(provider().provider_name))}
</p>
<Show
when={providerSupportsWebSearch(provider().provider_name)}
fallback={
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
{t('settings.provider.noWebSearchBadge')}
</span>
}
>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
{t('settings.provider.webSearchBadge')}
</span>
</Show>
</div>
</div>
)}
</Show>
</Show> </Show>
{/* Categories */} {/* Categories */}

@ -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';
}
Loading…
Cancel
Save