use crate::config::ProtocolKind; use crate::protocols; use anyhow::{Context, Result}; use chrono::Utc; use reqwest::Client; use reqwest::header::HeaderMap; use serde::Serialize; use serde_json::Value; use std::collections::BTreeMap; use std::fmt; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::Instant; #[derive(Clone)] pub struct ModelRequest { pub base_url: String, pub api_token: String, pub model: String, pub prompt: String, pub temperature: f32, pub max_tokens: u32, pub stream: bool, pub raw_debug: Option, pub thinking: Option, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ThinkingConfig { pub enabled: bool, pub kind: Option, pub budget_tokens: Option, pub effort: Option, pub display: Option, pub reasoning_effort: Option, pub reasoning_summary: Option, } #[derive(Clone)] pub struct RawDebugConfig { output_dir: PathBuf, prefix: String, counter: Arc, write_success_raw: bool, } impl RawDebugConfig { pub fn new(output_dir: PathBuf, prefix: String) -> Self { Self { output_dir, prefix: sanitize_filename_component(&prefix), counter: Arc::new(AtomicU64::new(0)), write_success_raw: true, } } pub fn with_success_raw(mut self, write_success_raw: bool) -> Self { self.write_success_raw = write_success_raw; self } pub fn write_success_raw(&self) -> bool { self.write_success_raw } pub async fn write_response(&self, response_kind: &str, contents: &str) -> Result { self.write_debug_file(response_kind, contents).await } pub async fn write_http_error( &self, response_kind: &str, request: HttpDebugRequest, response: HttpDebugResponse, ) -> Result { let envelope = HttpDebugEnvelope { request, response }; let contents = serde_json::to_string_pretty(&envelope) .context("failed to serialize raw debug HTTP error")?; self.write_debug_file(response_kind, &contents).await } pub async fn write_request_error( &self, response_kind: &str, request: HttpDebugRequest, error: String, ) -> Result { let envelope = HttpDebugEnvelope { request, response: HttpDebugResponse { status: None, headers: BTreeMap::new(), body: String::new(), error_kind: Some("request_error".to_string()), error: Some(error), }, }; let contents = serde_json::to_string_pretty(&envelope) .context("failed to serialize raw debug request error")?; self.write_debug_file(response_kind, &contents).await } async fn write_debug_file(&self, response_kind: &str, contents: &str) -> Result { tokio::fs::create_dir_all(&self.output_dir) .await .with_context(|| { format!( "failed to create raw debug dir {}", self.output_dir.display() ) })?; let sequence = self.counter.fetch_add(1, Ordering::Relaxed) + 1; let timestamp = Utc::now().format("%Y%m%dT%H%M%S%.3fZ"); let filename = format!( "{}-{}-{sequence:06}-{}.txt", self.prefix, timestamp, sanitize_filename_component(response_kind) ); let path = self.output_dir.join(filename); tokio::fs::write(&path, contents) .await .with_context(|| format!("failed to write raw debug response {}", path.display()))?; Ok(path) } } #[derive(Debug, Serialize)] pub struct HttpDebugEnvelope { pub request: HttpDebugRequest, pub response: HttpDebugResponse, } #[derive(Debug, Serialize)] pub struct HttpDebugRequest { pub method: String, pub url: String, pub headers: BTreeMap, pub body: Value, } #[derive(Debug, Serialize)] pub struct HttpDebugResponse { pub status: Option, pub headers: BTreeMap, pub body: String, #[serde(skip_serializing_if = "Option::is_none")] pub error_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, } pub fn request_headers_for_debug(headers: &[(&str, String)]) -> BTreeMap { headers .iter() .map(|(name, value)| { let name = name.to_ascii_lowercase(); let value = if is_sensitive_header(&name) { redact_header_value(&name, value) } else { value.clone() }; (name, value) }) .collect() } pub fn response_headers_for_debug(headers: &HeaderMap) -> BTreeMap { headers .iter() .map(|(name, value)| { ( name.as_str().to_ascii_lowercase(), value.to_str().unwrap_or("").to_string(), ) }) .collect() } fn is_sensitive_header(name: &str) -> bool { matches!( name, "authorization" | "x-api-key" | "x-goog-api-key" | "api-key" ) } fn redact_header_value(name: &str, value: &str) -> String { if name == "authorization" && let Some((scheme, _)) = value.split_once(' ') { format!("{scheme} [REDACTED]") } else { "[REDACTED]".to_string() } } impl fmt::Debug for ModelRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("ModelRequest") .field("base_url", &self.base_url) .field("api_token", &"[REDACTED]") .field("model", &self.model) .field("prompt", &self.prompt) .field("temperature", &self.temperature) .field("max_tokens", &self.max_tokens) .field("stream", &self.stream) .field("raw_debug", &self.raw_debug.is_some()) .field("thinking", &self.thinking) .finish() } } fn sanitize_filename_component(value: &str) -> String { let sanitized = value .chars() .map(|character| { if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { character } else { '-' } }) .collect::(); let collapsed = sanitized .split('-') .filter(|part| !part.is_empty()) .collect::>() .join("-"); if collapsed.is_empty() { "unknown".to_string() } else { collapsed } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelResponse { pub text: String, pub status: u16, pub elapsed_ms: u128, pub first_token_ms: Option, } pub async fn run_model_request( protocol: ProtocolKind, request: ModelRequest, ) -> Result { let client = Client::new(); run_model_request_with_client(&client, protocol, &request).await } pub async fn run_model_request_with_client( client: &Client, protocol: ProtocolKind, request: &ModelRequest, ) -> Result { let started = Instant::now(); let mut response = if request.stream { match protocol { ProtocolKind::Openai => protocols::openai::send_stream(client, request).await?, ProtocolKind::Anthropic => protocols::anthropic::send_stream(client, request).await?, ProtocolKind::Google => protocols::google::send_stream(client, request).await?, } } else { match protocol { ProtocolKind::Openai => protocols::openai::send(client, request).await?, ProtocolKind::Anthropic => protocols::anthropic::send(client, request).await?, ProtocolKind::Google => protocols::google::send(client, request).await?, } }; response.elapsed_ms = started.elapsed().as_millis(); Ok(response) } #[cfg(test)] mod tests { use super::*; #[test] fn model_request_debug_redacts_api_token() { let request = ModelRequest { base_url: "https://relay.example.com/v1".to_string(), api_token: "sk-secret-token".to_string(), model: "test-model".to_string(), prompt: "hello".to_string(), temperature: 0.0, max_tokens: 1024, stream: false, raw_debug: None, thinking: None, }; let debug = format!("{request:?}"); assert!(debug.contains("api_token")); assert!(debug.contains("[REDACTED]")); assert!(!debug.contains("sk-secret-token")); } }