Skip to main content

syncable_cli/agent/tools/platform/
list_hetzner_availability.rs

1//! List Hetzner availability tool for the agent
2//!
3//! Fetches real-time Hetzner Cloud region and server type availability with pricing.
4//! The agent uses this to make smart deployment decisions based on current capacity.
5
6use rig::completion::ToolDefinition;
7use rig::tool::Tool;
8use serde::{Deserialize, Serialize};
9use serde_json::json;
10
11use crate::agent::tools::error::{ErrorCategory, format_error_for_llm};
12use crate::platform::PlatformSession;
13use crate::platform::api::PlatformApiClient;
14use crate::wizard::{
15    DynamicCloudRegion, DynamicMachineType, HetznerFetchResult, get_hetzner_regions_dynamic,
16    get_hetzner_server_types_dynamic,
17};
18
19/// Arguments for the list_hetzner_availability tool
20#[derive(Debug, Deserialize)]
21pub struct ListHetznerAvailabilityArgs {
22    /// Optional: filter server types by location
23    pub location: Option<String>,
24}
25
26/// Error type for availability operations
27#[derive(Debug, thiserror::Error)]
28#[error("Hetzner availability error: {0}")]
29pub struct ListHetznerAvailabilityError(String);
30
31/// Tool to fetch real-time Hetzner Cloud availability
32///
33/// Returns current regions/locations and server types with:
34/// - Real-time availability per region
35/// - Current pricing (hourly and monthly in EUR)
36/// - CPU, memory, and disk specs for each server type
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ListHetznerAvailabilityTool;
39
40impl ListHetznerAvailabilityTool {
41    pub fn new() -> Self {
42        Self
43    }
44}
45
46impl Default for ListHetznerAvailabilityTool {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52impl Tool for ListHetznerAvailabilityTool {
53    const NAME: &'static str = "list_hetzner_availability";
54
55    type Error = ListHetznerAvailabilityError;
56    type Args = ListHetznerAvailabilityArgs;
57    type Output = String;
58
59    async fn definition(&self, _prompt: String) -> ToolDefinition {
60        ToolDefinition {
61            name: Self::NAME.to_string(),
62            description: r#"Fetch real-time Hetzner Cloud region and server type availability.
63
64**IMPORTANT:** Use this tool BEFORE recommending Hetzner regions or server types.
65This provides current data directly from Hetzner API - never use hardcoded/static data.
66
67**What it returns:**
68- Available regions/locations with:
69  - Region ID (e.g., "nbg1", "fsn1", "hel1", "ash", "hil", "sin")
70  - City name and country
71  - Network zone (eu-central, us-east, us-west, ap-southeast)
72  - List of server types currently available in that region
73
74- Available server types with:
75  - Server type ID (e.g., "cx22", "cx32", "cpx21")
76  - CPU cores and memory (GB)
77  - Disk size (GB)
78  - Current pricing (EUR/hour and EUR/month)
79  - Which regions this type is available in
80
81**When to use:**
82- When user asks about Hetzner regions/locations
83- When recommending infrastructure for Hetzner deployment
84- When user wants to compare Hetzner server types and pricing
85- Before deploying to Hetzner to verify availability
86
87**Parameters:**
88- location: Optional. Filter server types by specific location (e.g., "nbg1")
89
90**Prerequisites:**
91- User must be authenticated
92- A project with Hetzner credentials must be selected"#
93                .to_string(),
94            parameters: json!({
95                "type": "object",
96                "properties": {
97                    "location": {
98                        "type": "string",
99                        "description": "Optional: Filter server types by location (e.g., 'nbg1', 'fsn1')"
100                    }
101                }
102            }),
103        }
104    }
105
106    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
107        // Get API client
108        let client = match PlatformApiClient::new() {
109            Ok(c) => c,
110            Err(_) => {
111                return Ok(format_error_for_llm(
112                    "list_hetzner_availability",
113                    ErrorCategory::PermissionDenied,
114                    "Not authenticated",
115                    Some(vec!["Run: sync-ctl auth login"]),
116                ));
117            }
118        };
119
120        // Load platform session for project context
121        let session = match PlatformSession::load() {
122            Ok(s) => s,
123            Err(_) => {
124                return Ok(format_error_for_llm(
125                    "list_hetzner_availability",
126                    ErrorCategory::InternalError,
127                    "Failed to load platform session",
128                    Some(vec!["Try selecting a project with select_project"]),
129                ));
130            }
131        };
132
133        if !session.is_project_selected() {
134            return Ok(format_error_for_llm(
135                "list_hetzner_availability",
136                ErrorCategory::ValidationFailed,
137                "No project selected",
138                Some(vec!["Use select_project to choose a project first"]),
139            ));
140        }
141
142        let project_id = session.project_id.clone().unwrap_or_default();
143
144        // Fetch regions
145        let regions: Vec<DynamicCloudRegion> =
146            match get_hetzner_regions_dynamic(&client, &project_id).await {
147                HetznerFetchResult::Success(r) => r,
148                HetznerFetchResult::NoCredentials => {
149                    return Ok(format_error_for_llm(
150                        "list_hetzner_availability",
151                        ErrorCategory::PermissionDenied,
152                        "Hetzner credentials not configured for this project",
153                        Some(vec![
154                            "Add Hetzner API token in project settings",
155                            "Use open_provider_settings to configure Hetzner",
156                        ]),
157                    ));
158                }
159                HetznerFetchResult::ApiError(err) => {
160                    return Ok(format_error_for_llm(
161                        "list_hetzner_availability",
162                        ErrorCategory::NetworkError,
163                        &format!("Failed to fetch Hetzner regions: {}", err),
164                        None,
165                    ));
166                }
167            };
168
169        // Fetch server types
170        let server_types: Vec<DynamicMachineType> =
171            match get_hetzner_server_types_dynamic(&client, &project_id, args.location.as_deref())
172                .await
173            {
174                HetznerFetchResult::Success(s) => s,
175                HetznerFetchResult::NoCredentials => Vec::new(), // Already handled above
176                HetznerFetchResult::ApiError(_) => Vec::new(),   // Non-fatal, continue with regions
177            };
178
179        // Format response
180        let regions_json: Vec<serde_json::Value> = regions
181            .iter()
182            .map(|r| {
183                json!({
184                    "id": r.id,
185                    "name": r.name,
186                    "country": r.location,
187                    "network_zone": r.network_zone,
188                    "available_server_types_count": r.available_server_types.len(),
189                    "available_server_types": r.available_server_types,
190                })
191            })
192            .collect();
193
194        let server_types_json: Vec<serde_json::Value> = server_types
195            .iter()
196            .map(|s| {
197                json!({
198                    "id": s.id,
199                    "name": s.name,
200                    "cores": s.cores,
201                    "memory_gb": s.memory_gb,
202                    "disk_gb": s.disk_gb,
203                    "price_hourly_eur": s.price_hourly,
204                    "price_monthly_eur": s.price_monthly,
205                    "available_in": s.available_in,
206                })
207            })
208            .collect();
209
210        // Group server types by category for easier reading
211        let shared_cpu: Vec<&serde_json::Value> = server_types_json
212            .iter()
213            .filter(|s| s["id"].as_str().is_some_and(|id| id.starts_with("cx")))
214            .collect();
215
216        let dedicated_cpu: Vec<&serde_json::Value> = server_types_json
217            .iter()
218            .filter(|s| s["id"].as_str().is_some_and(|id| id.starts_with("ccx")))
219            .collect();
220
221        let performance: Vec<&serde_json::Value> = server_types_json
222            .iter()
223            .filter(|s| s["id"].as_str().is_some_and(|id| id.starts_with("cpx")))
224            .collect();
225
226        let response = json!({
227            "status": "success",
228            "summary": {
229                "total_regions": regions.len(),
230                "total_server_types": server_types.len(),
231                "filter_applied": args.location,
232            },
233            "regions": regions_json,
234            "server_types": {
235                "shared_cpu_cx": shared_cpu,
236                "dedicated_cpu_ccx": dedicated_cpu,
237                "performance_cpx": performance,
238                "all": server_types_json,
239            },
240            "recommendations": {
241                "cheapest": server_types.iter()
242                    .min_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap())
243                    .map(|s| json!({
244                        "id": s.id,
245                        "price_monthly_eur": s.price_monthly,
246                        "specs": format!("{} vCPU, {:.0} GB RAM", s.cores, s.memory_gb),
247                    })),
248                "best_value_4gb": server_types.iter()
249                    .filter(|s| s.memory_gb >= 4.0)
250                    .min_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap())
251                    .map(|s| json!({
252                        "id": s.id,
253                        "price_monthly_eur": s.price_monthly,
254                        "specs": format!("{} vCPU, {:.0} GB RAM", s.cores, s.memory_gb),
255                    })),
256                "best_value_8gb": server_types.iter()
257                    .filter(|s| s.memory_gb >= 8.0)
258                    .min_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap())
259                    .map(|s| json!({
260                        "id": s.id,
261                        "price_monthly_eur": s.price_monthly,
262                        "specs": format!("{} vCPU, {:.0} GB RAM", s.cores, s.memory_gb),
263                    })),
264            },
265            "usage_notes": [
266                "Use region IDs (nbg1, fsn1, hel1, ash, hil, sin) when deploying",
267                "EU regions (nbg1, fsn1, hel1) have lowest pricing",
268                "CX series: shared CPU, best for most workloads",
269                "CCX series: dedicated CPU, best for CPU-intensive workloads",
270                "CPX series: AMD performance, good balance of price/performance",
271            ],
272        });
273
274        serde_json::to_string_pretty(&response)
275            .map_err(|e| ListHetznerAvailabilityError(format!("Failed to serialize: {}", e)))
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn test_tool_name() {
285        assert_eq!(
286            ListHetznerAvailabilityTool::NAME,
287            "list_hetzner_availability"
288        );
289    }
290
291    #[test]
292    fn test_tool_creation() {
293        let tool = ListHetznerAvailabilityTool::new();
294        assert!(format!("{:?}", tool).contains("ListHetznerAvailabilityTool"));
295    }
296}