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
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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…
Reference in New Issue