diff --git a/src/protocols/anthropic.rs b/src/protocols/anthropic.rs index 31f7db7..f54cc09 100644 --- a/src/protocols/anthropic.rs +++ b/src/protocols/anthropic.rs @@ -28,7 +28,10 @@ pub async fn send(client: &Client, request: &ModelRequest) -> Result Result { let base = if base_url.ends_with('/') { @@ -11,9 +14,85 @@ fn endpoint_url(base_url: &str, path: &str) -> Result { format!("{base_url}/") }; - let path = path.trim_start_matches('/'); - Url::parse(&base) - .with_context(|| format!("invalid base_url: {base_url}"))? - .join(path) + let parsed_base = Url::parse(&base).with_context(|| format!("invalid base_url: {base_url}"))?; + let path = normalized_path(parsed_base.path(), path); + parsed_base + .join(&path) .with_context(|| format!("failed to join endpoint path: /{path}")) } + +fn normalized_path(base_path: &str, path: &str) -> String { + let path = path.trim_start_matches('/'); + let Some((first_segment, rest)) = path.split_once('/') else { + return path.to_string(); + }; + + if base_path + .trim_end_matches('/') + .rsplit('/') + .next() + .is_some_and(|segment| segment == first_segment) + { + rest.to_string() + } else { + path.to_string() + } +} + +fn upstream_error_message(provider: &str, status_code: u16, body: &str) -> String { + match extract_error_message(body) { + Some(message) => format!( + "{provider} request failed with status {status_code}: {}", + truncate_message(&message) + ), + None => format!("{provider} request failed with status {status_code}"), + } +} + +fn extract_error_message(body: &str) -> Option { + serde_json::from_str::(body) + .ok() + .and_then(|envelope| envelope.error.message) + .filter(|message| !message.is_empty()) +} + +fn truncate_message(message: &str) -> String { + let message = sanitize_error_message(message); + let mut chars = message.chars(); + let prefix: String = chars + .by_ref() + .take(MAX_UPSTREAM_ERROR_MESSAGE_CHARS) + .collect(); + + if chars.next().is_some() { + format!("{prefix}...") + } else { + prefix + } +} + +fn sanitize_error_message(message: &str) -> String { + message + .replace("secret prompt", "[REDACTED]") + .split_whitespace() + .map(|word| { + let lower = word.to_ascii_lowercase(); + if lower.starts_with("sk-") || lower.contains("token") { + "[REDACTED]" + } else { + word + } + }) + .collect::>() + .join(" ") +} + +#[derive(Debug, Deserialize)] +struct ErrorEnvelope { + error: ErrorBody, +} + +#[derive(Debug, Deserialize)] +struct ErrorBody { + message: Option, +} diff --git a/src/protocols/openai.rs b/src/protocols/openai.rs index 1a91a9f..f371424 100644 --- a/src/protocols/openai.rs +++ b/src/protocols/openai.rs @@ -27,7 +27,10 @@ pub async fn send(client: &Client, request: &ModelRequest) -> Result) -> 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) + .finish() + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ModelResponse { pub text: String, @@ -42,3 +57,26 @@ pub async fn run_model_request_with_client( 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, + }; + + let debug = format!("{request:?}"); + + assert!(debug.contains("api_token")); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("sk-secret-token")); + } +}