refactor: comprehensive audit — fix bugs, harden security, deduplicate providers, add CI/Docker
Phase 1: Fix compilation (config_path Option<PathBuf>, streaming test, stale test cleanup) Phase 2: Fix critical bugs (remove block_on deadlocks in 4 providers, fix broken SQL query builder) Phase 3: Security hardening (session manager, real auth, token masking, Gemini key to header, password policy) Phase 4: Implement stubs (real provider test, /proc health metrics, client/provider/backup endpoints, has_images) Phase 5: Code quality (shared provider helpers, explicit re-exports, all Clippy warnings fixed, unwrap removal, 6 unused deps removed, dashboard split into 7 sub-modules) Phase 6: Infrastructure (GitHub Actions CI, multi-stage Dockerfile, rustfmt.toml, clippy.toml, script fixes)
This commit is contained in:
@@ -1,14 +1,10 @@
|
||||
use async_trait::async_trait;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use async_trait::async_trait;
|
||||
use futures::stream::BoxStream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
models::UnifiedRequest,
|
||||
errors::AppError,
|
||||
config::AppConfig,
|
||||
};
|
||||
use super::{ProviderResponse, ProviderStreamChunk};
|
||||
use crate::{config::AppConfig, errors::AppError, models::UnifiedRequest};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GeminiRequest {
|
||||
@@ -61,8 +57,6 @@ struct GeminiResponse {
|
||||
usage_metadata: Option<GeminiUsageMetadata>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub struct GeminiProvider {
|
||||
client: reqwest::Client,
|
||||
config: crate::config::GeminiConfig,
|
||||
@@ -80,7 +74,7 @@ impl GeminiProvider {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
config: config.clone(),
|
||||
@@ -101,19 +95,16 @@ impl super::Provider for GeminiProvider {
|
||||
}
|
||||
|
||||
fn supports_multimodal(&self) -> bool {
|
||||
true // Gemini supports vision
|
||||
true // Gemini supports vision
|
||||
}
|
||||
|
||||
async fn chat_completion(
|
||||
&self,
|
||||
request: UnifiedRequest,
|
||||
) -> Result<ProviderResponse, AppError> {
|
||||
async fn chat_completion(&self, request: UnifiedRequest) -> Result<ProviderResponse, AppError> {
|
||||
// Convert UnifiedRequest to Gemini request
|
||||
let mut contents = Vec::with_capacity(request.messages.len());
|
||||
|
||||
|
||||
for msg in request.messages {
|
||||
let mut parts = Vec::with_capacity(msg.content.len());
|
||||
|
||||
|
||||
for part in msg.content {
|
||||
match part {
|
||||
crate::models::ContentPart::Text { text } => {
|
||||
@@ -123,9 +114,11 @@ impl super::Provider for GeminiProvider {
|
||||
});
|
||||
}
|
||||
crate::models::ContentPart::Image(image_input) => {
|
||||
let (base64_data, mime_type) = image_input.to_base64().await
|
||||
let (base64_data, mime_type) = image_input
|
||||
.to_base64()
|
||||
.await
|
||||
.map_err(|e| AppError::ProviderError(format!("Failed to convert image: {}", e)))?;
|
||||
|
||||
|
||||
parts.push(GeminiPart {
|
||||
text: None,
|
||||
inline_data: Some(GeminiInlineData {
|
||||
@@ -136,23 +129,20 @@ impl super::Provider for GeminiProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Map role: "user" -> "user", "assistant" -> "model", "system" -> "user"
|
||||
let role = match msg.role.as_str() {
|
||||
"assistant" => "model".to_string(),
|
||||
_ => "user".to_string(),
|
||||
};
|
||||
|
||||
contents.push(GeminiContent {
|
||||
parts,
|
||||
role,
|
||||
});
|
||||
|
||||
contents.push(GeminiContent { parts, role });
|
||||
}
|
||||
|
||||
|
||||
if contents.is_empty() {
|
||||
return Err(AppError::ProviderError("No valid text messages to send".to_string()));
|
||||
}
|
||||
|
||||
|
||||
// Build generation config
|
||||
let generation_config = if request.temperature.is_some() || request.max_tokens.is_some() {
|
||||
Some(GeminiGenerationConfig {
|
||||
@@ -162,51 +152,65 @@ impl super::Provider for GeminiProvider {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
let gemini_request = GeminiRequest {
|
||||
contents,
|
||||
generation_config,
|
||||
};
|
||||
|
||||
|
||||
// Build URL
|
||||
let url = format!("{}/models/{}:generateContent?key={}",
|
||||
self.config.base_url,
|
||||
request.model,
|
||||
self.api_key
|
||||
);
|
||||
|
||||
let url = format!("{}/models/{}:generateContent", self.config.base_url, request.model,);
|
||||
|
||||
// Send request
|
||||
let response = self.client
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header("x-goog-api-key", &self.api_key)
|
||||
.json(&gemini_request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| AppError::ProviderError(format!("HTTP request failed: {}", e)))?;
|
||||
|
||||
|
||||
// Check status
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response.text().await.unwrap_or_default();
|
||||
return Err(AppError::ProviderError(format!("Gemini API error ({}): {}", status, error_text)));
|
||||
return Err(AppError::ProviderError(format!(
|
||||
"Gemini API error ({}): {}",
|
||||
status, error_text
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
let gemini_response: GeminiResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| AppError::ProviderError(format!("Failed to parse response: {}", e)))?;
|
||||
|
||||
|
||||
// Extract content from first candidate
|
||||
let content = gemini_response.candidates
|
||||
let content = gemini_response
|
||||
.candidates
|
||||
.first()
|
||||
.and_then(|c| c.content.parts.first())
|
||||
.and_then(|p| p.text.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
|
||||
// Extract token usage
|
||||
let prompt_tokens = gemini_response.usage_metadata.as_ref().map(|u| u.prompt_token_count).unwrap_or(0);
|
||||
let completion_tokens = gemini_response.usage_metadata.as_ref().map(|u| u.candidates_token_count).unwrap_or(0);
|
||||
let total_tokens = gemini_response.usage_metadata.as_ref().map(|u| u.total_token_count).unwrap_or(0);
|
||||
|
||||
let prompt_tokens = gemini_response
|
||||
.usage_metadata
|
||||
.as_ref()
|
||||
.map(|u| u.prompt_token_count)
|
||||
.unwrap_or(0);
|
||||
let completion_tokens = gemini_response
|
||||
.usage_metadata
|
||||
.as_ref()
|
||||
.map(|u| u.candidates_token_count)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = gemini_response
|
||||
.usage_metadata
|
||||
.as_ref()
|
||||
.map(|u| u.total_token_count)
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(ProviderResponse {
|
||||
content,
|
||||
reasoning_content: None, // Gemini doesn't use this field name
|
||||
@@ -221,20 +225,22 @@ impl super::Provider for GeminiProvider {
|
||||
Ok(crate::utils::tokens::estimate_request_tokens(&request.model, request))
|
||||
}
|
||||
|
||||
fn calculate_cost(&self, model: &str, prompt_tokens: u32, completion_tokens: u32, registry: &crate::models::registry::ModelRegistry) -> f64 {
|
||||
if let Some(metadata) = registry.find_model(model) {
|
||||
if let Some(cost) = &metadata.cost {
|
||||
return (prompt_tokens as f64 * cost.input / 1_000_000.0) +
|
||||
(completion_tokens as f64 * cost.output / 1_000_000.0);
|
||||
}
|
||||
}
|
||||
|
||||
let (prompt_rate, completion_rate) = self.pricing.iter()
|
||||
.find(|p| model.contains(&p.model))
|
||||
.map(|p| (p.prompt_tokens_per_million, p.completion_tokens_per_million))
|
||||
.unwrap_or((0.075, 0.30)); // Default to Gemini 2.0 Flash price if not found
|
||||
|
||||
(prompt_tokens as f64 * prompt_rate / 1_000_000.0) + (completion_tokens as f64 * completion_rate / 1_000_000.0)
|
||||
fn calculate_cost(
|
||||
&self,
|
||||
model: &str,
|
||||
prompt_tokens: u32,
|
||||
completion_tokens: u32,
|
||||
registry: &crate::models::registry::ModelRegistry,
|
||||
) -> f64 {
|
||||
super::helpers::calculate_cost_with_registry(
|
||||
model,
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
registry,
|
||||
&self.pricing,
|
||||
0.075,
|
||||
0.30,
|
||||
)
|
||||
}
|
||||
|
||||
async fn chat_completion_stream(
|
||||
@@ -243,10 +249,10 @@ impl super::Provider for GeminiProvider {
|
||||
) -> Result<BoxStream<'static, Result<ProviderStreamChunk, AppError>>, AppError> {
|
||||
// Convert UnifiedRequest to Gemini request
|
||||
let mut contents = Vec::with_capacity(request.messages.len());
|
||||
|
||||
|
||||
for msg in request.messages {
|
||||
let mut parts = Vec::with_capacity(msg.content.len());
|
||||
|
||||
|
||||
for part in msg.content {
|
||||
match part {
|
||||
crate::models::ContentPart::Text { text } => {
|
||||
@@ -256,9 +262,11 @@ impl super::Provider for GeminiProvider {
|
||||
});
|
||||
}
|
||||
crate::models::ContentPart::Image(image_input) => {
|
||||
let (base64_data, mime_type) = image_input.to_base64().await
|
||||
let (base64_data, mime_type) = image_input
|
||||
.to_base64()
|
||||
.await
|
||||
.map_err(|e| AppError::ProviderError(format!("Failed to convert image: {}", e)))?;
|
||||
|
||||
|
||||
parts.push(GeminiPart {
|
||||
text: None,
|
||||
inline_data: Some(GeminiInlineData {
|
||||
@@ -269,19 +277,16 @@ impl super::Provider for GeminiProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Map role
|
||||
let role = match msg.role.as_str() {
|
||||
"assistant" => "model".to_string(),
|
||||
_ => "user".to_string(),
|
||||
};
|
||||
|
||||
contents.push(GeminiContent {
|
||||
parts,
|
||||
role,
|
||||
});
|
||||
|
||||
contents.push(GeminiContent { parts, role });
|
||||
}
|
||||
|
||||
|
||||
// Build generation config
|
||||
let generation_config = if request.temperature.is_some() || request.max_tokens.is_some() {
|
||||
Some(GeminiGenerationConfig {
|
||||
@@ -291,28 +296,32 @@ impl super::Provider for GeminiProvider {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
let gemini_request = GeminiRequest {
|
||||
contents,
|
||||
generation_config,
|
||||
};
|
||||
|
||||
// Build URL for streaming
|
||||
let url = format!("{}/models/{}:streamGenerateContent?alt=sse&key={}",
|
||||
self.config.base_url,
|
||||
request.model,
|
||||
self.api_key
|
||||
);
|
||||
|
||||
// Create eventsource stream
|
||||
use reqwest_eventsource::{EventSource, Event};
|
||||
use futures::StreamExt;
|
||||
|
||||
let es = EventSource::new(self.client.post(&url).json(&gemini_request))
|
||||
.map_err(|e| AppError::ProviderError(format!("Failed to create EventSource: {}", e)))?;
|
||||
|
||||
// Build URL for streaming
|
||||
let url = format!(
|
||||
"{}/models/{}:streamGenerateContent?alt=sse",
|
||||
self.config.base_url, request.model,
|
||||
);
|
||||
|
||||
// Create eventsource stream
|
||||
use futures::StreamExt;
|
||||
use reqwest_eventsource::{Event, EventSource};
|
||||
|
||||
let es = EventSource::new(
|
||||
self.client
|
||||
.post(&url)
|
||||
.header("x-goog-api-key", &self.api_key)
|
||||
.json(&gemini_request),
|
||||
)
|
||||
.map_err(|e| AppError::ProviderError(format!("Failed to create EventSource: {}", e)))?;
|
||||
|
||||
let model = request.model.clone();
|
||||
|
||||
|
||||
let stream = async_stream::try_stream! {
|
||||
let mut es = es;
|
||||
while let Some(event) = es.next().await {
|
||||
@@ -320,12 +329,12 @@ impl super::Provider for GeminiProvider {
|
||||
Ok(Event::Message(msg)) => {
|
||||
let gemini_response: GeminiResponse = serde_json::from_str(&msg.data)
|
||||
.map_err(|e| AppError::ProviderError(format!("Failed to parse stream chunk: {}", e)))?;
|
||||
|
||||
|
||||
if let Some(candidate) = gemini_response.candidates.first() {
|
||||
let content = candidate.content.parts.first()
|
||||
.and_then(|p| p.text.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
|
||||
yield ProviderStreamChunk {
|
||||
content,
|
||||
reasoning_content: None,
|
||||
@@ -341,7 +350,7 @@ impl super::Provider for GeminiProvider {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user