use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::Response;
use std::sync::Arc;
pub trait BaseHook: Send + Sync {
fn name(&self) -> &str;
fn when(&self) -> &str;
fn enabled(&self, config: &crate::config::Config) -> bool;
fn on_request(&self, _body: &serde_json::Value, _path: &str) -> Option<serde_json::Value> {
None
}
fn on_response(
&self,
_status: u16,
_path: &str,
_latency: std::time::Duration,
_body: &serde_json::Value,
) -> Option<crate::events::AgentEvent> {
None
}
}
pub struct BaseHookRegistry {
hooks: Vec<Arc<dyn BaseHook>>,
}
impl Default for BaseHookRegistry {
fn default() -> Self {
Self::new()
}
}
impl BaseHookRegistry {
pub fn new() -> Self {
Self { hooks: Vec::new() }
}
pub fn register(&mut self, hook: Arc<dyn BaseHook>) {
self.hooks.push(hook);
}
pub fn run_pre_request(
&self,
body: &serde_json::Value,
path: &str,
config: &crate::config::Config,
) -> serde_json::Value {
let mut body = body.clone();
for hook in &self.hooks {
if hook.when() == "pre_request" && hook.enabled(config) {
if let Some(modified) = hook.on_request(&body, path) {
body = modified;
}
}
}
body
}
pub fn run_post_response(
&self,
status: u16,
path: &str,
latency: std::time::Duration,
body: &serde_json::Value,
config: &crate::config::Config,
event_log: &Arc<crate::events::EventLog>,
) {
for hook in &self.hooks {
if hook.when() == "post_response" && hook.enabled(config) {
if let Some(event) = hook.on_response(status, path, latency, body) {
event_log.publish(event);
}
}
}
}
pub fn list(&self) -> Vec<&str> {
self.hooks.iter().map(|h| h.name()).collect()
}
}
pub struct RequestLogger;
impl BaseHook for RequestLogger {
fn name(&self) -> &str {
"request-logger"
}
fn when(&self) -> &str {
"pre_request"
}
fn enabled(&self, _config: &crate::config::Config) -> bool {
true }
fn on_request(&self, _body: &serde_json::Value, path: &str) -> Option<serde_json::Value> {
tracing::info!(path = %path, "AI request received");
None
}
}
pub struct ErrorCapture;
impl BaseHook for ErrorCapture {
fn name(&self) -> &str {
"error-capture"
}
fn when(&self) -> &str {
"post_response"
}
fn enabled(&self, _config: &crate::config::Config) -> bool {
true
}
fn on_response(
&self,
status: u16,
path: &str,
latency: std::time::Duration,
_body: &serde_json::Value,
) -> Option<crate::events::AgentEvent> {
if status >= 400 {
let severity = if status >= 500 { "error" } else { "warning" };
Some(crate::events::AgentEvent {
agent_id: "base-hook:error-capture".into(),
event_type: "upstream_error".into(),
severity: severity.into(),
timestamp: 0,
metadata: crate::types::BoundedMeta::from_iter([
("path".into(), path.to_string()),
("status".into(), status.to_string()),
("latency_ms".into(), latency.as_millis().to_string()),
]),
})
} else {
None
}
}
}
pub struct ApiKeyMask;
impl BaseHook for ApiKeyMask {
fn name(&self) -> &str {
"api-key-mask"
}
fn when(&self) -> &str {
"pre_request"
}
fn enabled(&self, _config: &crate::config::Config) -> bool {
true
}
fn on_request(&self, body: &serde_json::Value, _path: &str) -> Option<serde_json::Value> {
let mut body = body.clone();
if let Some(obj) = body.as_object_mut() {
for key in &["api_key", "apikey", "authorization", "x-api-key"] {
if obj.contains_key(*key) {
obj.insert(key.to_string(), serde_json::json!("[REDACTED]"));
}
}
}
Some(body)
}
}
pub async fn base_hooks_middleware(
State(state): State<Arc<crate::AppState>>,
req: Request,
next: Next,
) -> Response {
let path = req.uri().path().to_string();
let is_ai_path =
path.starts_with("/v1/") || path.starts_with("/v1beta/") || path == "/v1/chat/completions";
if !is_ai_path {
return next.run(req).await;
}
let (parts, body) = req.into_parts();
let body_bytes = axum::body::to_bytes(body, 10_000_000)
.await
.unwrap_or_default();
let (modified, parsed) = {
let config = state.config.read().unwrap();
let parsed: serde_json::Value =
serde_json::from_slice(&body_bytes).unwrap_or(serde_json::Value::Null);
let modified = state.base_hooks.run_pre_request(&parsed, &path, &config);
(modified, parsed)
};
let body_bytes = if modified != parsed {
serde_json::to_vec(&modified).unwrap_or(body_bytes.to_vec())
} else {
body_bytes.to_vec()
};
let req = axum::http::Request::from_parts(parts, axum::body::Body::from(body_bytes));
let start = std::time::Instant::now();
let resp = next.run(req).await;
let latency = start.elapsed();
let status = resp.status().as_u16();
{
let config = state.config.read().unwrap();
state.base_hooks.run_post_response(
status,
&path,
latency,
&parsed,
&config,
&state.event_log,
);
}
resp
}
pub fn default_registry() -> BaseHookRegistry {
let mut reg = BaseHookRegistry::new();
reg.register(Arc::new(RequestLogger));
reg.register(Arc::new(ErrorCapture));
reg.register(Arc::new(ApiKeyMask));
reg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_lists_hooks() {
let reg = default_registry();
assert_eq!(
reg.list(),
vec!["request-logger", "error-capture", "api-key-mask"]
);
}
#[test]
fn request_logger_always_enabled() {
let hook = RequestLogger;
let config = crate::config::Config::default();
assert!(hook.enabled(&config));
}
#[test]
fn error_capture_captures_500() {
let hook = ErrorCapture;
let event = hook.on_response(
500,
"/v1/chat/completions",
std::time::Duration::from_millis(100),
&serde_json::json!({}),
);
assert!(event.is_some());
let event = event.unwrap();
assert_eq!(event.event_type, "upstream_error");
assert_eq!(event.severity, "error");
}
#[test]
fn error_capture_ignores_200() {
let hook = ErrorCapture;
let event = hook.on_response(
200,
"/v1/chat/completions",
std::time::Duration::from_millis(100),
&serde_json::json!({}),
);
assert!(event.is_none());
}
#[test]
fn api_key_mask_redacts_keys() {
let hook = ApiKeyMask;
let body = serde_json::json!({
"model": "gpt-4",
"api_key": "sk-secret123",
"messages": []
});
let masked = hook.on_request(&body, "/test").unwrap();
assert_eq!(masked["api_key"], "[REDACTED]");
assert_eq!(masked["model"], "gpt-4"); }
#[test]
fn pre_request_runs_all_hooks() {
let reg = default_registry();
let config = crate::config::Config::default();
let body = serde_json::json!({
"model": "gpt-4",
"api_key": "sk-secret",
"messages": []
});
let result = reg.run_pre_request(&body, "/test", &config);
assert_eq!(result["api_key"], "[REDACTED]");
}
}