refactor: comprehensive audit — fix bugs, harden security, deduplicate providers, add CI/Docker
Some checks failed
CI / Check (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Formatting (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Release Build (push) Has been cancelled

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:
2026-03-02 00:35:45 -05:00
parent ba643dd2b0
commit 2cdc49d7f2
42 changed files with 2800 additions and 2747 deletions

View File

@@ -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))
}
}
}