add claude module

This commit is contained in:
Chris Beck
2025-12-07 15:59:46 -07:00
parent 0b244b8e38
commit 6633768df6
9 changed files with 584 additions and 4 deletions
Generated
+1
View File
@@ -1681,6 +1681,7 @@ dependencies = [
"prometheus-http-client",
"rand",
"regex",
"reqwest",
"serde",
"serde_json",
"thiserror",
+1
View File
@@ -26,6 +26,7 @@ http-body-util = { workspace = true }
humantime = { workspace = true }
jsonrpsee = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+279
View File
@@ -0,0 +1,279 @@
//! Claude API integration for AI-powered responses with tool use support.
mod tools;
pub use tools::{Tool, ToolExecutor};
use conf::Conf;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::PathBuf;
use tracing::info;
/// The Anthropic API version header value. This is a stable version identifier,
/// not a date indicating when the API was released. Anthropic adds new features
/// through separate `anthropic-beta` headers rather than bumping this version.
/// The official Anthropic SDKs use this same value.
const ANTHROPIC_API_VERSION: &str = "2023-06-01";
/// Configuration for the Claude API integration.
#[derive(Clone, Conf, Debug)]
#[conf(serde)]
pub struct ClaudeConfig {
/// Path to file containing the Claude API key.
#[conf(long, env)]
pub api_key_file: PathBuf,
/// Path to file containing the system prompt.
#[conf(long, env)]
pub system_prompt_file: PathBuf,
/// Claude API URL.
#[conf(long, env, default_value = "https://api.anthropic.com/v1/messages")]
pub claude_api_url: String,
/// Claude model to use.
#[conf(long, env, default_value = "claude-opus-4-20250514")]
pub claude_model: String,
/// Maximum tokens in the response.
#[conf(long, env, default_value = "1024")]
pub claude_max_tokens: u32,
/// Maximum tool use iterations before giving up.
#[conf(long, env, default_value = "10")]
pub claude_max_iterations: u32,
}
/// Error type for Claude API operations.
#[derive(Debug, thiserror::Error)]
pub enum ClaudeError {
/// Failed to read API key file.
#[error("failed to read API key file: {0}")]
ApiKeyRead(std::io::Error),
/// Failed to read system prompt file.
#[error("failed to read system prompt file: {0}")]
SystemPromptRead(std::io::Error),
/// HTTP request failed.
#[error("HTTP request failed: {0}")]
Request(#[from] reqwest::Error),
/// API returned an error response.
#[error("API error: {0}")]
ApiError(String),
/// Tool execution failed.
#[error("tool execution failed: {0}")]
ToolError(String),
/// Too many tool use iterations.
#[error("exceeded maximum tool use iterations ({0})")]
TooManyIterations(u32),
}
/// Claude API client.
pub struct ClaudeApi {
config: ClaudeConfig,
client: reqwest::Client,
api_key: String,
system_prompt: String,
}
/// Request body for the Claude Messages API.
#[derive(Serialize)]
struct MessagesRequest<'a> {
model: &'a str,
max_tokens: u32,
system: &'a str,
messages: Vec<MessageContent>,
#[serde(skip_serializing_if = "Vec::is_empty")]
tools: Vec<Tool>,
}
/// A message in the conversation (can have multiple content blocks).
#[derive(Clone, Debug, Serialize, Deserialize)]
struct MessageContent {
role: String,
content: Vec<ContentBlock>,
}
impl MessageContent {
fn user(text: &str) -> Self {
Self {
role: "user".to_owned(),
content: vec![ContentBlock::Text {
text: text.to_owned(),
}],
}
}
fn assistant(blocks: Vec<ContentBlock>) -> Self {
Self {
role: "assistant".to_owned(),
content: blocks,
}
}
fn tool_result(tool_use_id: String, content: String, is_error: bool) -> Self {
Self {
role: "user".to_owned(),
content: vec![ContentBlock::ToolResult {
tool_use_id,
content,
is_error: if is_error { Some(true) } else { None },
}],
}
}
}
/// A content block in the request/response.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ContentBlock {
Text {
text: String,
},
ToolUse {
id: String,
name: String,
input: Value,
},
ToolResult {
tool_use_id: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
},
}
/// Response from the Claude Messages API.
#[derive(Debug, Deserialize)]
struct MessagesResponse {
content: Vec<ContentBlock>,
stop_reason: String,
}
/// Error response from the Claude API.
#[derive(Deserialize)]
struct ErrorResponse {
error: ApiErrorDetail,
}
#[derive(Deserialize)]
struct ApiErrorDetail {
message: String,
}
impl ClaudeApi {
/// Create a new Claude API client from configuration.
///
/// Reads the API key and system prompt from the configured files.
pub fn new(config: ClaudeConfig) -> Result<Self, ClaudeError> {
let api_key = std::fs::read_to_string(&config.api_key_file)
.map_err(ClaudeError::ApiKeyRead)?
.trim()
.to_owned();
let system_prompt = std::fs::read_to_string(&config.system_prompt_file)
.map_err(ClaudeError::SystemPromptRead)?;
let client = reqwest::Client::new();
Ok(Self {
config,
client,
api_key,
system_prompt,
})
}
/// Send a request to the Claude API and return the response text.
/// If a tool executor is provided, handles tool use in a loop.
pub async fn request(
&self,
prompt: &str,
tool_executor: Option<&dyn ToolExecutor>,
) -> Result<String, ClaudeError> {
let max_iterations = self.config.claude_max_iterations;
let tools = tool_executor.map(|te| te.tools()).unwrap_or_default();
let mut messages = vec![MessageContent::user(prompt)];
info!("Claude request: {}", prompt);
for iteration in 0..max_iterations {
let request_body = MessagesRequest {
model: &self.config.claude_model,
max_tokens: self.config.claude_max_tokens,
system: &self.system_prompt,
messages: messages.clone(),
tools: tools.clone(),
};
let response = self
.client
.post(&self.config.claude_api_url)
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_API_VERSION)
.header("content-type", "application/json")
.json(&request_body)
.send()
.await?;
if !response.status().is_success() {
let error: ErrorResponse = response.json().await?;
return Err(ClaudeError::ApiError(error.error.message));
}
let response: MessagesResponse = response.json().await?;
info!(
"Claude response (stop_reason={}): {:?}",
response.stop_reason, response.content
);
// Check if we need to handle tool use
if response.stop_reason == "tool_use" {
let Some(executor) = tool_executor else {
return Err(ClaudeError::ToolError(
"tool use requested but no executor provided".to_owned(),
));
};
// Add assistant's response to messages
messages.push(MessageContent::assistant(response.content.clone()));
// Execute each tool use and collect results
for block in &response.content {
if let ContentBlock::ToolUse { id, name, input } = block {
info!("Claude tool use: {}({})", name, input);
let (result, is_error) = match executor.execute(name, input).await {
Ok(result) => {
info!("Tool result: {}", result);
(result, false)
}
Err(err) => {
info!("Tool error: {}", err);
(err, true)
}
};
messages.push(MessageContent::tool_result(id.clone(), result, is_error));
}
}
// Continue the loop to get Claude's next response
info!("Tool use iteration {}, continuing...", iteration + 1);
continue;
}
// No tool use, extract final text response
let text = response
.content
.into_iter()
.filter_map(|block| {
if let ContentBlock::Text { text } = block {
Some(text)
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n");
info!("Claude final result: {}", text);
return Ok(text);
}
Err(ClaudeError::TooManyIterations(max_iterations))
}
}
+32
View File
@@ -0,0 +1,32 @@
//! Tool definitions and executor trait for the Claude API.
use async_trait::async_trait;
use serde::Serialize;
use serde_json::Value;
/// Trait for executing tools. Implement this to provide tool capabilities.
#[async_trait]
pub trait ToolExecutor: Send + Sync {
/// Get the list of available tools as JSON tool definitions.
fn tools(&self) -> Vec<Tool>;
/// Check if this executor handles a tool with the given name.
fn has_tool(&self, name: &str) -> bool {
self.tools().iter().any(|t| t.name == name)
}
/// Execute a tool by name with the given input arguments.
/// Returns the result as a string to be sent back to Claude.
async fn execute(&self, name: &str, input: &Value) -> Result<String, String>;
}
/// A tool definition for the Claude API.
#[derive(Clone, Debug, Serialize)]
pub struct Tool {
/// The name of the tool.
pub name: &'static str,
/// A description of what the tool does.
pub description: &'static str,
/// JSON schema for the tool's input parameters.
pub input_schema: Value,
}
+36
View File
@@ -4,10 +4,12 @@ use super::{
route::{Destination, Limit, Route},
};
use crate::{
claude::{Tool, ToolExecutor},
concurrent_map::LazyMap,
log_format::LogFormatConfig,
log_message::{LogFilter, LogMessage, Origin},
};
use async_trait::async_trait;
use chrono::Utc;
use conf::Conf;
use std::fmt;
@@ -282,3 +284,37 @@ impl LogHandler {
Ok(first_destination)
}
}
fn logs_tool() -> Tool {
Tool {
name: "logs",
description: "Get recent log messages from monitored applications. Returns buffered log entries, optionally filtered by application name or hostname.",
input_schema: serde_json::json!({
"type": "object",
"properties": {
"filter": {
"type": "string",
"description": "Optional filter string. If it contains '@', format is 'app@host' where both parts are substring matches. Otherwise, matches either app or host containing the string."
}
},
"required": []
}),
}
}
#[async_trait]
impl ToolExecutor for LogHandler {
fn tools(&self) -> Vec<Tool> {
vec![logs_tool()]
}
async fn execute(&self, name: &str, input: &serde_json::Value) -> Result<String, String> {
match name {
"logs" => {
let filter = input.get("filter").and_then(|v| v.as_str());
Ok(self.format_logs(filter).await)
}
_ => Err(format!("unknown tool: {name}")),
}
}
}
+58
View File
@@ -4,6 +4,7 @@
use crate::signal_jsonrpc::connect_ipc;
use crate::{
alertmanager::AlertPost,
claude::{ClaudeApi, ClaudeConfig, Tool, ToolExecutor},
log_message::{LogMessage, Origin},
message_handler::{
AdminMessage, AdminMessageResponse, Context, MessageHandler, MessageHandlerResult,
@@ -13,6 +14,7 @@ use crate::{
Envelope, MessageTarget, RpcClient, RpcClientError, SignalMessage, connect_tcp,
},
};
use async_trait::async_trait;
use chrono::Utc;
use conf::{Conf, Subcommands};
use futures_util::FutureExt;
@@ -75,6 +77,9 @@ pub struct GatewayConfig {
/// Log handler configuration for processing log messages.
#[conf(flatten, prefix)]
pub log_handler: LogHandlerConfig,
/// Claude API configuration for AI-powered responses.
#[conf(flatten, prefix)]
pub claude: Option<ClaudeConfig>,
}
/// Wrapper for parsing gateway commands
@@ -129,6 +134,13 @@ enum GatewayCommand {
/// Show current alerts from prometheus
#[conf(name = "alerts", alias = "ALERTS")]
Alerts,
/// Ask Claude AI a question
#[conf(name = "c", alias = "C")]
Claude {
/// The prompt to send to Claude
#[conf(repeat, pos)]
prompt: Vec<String>,
},
}
/// Parse a gateway command from a string (with or without leading /)
@@ -217,6 +229,8 @@ pub struct Gateway {
log_handler: LogHandler,
/// Handler for admin messages that don't start with `/`
message_handler: Option<Box<dyn MessageHandler>>,
/// Claude API client for AI-powered responses.
claude: Option<ClaudeApi>,
}
impl Gateway {
@@ -237,6 +251,11 @@ impl Gateway {
let log_handler = LogHandler::new(config.log_handler.clone(), signal_alert_mq_tx.clone());
let claude = config
.claude
.clone()
.map(|cc| ClaudeApi::new(cc).expect("Invalid claude config"));
Self {
config,
signal_alert_mq_tx,
@@ -245,6 +264,7 @@ impl Gateway {
prometheus,
log_handler,
message_handler,
claude,
}
}
@@ -655,6 +675,19 @@ impl Gateway {
Err(err) => Err((500, err)),
}
}
GatewayCommand::Claude { prompt } => {
let claude = self
.claude
.as_ref()
.ok_or_else(|| (501u16, "claude was not configured".into()))?;
let prompt_text = prompt.join(" ");
// Pass self as the tool executor so Claude can query prometheus
match claude.request(&prompt_text, Some(self)).await {
Ok(response) => Ok(AdminMessageResponse::new(response)),
Err(err) => Err((500, err.to_string().into())),
}
}
}
}
@@ -773,6 +806,31 @@ impl Gateway {
}
}
#[async_trait]
impl ToolExecutor for Gateway {
fn tools(&self) -> Vec<Tool> {
let mut tools = self.log_handler.tools();
if let Some(prometheus) = &self.prometheus {
tools.extend(prometheus.tools());
}
tools
}
async fn execute(&self, name: &str, input: &serde_json::Value) -> Result<String, String> {
if self.log_handler.has_tool(name) {
return self.log_handler.execute(name, input).await;
}
if let Some(prometheus) = &self.prometheus
&& prometheus.has_tool(name)
{
return prometheus.execute(name, input).await;
}
Err(format!("unknown tool: {name}"))
}
}
/// Placeholder context for message handlers.
struct GatewayContext;
+1
View File
@@ -6,6 +6,7 @@
#![deny(missing_docs)]
pub mod alertmanager;
pub mod claude;
pub mod gateway;
pub mod message_handler;
+4 -4
View File
@@ -358,10 +358,10 @@ impl LogFilter {
return false;
}
if let Some(regex) = &self.msg_regex {
if !regex.is_match(&log_msg.msg) {
return false;
}
if let Some(regex) = &self.msg_regex
&& !regex.is_match(&log_msg.msg)
{
return false;
}
if !self.module_equals.is_empty() {
+172
View File
@@ -1,9 +1,12 @@
use crate::claude::{Tool, ToolExecutor};
use async_trait::async_trait;
use conf::Conf;
use prometheus_http_client::{
AlertInfo, AlertsRequest, ExtractLabels, Labels, LabelsRequest, MetricVal, MetricValue,
PromRequest, QueryRequest, ReqwestClient, SeriesRequest,
};
use std::error::Error;
use std::fmt::Write;
use tracing::info;
type BoxError = Box<dyn Error + Send + Sync>;
@@ -228,3 +231,172 @@ fn parse_alert_expr(expr: &str) -> Result<(String, PlotThreshold), BoxError> {
Err("no comparator (< or >) found in expression".into())
}
// Tool definitions for Claude API integration
fn query_tool() -> Tool {
Tool {
name: "prometheus_query",
description: "Query Prometheus for current metric values using PromQL. Returns the current value of metrics matching the query expression.",
input_schema: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "A PromQL query expression (e.g., 'up', 'rate(http_requests_total[5m])', 'node_memory_MemFree_bytes / node_memory_MemTotal_bytes')"
}
},
"required": ["query"]
}),
}
}
fn series_tool() -> Tool {
Tool {
name: "prometheus_series",
description: "List all time series (metrics) matching the given label matchers. Returns metric names with all their label key-value pairs.",
input_schema: serde_json::json!({
"type": "object",
"properties": {
"matchers": {
"type": "array",
"items": { "type": "string" },
"description": "Label matchers to filter series (e.g., ['__name__=~\"http_.*\"', 'job=\"api\"']). Use __name__ to match metric names."
}
},
"required": ["matchers"]
}),
}
}
fn labels_tool() -> Tool {
Tool {
name: "prometheus_labels",
description: "List all label names that exist in Prometheus, optionally filtered by series matchers.",
input_schema: serde_json::json!({
"type": "object",
"properties": {
"matchers": {
"type": "array",
"items": { "type": "string" },
"description": "Optional label matchers to filter which series to consider (e.g., ['job=\"api\"']). If empty, returns all label names."
}
},
"required": []
}),
}
}
fn alerts_tool() -> Tool {
Tool {
name: "prometheus_alerts",
description: "List all current alerts from Prometheus, including their state (pending/firing), labels, and annotations.",
input_schema: serde_json::json!({
"type": "object",
"properties": {},
"required": []
}),
}
}
#[async_trait]
impl ToolExecutor for Prometheus {
fn tools(&self) -> Vec<Tool> {
vec![query_tool(), series_tool(), labels_tool(), alerts_tool()]
}
async fn execute(&self, name: &str, input: &serde_json::Value) -> Result<String, String> {
match name {
"prometheus_query" => {
let query = input
.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| "missing 'query' parameter".to_owned())?;
match self.oneoff_query(query.to_owned()).await {
Ok((labels, values)) => {
let mut result = format!(
"Query: {}\nMetric: {} (common labels: {:?})\n",
query, labels.name, labels.common_labels
);
for (sl, val) in labels.specific_labels.iter().zip(values.iter()) {
let value_str = val
.as_ref()
.map(|(_, v)| v.to_string())
.unwrap_or_else(|| "-".to_owned());
writeln!(&mut result, " {:?} = {}", sl, value_str).unwrap();
}
Ok(result)
}
Err(err) => Err(format!("prometheus query failed: {err}")),
}
}
"prometheus_series" => {
let matchers: Vec<String> = input
.get("matchers")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect()
})
.unwrap_or_default();
match self.series(&matchers).await {
Ok(series) => {
let mut result = format!("Found {} series:\n", series.len());
for labels in series.iter().take(100) {
writeln!(&mut result, " {:?}", labels).unwrap();
}
if series.len() > 100 {
result.push_str(&format!(" ... and {} more\n", series.len() - 100));
}
Ok(result)
}
Err(err) => Err(format!("prometheus series failed: {err}")),
}
}
"prometheus_labels" => {
let matchers: Vec<String> = input
.get("matchers")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_owned()))
.collect()
})
.unwrap_or_default();
match self.labels(&matchers).await {
Ok(labels) => {
let mut result = format!("Found {} labels:\n", labels.len());
for label in &labels {
writeln!(&mut result, " {}", label).unwrap();
}
Ok(result)
}
Err(err) => Err(format!("prometheus labels failed: {err}")),
}
}
"prometheus_alerts" => match self.alerts().await {
Ok(alerts) => {
if alerts.is_empty() {
return Ok("No active alerts".to_owned());
}
let mut result = format!("Found {} alerts:\n", alerts.len());
for alert in &alerts {
writeln!(
&mut result,
" [{:?}] {:?} - {:?}",
alert.state, alert.labels, alert.annotations
)
.unwrap();
}
Ok(result)
}
Err(err) => Err(format!("prometheus alerts failed: {err}")),
},
_ => Err(format!("unknown tool: {name}")),
}
}
}