syncable_cli/agent/tools/platform/
list_hetzner_availability.rs1use 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#[derive(Debug, Deserialize)]
21pub struct ListHetznerAvailabilityArgs {
22 pub location: Option<String>,
24}
25
26#[derive(Debug, thiserror::Error)]
28#[error("Hetzner availability error: {0}")]
29pub struct ListHetznerAvailabilityError(String);
30
31#[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 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 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 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 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(), HetznerFetchResult::ApiError(_) => Vec::new(), };
178
179 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 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}