use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::agent::tools::error::{ErrorCategory, format_error_for_llm};
use crate::platform::PlatformSession;
use crate::platform::api::PlatformApiClient;
use crate::wizard::{
DynamicCloudRegion, DynamicMachineType, HetznerFetchResult, get_hetzner_regions_dynamic,
get_hetzner_server_types_dynamic,
};
#[derive(Debug, Deserialize)]
pub struct ListHetznerAvailabilityArgs {
pub location: Option<String>,
}
#[derive(Debug, thiserror::Error)]
#[error("Hetzner availability error: {0}")]
pub struct ListHetznerAvailabilityError(String);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListHetznerAvailabilityTool;
impl ListHetznerAvailabilityTool {
pub fn new() -> Self {
Self
}
}
impl Default for ListHetznerAvailabilityTool {
fn default() -> Self {
Self::new()
}
}
impl Tool for ListHetznerAvailabilityTool {
const NAME: &'static str = "list_hetzner_availability";
type Error = ListHetznerAvailabilityError;
type Args = ListHetznerAvailabilityArgs;
type Output = String;
async fn definition(&self, _prompt: String) -> ToolDefinition {
ToolDefinition {
name: Self::NAME.to_string(),
description: r#"Fetch real-time Hetzner Cloud region and server type availability.
**IMPORTANT:** Use this tool BEFORE recommending Hetzner regions or server types.
This provides current data directly from Hetzner API - never use hardcoded/static data.
**What it returns:**
- Available regions/locations with:
- Region ID (e.g., "nbg1", "fsn1", "hel1", "ash", "hil", "sin")
- City name and country
- Network zone (eu-central, us-east, us-west, ap-southeast)
- List of server types currently available in that region
- Available server types with:
- Server type ID (e.g., "cx22", "cx32", "cpx21")
- CPU cores and memory (GB)
- Disk size (GB)
- Current pricing (EUR/hour and EUR/month)
- Which regions this type is available in
**When to use:**
- When user asks about Hetzner regions/locations
- When recommending infrastructure for Hetzner deployment
- When user wants to compare Hetzner server types and pricing
- Before deploying to Hetzner to verify availability
**Parameters:**
- location: Optional. Filter server types by specific location (e.g., "nbg1")
**Prerequisites:**
- User must be authenticated
- A project with Hetzner credentials must be selected"#
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "Optional: Filter server types by location (e.g., 'nbg1', 'fsn1')"
}
}
}),
}
}
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
let client = match PlatformApiClient::new() {
Ok(c) => c,
Err(_) => {
return Ok(format_error_for_llm(
"list_hetzner_availability",
ErrorCategory::PermissionDenied,
"Not authenticated",
Some(vec!["Run: sync-ctl auth login"]),
));
}
};
let session = match PlatformSession::load() {
Ok(s) => s,
Err(_) => {
return Ok(format_error_for_llm(
"list_hetzner_availability",
ErrorCategory::InternalError,
"Failed to load platform session",
Some(vec!["Try selecting a project with select_project"]),
));
}
};
if !session.is_project_selected() {
return Ok(format_error_for_llm(
"list_hetzner_availability",
ErrorCategory::ValidationFailed,
"No project selected",
Some(vec!["Use select_project to choose a project first"]),
));
}
let project_id = session.project_id.clone().unwrap_or_default();
let regions: Vec<DynamicCloudRegion> =
match get_hetzner_regions_dynamic(&client, &project_id).await {
HetznerFetchResult::Success(r) => r,
HetznerFetchResult::NoCredentials => {
return Ok(format_error_for_llm(
"list_hetzner_availability",
ErrorCategory::PermissionDenied,
"Hetzner credentials not configured for this project",
Some(vec![
"Add Hetzner API token in project settings",
"Use open_provider_settings to configure Hetzner",
]),
));
}
HetznerFetchResult::ApiError(err) => {
return Ok(format_error_for_llm(
"list_hetzner_availability",
ErrorCategory::NetworkError,
&format!("Failed to fetch Hetzner regions: {}", err),
None,
));
}
};
let server_types: Vec<DynamicMachineType> =
match get_hetzner_server_types_dynamic(&client, &project_id, args.location.as_deref())
.await
{
HetznerFetchResult::Success(s) => s,
HetznerFetchResult::NoCredentials => Vec::new(), HetznerFetchResult::ApiError(_) => Vec::new(), };
let regions_json: Vec<serde_json::Value> = regions
.iter()
.map(|r| {
json!({
"id": r.id,
"name": r.name,
"country": r.location,
"network_zone": r.network_zone,
"available_server_types_count": r.available_server_types.len(),
"available_server_types": r.available_server_types,
})
})
.collect();
let server_types_json: Vec<serde_json::Value> = server_types
.iter()
.map(|s| {
json!({
"id": s.id,
"name": s.name,
"cores": s.cores,
"memory_gb": s.memory_gb,
"disk_gb": s.disk_gb,
"price_hourly_eur": s.price_hourly,
"price_monthly_eur": s.price_monthly,
"available_in": s.available_in,
})
})
.collect();
let shared_cpu: Vec<&serde_json::Value> = server_types_json
.iter()
.filter(|s| s["id"].as_str().is_some_and(|id| id.starts_with("cx")))
.collect();
let dedicated_cpu: Vec<&serde_json::Value> = server_types_json
.iter()
.filter(|s| s["id"].as_str().is_some_and(|id| id.starts_with("ccx")))
.collect();
let performance: Vec<&serde_json::Value> = server_types_json
.iter()
.filter(|s| s["id"].as_str().is_some_and(|id| id.starts_with("cpx")))
.collect();
let response = json!({
"status": "success",
"summary": {
"total_regions": regions.len(),
"total_server_types": server_types.len(),
"filter_applied": args.location,
},
"regions": regions_json,
"server_types": {
"shared_cpu_cx": shared_cpu,
"dedicated_cpu_ccx": dedicated_cpu,
"performance_cpx": performance,
"all": server_types_json,
},
"recommendations": {
"cheapest": server_types.iter()
.min_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap())
.map(|s| json!({
"id": s.id,
"price_monthly_eur": s.price_monthly,
"specs": format!("{} vCPU, {:.0} GB RAM", s.cores, s.memory_gb),
})),
"best_value_4gb": server_types.iter()
.filter(|s| s.memory_gb >= 4.0)
.min_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap())
.map(|s| json!({
"id": s.id,
"price_monthly_eur": s.price_monthly,
"specs": format!("{} vCPU, {:.0} GB RAM", s.cores, s.memory_gb),
})),
"best_value_8gb": server_types.iter()
.filter(|s| s.memory_gb >= 8.0)
.min_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap())
.map(|s| json!({
"id": s.id,
"price_monthly_eur": s.price_monthly,
"specs": format!("{} vCPU, {:.0} GB RAM", s.cores, s.memory_gb),
})),
},
"usage_notes": [
"Use region IDs (nbg1, fsn1, hel1, ash, hil, sin) when deploying",
"EU regions (nbg1, fsn1, hel1) have lowest pricing",
"CX series: shared CPU, best for most workloads",
"CCX series: dedicated CPU, best for CPU-intensive workloads",
"CPX series: AMD performance, good balance of price/performance",
],
});
serde_json::to_string_pretty(&response)
.map_err(|e| ListHetznerAvailabilityError(format!("Failed to serialize: {}", e)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_name() {
assert_eq!(
ListHetznerAvailabilityTool::NAME,
"list_hetzner_availability"
);
}
#[test]
fn test_tool_creation() {
let tool = ListHetznerAvailabilityTool::new();
assert!(format!("{:?}", tool).contains("ListHetznerAvailabilityTool"));
}
}