Graham Paasch commited on
Commit
1209812
Β·
1 Parent(s): e667ba8

Improve monitoring UX and deterministic offline outputs

Browse files
API_MONITORING.md CHANGED
@@ -47,6 +47,10 @@ Every single API call is logged with full details:
47
 
48
  4. **Token Breakdown** - Separate counts for input vs output tokens
49
 
 
 
 
 
50
  ## Why This Impresses Judges
51
 
52
  ### 1. **Enterprise-Grade Observability**
 
47
 
48
  4. **Token Breakdown** - Separate counts for input vs output tokens
49
 
50
+ 5. **Budget Guardrails** - Optional session budget with alerts
51
+ - Set `API_BUDGET_USD` (e.g., `50` or `15.5`) to display remaining budget and trigger warnings
52
+ - Tweak alert threshold with `API_BUDGET_ALERT_FRACTION` (default `0.8` for 80%)
53
+
54
  ## Why This Impresses Judges
55
 
56
  ### 1. **Enterprise-Grade Observability**
agent/api_monitor.py CHANGED
@@ -5,6 +5,7 @@ Provides real-time visibility into LLM and GNS3 API usage for judges/users
5
 
6
  import time
7
  import logging
 
8
  from typing import Dict, List, Optional, Any
9
  from dataclasses import dataclass, field, asdict
10
  from datetime import datetime
@@ -29,6 +30,7 @@ PRICING = {
29
  @dataclass
30
  class APICall:
31
  """Record of a single API call"""
 
32
  timestamp: str
33
  api_type: str # "llm", "gns3", "netbox", etc.
34
  provider: str # "openai", "anthropic", "openrouter", "gns3"
@@ -89,36 +91,71 @@ class SessionStats:
89
  total_cost: float = 0.0
90
  errors: int = 0
91
  start_time: str = field(default_factory=lambda: datetime.now().isoformat())
 
 
 
92
 
93
  def to_dict(self) -> Dict:
94
  """Convert to dictionary"""
95
  return asdict(self)
96
 
97
- def format_dashboard(self) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  """Format as markdown dashboard for UI"""
99
  uptime = datetime.now() - datetime.fromisoformat(self.start_time)
100
  uptime_str = f"{int(uptime.total_seconds() / 60)}m {int(uptime.total_seconds() % 60)}s"
101
 
102
- return f"""
103
- ### πŸ“Š Session Statistics
104
-
105
- | Metric | Value |
106
- |--------|-------|
107
- | ⏰ Session Duration | {uptime_str} |
108
- | πŸ“ž Total API Calls | {self.total_calls:,} |
109
- | πŸ€– LLM Calls | {self.llm_calls:,} |
110
- | 🌐 GNS3 Calls | {self.gns3_calls:,} |
111
- | πŸ“ Total Tokens | {self.total_tokens:,} |
112
- | πŸ’° **Total Cost** | **${self.total_cost:.4f}** |
113
- | ❌ Errors | {self.errors} |
114
-
115
- ---
116
-
117
- ### πŸ’΅ Cost Breakdown
118
- - Input tokens: {self.total_input_tokens:,} ({self.total_input_tokens / 1000000:.2f}M)
119
- - Output tokens: {self.total_output_tokens:,} ({self.total_output_tokens / 1000000:.2f}M)
120
- - Avg cost per call: ${self.total_cost / max(self.llm_calls, 1):.4f}
121
- """
 
 
 
 
 
 
122
 
123
 
124
  class APIMonitor:
@@ -144,8 +181,18 @@ class APIMonitor:
144
 
145
  self._initialized = True
146
  self.calls: List[APICall] = []
147
- self.stats = SessionStats()
148
  self.active_calls: Dict[str, float] = {} # call_id -> start_time
 
 
 
 
 
 
 
 
 
 
 
149
  logger.info("API Monitor initialized")
150
 
151
  def start_call(self, call_id: str, api_type: str, provider: str, endpoint: str, **metadata) -> APICall:
@@ -155,6 +202,7 @@ class APIMonitor:
155
  """
156
  with self._lock:
157
  call = APICall(
 
158
  timestamp=datetime.now().isoformat(),
159
  api_type=api_type,
160
  provider=provider,
@@ -164,6 +212,7 @@ class APIMonitor:
164
  )
165
  self.calls.append(call)
166
  self.active_calls[call_id] = time.time()
 
167
  return call
168
 
169
  def complete_call(
@@ -177,22 +226,13 @@ class APIMonitor:
177
  ):
178
  """Mark a call as completed and update statistics"""
179
  with self._lock:
180
- if call_id not in self.active_calls:
181
- logger.warning(f"Call {call_id} not found in active calls")
182
- return
183
-
184
- start_time = self.active_calls.pop(call_id)
185
- duration_ms = (time.time() - start_time) * 1000
186
 
187
- # Find the call in our list (should be most recent)
188
- call = None
189
- for c in reversed(self.calls):
190
- if c.status == "in-progress" and c.timestamp == call_id:
191
- call = c
192
- break
193
-
194
- # If we can't find by timestamp, find the most recent in-progress call
195
  if not call:
 
196
  for c in reversed(self.calls):
197
  if c.status == "in-progress":
198
  call = c
@@ -210,14 +250,17 @@ class APIMonitor:
210
  call.error_message = error_message
211
  call.metadata.update(metadata)
212
 
213
- if input_tokens and output_tokens:
214
- call.total_tokens = input_tokens + output_tokens
 
 
 
215
 
216
  # Calculate cost if it's an LLM call
217
  if call.api_type == "llm" and call.endpoint in PRICING:
218
  pricing = PRICING[call.endpoint]
219
- input_cost = (input_tokens / 1_000_000) * pricing["input"]
220
- output_cost = (output_tokens / 1_000_000) * pricing["output"]
221
  call.estimated_cost = input_cost + output_cost
222
 
223
  # Update session stats
@@ -228,14 +271,20 @@ class APIMonitor:
228
  elif call.api_type == "gns3":
229
  self.stats.gns3_calls += 1
230
 
231
- if call.total_tokens:
232
  self.stats.total_tokens += call.total_tokens
233
- if call.input_tokens:
234
  self.stats.total_input_tokens += call.input_tokens
235
- if call.output_tokens:
236
  self.stats.total_output_tokens += call.output_tokens
237
  if call.estimated_cost:
238
  self.stats.total_cost += call.estimated_cost
 
 
 
 
 
 
239
 
240
  if not success:
241
  self.stats.errors += 1
@@ -270,14 +319,23 @@ class APIMonitor:
270
  for call in reversed(recent):
271
  lines.append(f"- {call.format_log_entry()}")
272
 
 
 
 
273
  return "\n".join(lines)
274
 
275
  def reset(self):
276
  """Reset all tracking (for testing or new sessions)"""
277
  with self._lock:
278
  self.calls.clear()
279
- self.stats = SessionStats()
 
 
 
 
 
280
  self.active_calls.clear()
 
281
  logger.info("API Monitor reset")
282
 
283
  def export_json(self) -> str:
 
5
 
6
  import time
7
  import logging
8
+ import os
9
  from typing import Dict, List, Optional, Any
10
  from dataclasses import dataclass, field, asdict
11
  from datetime import datetime
 
30
  @dataclass
31
  class APICall:
32
  """Record of a single API call"""
33
+ call_id: str
34
  timestamp: str
35
  api_type: str # "llm", "gns3", "netbox", etc.
36
  provider: str # "openai", "anthropic", "openrouter", "gns3"
 
91
  total_cost: float = 0.0
92
  errors: int = 0
93
  start_time: str = field(default_factory=lambda: datetime.now().isoformat())
94
+ budget_limit: Optional[float] = None
95
+ budget_alert_fraction: float = 0.8
96
+ budget_alert_triggered: bool = False
97
 
98
  def to_dict(self) -> Dict:
99
  """Convert to dictionary"""
100
  return asdict(self)
101
 
102
+ def budget_remaining(self) -> Optional[float]:
103
+ """Return remaining budget if configured"""
104
+ if self.budget_limit is None:
105
+ return None
106
+ return max(self.budget_limit - self.total_cost, 0.0)
107
+
108
+ def budget_usage_ratio(self) -> Optional[float]:
109
+ """Return fraction of budget consumed"""
110
+ if self.budget_limit is None or self.budget_limit == 0:
111
+ return None
112
+ return self.total_cost / self.budget_limit
113
+
114
+ def budget_status(self) -> str:
115
+ """Human-readable budget status message"""
116
+ if self.budget_limit is None or self.budget_limit == 0:
117
+ return "Budget not configured"
118
+
119
+ ratio = self.budget_usage_ratio() or 0.0
120
+ remaining = self.budget_remaining()
121
+
122
+ if ratio >= 1.0:
123
+ return f"πŸ›‘ Budget exceeded by ${abs(remaining):.2f}"
124
+ if ratio >= self.budget_alert_fraction:
125
+ return f"⚠️ {ratio*100:.0f}% of budget used (limit ${self.budget_limit:.2f})"
126
+ return f"βœ… {ratio*100:.0f}% of budget used, ${remaining:.2f} remaining"
127
+
128
+ def format_dashboard(self, include_heading: bool = True) -> str:
129
  """Format as markdown dashboard for UI"""
130
  uptime = datetime.now() - datetime.fromisoformat(self.start_time)
131
  uptime_str = f"{int(uptime.total_seconds() / 60)}m {int(uptime.total_seconds() % 60)}s"
132
 
133
+ budget_line = "n/a"
134
+ if self.budget_limit not in (None, 0):
135
+ remaining = self.budget_remaining()
136
+ budget_line = f"${self.budget_limit:.2f} (remaining ${remaining:.2f})"
137
+ status_line = self.budget_status()
138
+
139
+ header = "### πŸ“Š Session Statistics\n\n" if include_heading else ""
140
+ return (
141
+ f"{header}"
142
+ "| Metric | Value |\n"
143
+ "|--------|-------|\n"
144
+ f"| ⏰ Session Duration | {uptime_str} |\n"
145
+ f"| πŸ“ž Total API Calls | {self.total_calls:,} |\n"
146
+ f"| πŸ€– LLM Calls | {self.llm_calls:,} |\n"
147
+ f"| 🌐 GNS3 Calls | {self.gns3_calls:,} |\n"
148
+ f"| πŸ“ Total Tokens | {self.total_tokens:,} |\n"
149
+ f"| πŸ’° **Total Cost** | **${self.total_cost:.4f}** |\n"
150
+ f"| ❌ Errors | {self.errors} |\n"
151
+ f"| 🧭 Budget | {budget_line} |\n"
152
+ f"| 🚨 Budget Status | {status_line} |\n\n"
153
+ "---\n\n"
154
+ "### πŸ’΅ Cost Breakdown\n"
155
+ f"- Input tokens: {self.total_input_tokens:,} ({self.total_input_tokens / 1000000:.2f}M)\n"
156
+ f"- Output tokens: {self.total_output_tokens:,} ({self.total_output_tokens / 1000000:.2f}M)\n"
157
+ f"- Avg cost per call: ${self.total_cost / max(self.total_calls, 1):.4f}\n"
158
+ )
159
 
160
 
161
  class APIMonitor:
 
181
 
182
  self._initialized = True
183
  self.calls: List[APICall] = []
 
184
  self.active_calls: Dict[str, float] = {} # call_id -> start_time
185
+ self._call_index: Dict[str, APICall] = {}
186
+
187
+ # Budget configuration (env vars for quick tuning)
188
+ budget_env = os.getenv("API_BUDGET_USD")
189
+ budget_limit = float(budget_env) if budget_env else None
190
+ budget_alert_fraction = float(os.getenv("API_BUDGET_ALERT_FRACTION", "0.8"))
191
+
192
+ self.stats = SessionStats(
193
+ budget_limit=budget_limit,
194
+ budget_alert_fraction=budget_alert_fraction
195
+ )
196
  logger.info("API Monitor initialized")
197
 
198
  def start_call(self, call_id: str, api_type: str, provider: str, endpoint: str, **metadata) -> APICall:
 
202
  """
203
  with self._lock:
204
  call = APICall(
205
+ call_id=call_id,
206
  timestamp=datetime.now().isoformat(),
207
  api_type=api_type,
208
  provider=provider,
 
212
  )
213
  self.calls.append(call)
214
  self.active_calls[call_id] = time.time()
215
+ self._call_index[call_id] = call
216
  return call
217
 
218
  def complete_call(
 
226
  ):
227
  """Mark a call as completed and update statistics"""
228
  with self._lock:
229
+ start_time = self.active_calls.pop(call_id, None)
230
+ duration_ms = (time.time() - start_time) * 1000 if start_time else None
 
 
 
 
231
 
232
+ # Find the matching call by call_id
233
+ call = self._call_index.pop(call_id, None)
 
 
 
 
 
 
234
  if not call:
235
+ # Fallback to the most recent in-progress call
236
  for c in reversed(self.calls):
237
  if c.status == "in-progress":
238
  call = c
 
250
  call.error_message = error_message
251
  call.metadata.update(metadata)
252
 
253
+ # Handle token accounting even if only one side is present
254
+ if input_tokens is not None or output_tokens is not None:
255
+ in_tokens = input_tokens or 0
256
+ out_tokens = output_tokens or 0
257
+ call.total_tokens = in_tokens + out_tokens
258
 
259
  # Calculate cost if it's an LLM call
260
  if call.api_type == "llm" and call.endpoint in PRICING:
261
  pricing = PRICING[call.endpoint]
262
+ input_cost = (in_tokens / 1_000_000) * pricing["input"]
263
+ output_cost = (out_tokens / 1_000_000) * pricing["output"]
264
  call.estimated_cost = input_cost + output_cost
265
 
266
  # Update session stats
 
271
  elif call.api_type == "gns3":
272
  self.stats.gns3_calls += 1
273
 
274
+ if call.total_tokens is not None:
275
  self.stats.total_tokens += call.total_tokens
276
+ if call.input_tokens is not None:
277
  self.stats.total_input_tokens += call.input_tokens
278
+ if call.output_tokens is not None:
279
  self.stats.total_output_tokens += call.output_tokens
280
  if call.estimated_cost:
281
  self.stats.total_cost += call.estimated_cost
282
+
283
+ # Budget monitoring
284
+ if self.stats.budget_limit not in (None, 0):
285
+ usage_ratio = self.stats.budget_usage_ratio() or 0.0
286
+ if usage_ratio >= self.stats.budget_alert_fraction:
287
+ self.stats.budget_alert_triggered = True
288
 
289
  if not success:
290
  self.stats.errors += 1
 
319
  for call in reversed(recent):
320
  lines.append(f"- {call.format_log_entry()}")
321
 
322
+ if self.stats.budget_alert_triggered:
323
+ lines.append("\n> 🚨 Budget alert: " + self.stats.budget_status())
324
+
325
  return "\n".join(lines)
326
 
327
  def reset(self):
328
  """Reset all tracking (for testing or new sessions)"""
329
  with self._lock:
330
  self.calls.clear()
331
+ budget_limit = self.stats.budget_limit
332
+ alert_fraction = self.stats.budget_alert_fraction
333
+ self.stats = SessionStats(
334
+ budget_limit=budget_limit,
335
+ budget_alert_fraction=alert_fraction
336
+ )
337
  self.active_calls.clear()
338
+ self._call_index.clear()
339
  logger.info("API Monitor reset")
340
 
341
  def export_json(self) -> str:
agent/hardware_pricing.py CHANGED
@@ -3,6 +3,22 @@ Hardware pricing database for BOM generation
3
  Prices are approximate retail as of 2025
4
  """
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  HARDWARE_PRICING = {
7
  # Switches
8
  "Cisco Catalyst 9300": {"price": 8500, "category": "switch", "vendor": "Cisco"},
 
3
  Prices are approximate retail as of 2025
4
  """
5
 
6
+ # Basic procurement links for common gear we surface in mock/default designs
7
+ PROCUREMENT_LINKS = {
8
+ "Cisco Catalyst 9300": "https://www.cisco.com/c/en/us/products/switches/catalyst-9300-series-switches/",
9
+ "Cisco ISR 4331": "https://www.cisco.com/c/en/us/products/routers/4000-series-integrated-services-routers-isr/index.html",
10
+ "Cisco ISR 1100": "https://www.cisco.com/c/en/us/products/routers/1100-series-integrated-services-routers-isr/index.html",
11
+ "Arista 7050": "https://www.arista.com/en/products/7050x3-series",
12
+ "Fortinet FortiGate 60F": "https://www.fortinet.com/products/next-generation-firewall/fortigate-60f",
13
+ "Palo Alto PA-220": "https://www.paloaltonetworks.com/products/secure-the-network/next-generation-firewall/pa-220",
14
+ "Ubiquiti U6-Pro": "https://store.ui.com/us/en/pro/category/all-wifi/products/u6-pro",
15
+ "Ubiquiti U6-Enterprise": "https://store.ui.com/us/en/pro/category/all-wifi/products/u6-enterprise",
16
+ "Ubiquiti USW-Pro-24-PoE": "https://store.ui.com/us/en/pro/category/switching/products/usw-pro-24-poe",
17
+ "Ubiquiti USW-Enterprise-48-PoE": "https://store.ui.com/us/en/pro/category/switching/products/usw-enterprise-48-poe",
18
+ "pfSense Netgate 6100": "https://shop.netgate.com/products/6100-base",
19
+ "Dell PowerEdge R650": "https://www.dell.com/en-us/shop/povw/poweredge-r650",
20
+ }
21
+
22
  HARDWARE_PRICING = {
23
  # Switches
24
  "Cisco Catalyst 9300": {"price": 8500, "category": "switch", "vendor": "Cisco"},
agent/llm_client.py CHANGED
@@ -44,6 +44,26 @@ class LLMClient:
44
  logger.info(f"LLM client initialized with provider: {self.provider}")
45
  else:
46
  logger.warning("No LLM API keys found - using mock responses")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
  def _detect_provider(self) -> Optional[str]:
49
  """Detect which LLM provider is available"""
 
44
  logger.info(f"LLM client initialized with provider: {self.provider}")
45
  else:
46
  logger.warning("No LLM API keys found - using mock responses")
47
+
48
+ @staticmethod
49
+ def env_status() -> Dict[str, str]:
50
+ """
51
+ Report which keys are present (without exposing values) and chosen provider.
52
+ Helpful for UI/debug when Secrets are misconfigured.
53
+ """
54
+ status = {
55
+ "openai_key": "present" if (os.getenv("OPENAI_API_KEY") or os.getenv("OPENAI_MCP_1ST_BDAY")) else "missing",
56
+ "anthropic_key": "present" if (os.getenv("ANTHROPIC_API_KEY") or os.getenv("ANTHROPIC_MCP_1ST_BDAY")) else "missing",
57
+ "openrouter_key": "present" if os.getenv("OPENROUTER_API_KEY") else "missing",
58
+ "provider": "unknown",
59
+ }
60
+ if status["anthropic_key"] == "present":
61
+ status["provider"] = "anthropic"
62
+ elif status["openai_key"] == "present":
63
+ status["provider"] = "openai"
64
+ elif status["openrouter_key"] == "present":
65
+ status["provider"] = "openrouter"
66
+ return status
67
 
68
  def _detect_provider(self) -> Optional[str]:
69
  """Detect which LLM provider is available"""
agent/pipeline_engine.py CHANGED
@@ -91,14 +91,20 @@ class BillOfMaterials:
91
  for item in self.devices:
92
  lines.append(f"- [{item['quantity']}x] {item['model']} - {item['purpose']}")
93
  lines.append(f" Vendor: {item['vendor']} | Est. Cost: ${item['estimated_cost']}")
 
 
94
 
95
  lines.append("\n## Cabling")
96
  for item in self.cables:
97
  lines.append(f"- [{item['quantity']}x] {item['type']} ({item['length']})")
 
 
98
 
99
  lines.append("\n## Accessories")
100
  for item in self.accessories:
101
  lines.append(f"- {item['name']} - {item['purpose']}")
 
 
102
 
103
  lines.append("\n## Software Licenses")
104
  for item in self.software_licenses:
@@ -470,6 +476,16 @@ class OvergrowthPipeline:
470
  )
471
 
472
  return intent
 
 
 
 
 
 
 
 
 
 
473
 
474
  def stage2_generate_sot(self, intent: NetworkIntent) -> NetworkModel:
475
  """
@@ -482,7 +498,36 @@ class OvergrowthPipeline:
482
  import json
483
 
484
  llm = LLMClient()
485
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  # Build prompt for network design
487
  design_prompt = f"""You are an expert network architect. Design a production-ready network based on these requirements:
488
 
@@ -534,7 +579,7 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
534
  name=f"network_{intent.description[:20].replace(' ', '_')}",
535
  version="1.0.0",
536
  intent=intent,
537
- devices=[], # Will populate from design
538
  vlans=design.get('vlans', []),
539
  subnets=design.get('subnets', []),
540
  routing=design.get('routing', {}),
@@ -543,17 +588,62 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
543
 
544
  except Exception as e:
545
  logger.error(f"LLM design failed: {e}, using template")
546
- # Fallback to basic template
 
547
  model = NetworkModel(
548
  name=f"network_{intent.description[:20].replace(' ', '_')}",
549
  version="1.0.0",
550
  intent=intent,
551
- devices=[],
552
- vlans=[],
553
- subnets=[],
554
- routing={},
555
- services=["DHCP", "DNS", "NTP"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
 
558
  # Save to file (always, for backup)
559
  self.sot_file.write_text(model.to_yaml())
@@ -606,10 +696,16 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
606
  """
607
  logger.info("Stage 4: Generating bill of materials")
608
 
609
- from agent.hardware_pricing import estimate_device_cost, estimate_cable_cost, estimate_accessory_cost
 
 
 
 
 
610
 
611
  devices = []
612
  device_total = 0
 
613
 
614
  # If we have devices in the model, price them
615
  if model.devices:
@@ -620,9 +716,12 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
620
  'model': device.model,
621
  'purpose': f"{device.role} - {device.name}",
622
  'vendor': device.vendor,
623
- 'estimated_cost': cost
 
624
  })
625
  device_total += cost
 
 
626
  else:
627
  # Estimate based on VLANs/subnets if no devices specified
628
  num_vlans = len(model.vlans)
@@ -633,9 +732,12 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
633
  'model': 'Ubiquiti USW-Pro-24-PoE',
634
  'purpose': 'Core switch',
635
  'vendor': 'Ubiquiti',
636
- 'estimated_cost': 499
 
637
  })
638
  device_total += 499
 
 
639
 
640
  # Add APs if we have guest/user networks
641
  if any('guest' in v.get('name', '').lower() or 'wifi' in v.get('name', '').lower()
@@ -646,9 +748,12 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
646
  'model': 'Ubiquiti U6-Pro',
647
  'purpose': 'Wireless Access Points',
648
  'vendor': 'Ubiquiti',
649
- 'estimated_cost': ap_cost * 2
 
650
  })
651
  device_total += ap_cost * 2
 
 
652
 
653
  # Cables
654
  cable_total = 0
@@ -657,7 +762,9 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
657
  {'type': 'Fiber LC-LC', 'length': '10m', 'quantity': max(2, len(model.devices) // 3)}
658
  ]
659
  for cable in cables:
660
- cable_total += estimate_cable_cost(cable['type'], cable['quantity'], cable['length'])
 
 
661
 
662
  # Accessories
663
  accessory_total = 0
@@ -666,7 +773,9 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
666
  {'name': 'Console Cable Kit', 'purpose': 'Initial configuration'}
667
  ]
668
  for acc in accessories:
669
- accessory_total += estimate_accessory_cost(acc['name'])
 
 
670
 
671
  total_cost = device_total + cable_total + accessory_total
672
 
@@ -677,7 +786,7 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
677
  accessories=accessories,
678
  software_licenses=[],
679
  total_estimated_cost=total_cost,
680
- procurement_links=[]
681
  )
682
 
683
  # Save BOM
@@ -1011,6 +1120,7 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
1011
  # Stage 1: Consultation
1012
  intent = self.stage1_consultation(consultation_input)
1013
  results['intent'] = asdict(intent)
 
1014
 
1015
  # Stage 2: Source of Truth
1016
  model = self.stage2_generate_sot(intent)
@@ -1032,6 +1142,7 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
1032
 
1033
  bom = self.stage4_generate_bom(model)
1034
  results['bom'] = asdict(bom)
 
1035
 
1036
  return results
1037
 
 
91
  for item in self.devices:
92
  lines.append(f"- [{item['quantity']}x] {item['model']} - {item['purpose']}")
93
  lines.append(f" Vendor: {item['vendor']} | Est. Cost: ${item['estimated_cost']}")
94
+ if item.get('link'):
95
+ lines.append(f" Link: {item['link']}")
96
 
97
  lines.append("\n## Cabling")
98
  for item in self.cables:
99
  lines.append(f"- [{item['quantity']}x] {item['type']} ({item['length']})")
100
+ if 'estimated_cost' in item:
101
+ lines.append(f" Est. Cost: ${item['estimated_cost']:.2f}")
102
 
103
  lines.append("\n## Accessories")
104
  for item in self.accessories:
105
  lines.append(f"- {item['name']} - {item['purpose']}")
106
+ if 'estimated_cost' in item:
107
+ lines.append(f" Est. Cost: ${item['estimated_cost']:.2f}")
108
 
109
  lines.append("\n## Software Licenses")
110
  for item in self.software_licenses:
 
476
  )
477
 
478
  return intent
479
+
480
+ def _generate_clarifying_questions(self, intent: NetworkIntent) -> List[str]:
481
+ """Deterministic clarifying questions for the UI when LLM chat is disabled."""
482
+ return [
483
+ "What is the target WAN bandwidth per site (e.g., 200 Mbps, 1 Gbps)?",
484
+ "Do you need redundant internet links at HQ or any branch?",
485
+ "Are guest and IoT networks required to be fully isolated from corporate traffic?",
486
+ "Which vendors are approved for switches/routers/firewalls (Cisco/Arista/Fortinet/Ubiquiti)?",
487
+ "Do you need WiFi voice roaming or only data for guests/corp?",
488
+ ]
489
 
490
  def stage2_generate_sot(self, intent: NetworkIntent) -> NetworkModel:
491
  """
 
498
  import json
499
 
500
  llm = LLMClient()
501
+
502
+ def _default_design() -> Dict[str, Any]:
503
+ """Deterministic fallback design with concrete values for offline/demo runs."""
504
+ return {
505
+ "vlans": [
506
+ {"id": 10, "name": "Management", "subnet": "10.10.10.0/24", "purpose": "Mgmt"},
507
+ {"id": 20, "name": "Users", "subnet": "10.20.0.0/22", "purpose": "Corp"},
508
+ {"id": 30, "name": "Guest", "subnet": "10.30.0.0/23", "purpose": "Guest WiFi"},
509
+ {"id": 40, "name": "IoT", "subnet": "10.40.0.0/23", "purpose": "Cameras/IoT"},
510
+ ],
511
+ "subnets": [
512
+ {"network": "10.10.10.0/24", "gateway": "10.10.10.1", "vlan": 10, "purpose": "Mgmt"},
513
+ {"network": "10.20.0.0/22", "gateway": "10.20.0.1", "vlan": 20, "purpose": "Users"},
514
+ {"network": "10.30.0.0/23", "gateway": "10.30.0.1", "vlan": 30, "purpose": "Guest"},
515
+ {"network": "10.40.0.0/23", "gateway": "10.40.0.1", "vlan": 40, "purpose": "IoT"},
516
+ ],
517
+ "devices": [
518
+ {"name": "hq-core-1", "role": "core", "model": "Cisco Catalyst 9300", "vendor": "Cisco", "mgmt_ip": "10.10.10.11", "location": "HQ"},
519
+ {"name": "hq-core-2", "role": "core", "model": "Arista 7050", "vendor": "Arista", "mgmt_ip": "10.10.10.12", "location": "HQ"},
520
+ {"name": "hq-fw", "role": "firewall", "model": "Fortinet FortiGate 60F", "vendor": "Fortinet", "mgmt_ip": "10.10.10.21", "location": "HQ"},
521
+ {"name": "branch1-wan", "role": "edge", "model": "Cisco ISR 1100", "vendor": "Cisco", "mgmt_ip": "10.10.10.31", "location": "Branch1"},
522
+ {"name": "branch2-wan", "role": "edge", "model": "Cisco ISR 1100", "vendor": "Cisco", "mgmt_ip": "10.10.10.32", "location": "Branch2"},
523
+ {"name": "branch3-wan", "role": "edge", "model": "Cisco ISR 1100", "vendor": "Cisco", "mgmt_ip": "10.10.10.33", "location": "Branch3"},
524
+ {"name": "hq-ap-1", "role": "access_point", "model": "Ubiquiti U6-Pro", "vendor": "Ubiquiti", "mgmt_ip": "10.10.10.41", "location": "HQ"},
525
+ {"name": "hq-ap-2", "role": "access_point", "model": "Ubiquiti U6-Pro", "vendor": "Ubiquiti", "mgmt_ip": "10.10.10.42", "location": "HQ"},
526
+ ],
527
+ "services": ["DHCP", "DNS", "NTP", "Syslog", "RADIUS"],
528
+ "routing": {"protocol": "ospf", "areas": ["0.0.0.0"], "process_id": 1, "networks": ["10.0.0.0/8"]},
529
+ }
530
+
531
  # Build prompt for network design
532
  design_prompt = f"""You are an expert network architect. Design a production-ready network based on these requirements:
533
 
 
579
  name=f"network_{intent.description[:20].replace(' ', '_')}",
580
  version="1.0.0",
581
  intent=intent,
582
+ devices=[], # populated below
583
  vlans=design.get('vlans', []),
584
  subnets=design.get('subnets', []),
585
  routing=design.get('routing', {}),
 
588
 
589
  except Exception as e:
590
  logger.error(f"LLM design failed: {e}, using template")
591
+ # Deterministic fallback template with real values
592
+ design = _default_design()
593
  model = NetworkModel(
594
  name=f"network_{intent.description[:20].replace(' ', '_')}",
595
  version="1.0.0",
596
  intent=intent,
597
+ devices=[], # populated below
598
+ vlans=design.get('vlans', []),
599
+ subnets=design.get('subnets', []),
600
+ routing=design.get('routing', {}),
601
+ services=design.get('services', ["DHCP", "DNS", "NTP"])
602
+ )
603
+
604
+ # Ensure we have meaningful design data even if LLM returned partials
605
+ if not model.vlans or not model.subnets or not design.get("devices"):
606
+ design = _default_design()
607
+ model.vlans = design["vlans"]
608
+ model.subnets = design["subnets"]
609
+ model.routing = design["routing"]
610
+ model.services = design["services"]
611
+
612
+ # Populate devices from design and backfill mgmt IPs if missing
613
+ devices: List[Device] = []
614
+ mgmt_seed = 11
615
+ for dev in design.get("devices", []):
616
+ mgmt_ip = dev.get("mgmt_ip") or f"10.10.10.{mgmt_seed}"
617
+ mgmt_seed += 1
618
+ devices.append(
619
+ Device(
620
+ name=dev.get("name", f"device-{mgmt_seed}"),
621
+ role=dev.get("role", "access"),
622
+ model=dev.get("model", "Generic Switch 48-port"),
623
+ vendor=dev.get("vendor", "Generic"),
624
+ mgmt_ip=mgmt_ip,
625
+ location=dev.get("location", "unspecified"),
626
+ interfaces=dev.get("interfaces", [])
627
+ )
628
  )
629
+
630
+ # If no devices came through, fall back again to deterministic set
631
+ if not devices:
632
+ fallback = _default_design()["devices"]
633
+ for dev in fallback:
634
+ devices.append(
635
+ Device(
636
+ name=dev["name"],
637
+ role=dev["role"],
638
+ model=dev["model"],
639
+ vendor=dev["vendor"],
640
+ mgmt_ip=dev["mgmt_ip"],
641
+ location=dev["location"],
642
+ interfaces=[]
643
+ )
644
+ )
645
+
646
+ model.devices = devices
647
 
648
  # Save to file (always, for backup)
649
  self.sot_file.write_text(model.to_yaml())
 
696
  """
697
  logger.info("Stage 4: Generating bill of materials")
698
 
699
+ from agent.hardware_pricing import (
700
+ estimate_device_cost,
701
+ estimate_cable_cost,
702
+ estimate_accessory_cost,
703
+ PROCUREMENT_LINKS,
704
+ )
705
 
706
  devices = []
707
  device_total = 0
708
+ procurement_links = []
709
 
710
  # If we have devices in the model, price them
711
  if model.devices:
 
716
  'model': device.model,
717
  'purpose': f"{device.role} - {device.name}",
718
  'vendor': device.vendor,
719
+ 'estimated_cost': cost,
720
+ 'link': PROCUREMENT_LINKS.get(device.model)
721
  })
722
  device_total += cost
723
+ if device.model in PROCUREMENT_LINKS:
724
+ procurement_links.append(f"{device.model}: {PROCUREMENT_LINKS[device.model]}")
725
  else:
726
  # Estimate based on VLANs/subnets if no devices specified
727
  num_vlans = len(model.vlans)
 
732
  'model': 'Ubiquiti USW-Pro-24-PoE',
733
  'purpose': 'Core switch',
734
  'vendor': 'Ubiquiti',
735
+ 'estimated_cost': 499,
736
+ 'link': PROCUREMENT_LINKS.get("Ubiquiti USW-Pro-24-PoE")
737
  })
738
  device_total += 499
739
+ if "Ubiquiti USW-Pro-24-PoE" in PROCUREMENT_LINKS:
740
+ procurement_links.append(f"Ubiquiti USW-Pro-24-PoE: {PROCUREMENT_LINKS['Ubiquiti USW-Pro-24-PoE']}")
741
 
742
  # Add APs if we have guest/user networks
743
  if any('guest' in v.get('name', '').lower() or 'wifi' in v.get('name', '').lower()
 
748
  'model': 'Ubiquiti U6-Pro',
749
  'purpose': 'Wireless Access Points',
750
  'vendor': 'Ubiquiti',
751
+ 'estimated_cost': ap_cost * 2,
752
+ 'link': PROCUREMENT_LINKS.get("Ubiquiti U6-Pro")
753
  })
754
  device_total += ap_cost * 2
755
+ if "Ubiquiti U6-Pro" in PROCUREMENT_LINKS:
756
+ procurement_links.append(f"Ubiquiti U6-Pro: {PROCUREMENT_LINKS['Ubiquiti U6-Pro']}")
757
 
758
  # Cables
759
  cable_total = 0
 
762
  {'type': 'Fiber LC-LC', 'length': '10m', 'quantity': max(2, len(model.devices) // 3)}
763
  ]
764
  for cable in cables:
765
+ cost = estimate_cable_cost(cable['type'], cable['quantity'], cable['length'])
766
+ cable['estimated_cost'] = cost
767
+ cable_total += cost
768
 
769
  # Accessories
770
  accessory_total = 0
 
773
  {'name': 'Console Cable Kit', 'purpose': 'Initial configuration'}
774
  ]
775
  for acc in accessories:
776
+ cost = estimate_accessory_cost(acc['name'])
777
+ acc['estimated_cost'] = cost
778
+ accessory_total += cost
779
 
780
  total_cost = device_total + cable_total + accessory_total
781
 
 
786
  accessories=accessories,
787
  software_licenses=[],
788
  total_estimated_cost=total_cost,
789
+ procurement_links=procurement_links
790
  )
791
 
792
  # Save BOM
 
1120
  # Stage 1: Consultation
1121
  intent = self.stage1_consultation(consultation_input)
1122
  results['intent'] = asdict(intent)
1123
+ results['questions'] = self._generate_clarifying_questions(intent)
1124
 
1125
  # Stage 2: Source of Truth
1126
  model = self.stage2_generate_sot(intent)
 
1142
 
1143
  bom = self.stage4_generate_bom(model)
1144
  results['bom'] = asdict(bom)
1145
+ results['shopping_list'] = bom.to_shopping_list()
1146
 
1147
  return results
1148
 
app.py CHANGED
@@ -217,7 +217,7 @@ def build_ui():
217
  with gr.Row(elem_classes=["og-main-row"]):
218
  with gr.Column(scale=1, elem_classes=["og-panel"]):
219
  gr.Markdown("### πŸ“Š Session Statistics")
220
- api_stats = gr.Markdown(value=monitor.get_stats().format_dashboard())
221
  refresh_stats_btn = gr.Button("πŸ”„ Refresh Stats", size="sm", variant="secondary")
222
 
223
  with gr.Column(scale=2, elem_classes=["og-panel"]):
@@ -227,7 +227,7 @@ def build_ui():
227
 
228
  # Auto-refresh handlers
229
  def refresh_api_stats():
230
- return monitor.get_stats().format_dashboard()
231
 
232
  def refresh_api_activity():
233
  return monitor.format_activity_feed()
@@ -301,10 +301,14 @@ def build_ui():
301
  def run_pipeline(user_input):
302
  """Execute the full automation pipeline"""
303
  from agent.pipeline_engine import OvergrowthPipeline
 
304
 
305
  pipeline = OvergrowthPipeline()
306
 
307
  try:
 
 
 
308
  # Run the pipeline (this will generate API calls that get tracked)
309
  results = pipeline.run_full_pipeline(user_input)
310
 
@@ -322,6 +326,32 @@ def build_ui():
322
  status += f"- **LLM Calls:** {stats.llm_calls} | **GNS3 Calls:** {stats.gns3_calls}\n"
323
  status += f"- **Tokens:** {stats.total_tokens:,} ({stats.total_input_tokens:,} in / {stats.total_output_tokens:,} out)\n\n"
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  # Pre-flight validation section
326
  if ready_to_deploy:
327
  status += "### βœ… Pre-flight Validation PASSED\n"
@@ -366,6 +396,12 @@ def build_ui():
366
  status += "- `infra/network_model.yaml` - Source of Truth\n"
367
  status += "- `infra/bill_of_materials.json` - BOM data\n"
368
  status += "- `infra/setup_guide.md` - Deployment guide\n"
 
 
 
 
 
 
369
 
370
  # Extract outputs
371
  sot_yaml = results.get('model', {})
@@ -377,7 +413,7 @@ def build_ui():
377
  diagram = results.get('diagrams', {}).get('ascii', 'No diagram available')
378
 
379
  # Also return updated API stats and activity
380
- updated_stats = monitor.get_stats().format_dashboard()
381
  updated_activity = monitor.format_activity_feed()
382
 
383
  return status, sot_str, bom_md, setup_md, diagram, updated_stats, updated_activity
@@ -388,7 +424,7 @@ def build_ui():
388
  error += f"\n\n```\n{traceback.format_exc()}\n```"
389
 
390
  # Still update API stats even on error
391
- updated_stats = monitor.get_stats().format_dashboard()
392
  updated_activity = monitor.format_activity_feed()
393
 
394
  return error, "", "", "", "", updated_stats, updated_activity
 
217
  with gr.Row(elem_classes=["og-main-row"]):
218
  with gr.Column(scale=1, elem_classes=["og-panel"]):
219
  gr.Markdown("### πŸ“Š Session Statistics")
220
+ api_stats = gr.Markdown(value=monitor.get_stats().format_dashboard(include_heading=False))
221
  refresh_stats_btn = gr.Button("πŸ”„ Refresh Stats", size="sm", variant="secondary")
222
 
223
  with gr.Column(scale=2, elem_classes=["og-panel"]):
 
227
 
228
  # Auto-refresh handlers
229
  def refresh_api_stats():
230
+ return monitor.get_stats().format_dashboard(include_heading=False)
231
 
232
  def refresh_api_activity():
233
  return monitor.format_activity_feed()
 
301
  def run_pipeline(user_input):
302
  """Execute the full automation pipeline"""
303
  from agent.pipeline_engine import OvergrowthPipeline
304
+ from agent.llm_client import LLMClient
305
 
306
  pipeline = OvergrowthPipeline()
307
 
308
  try:
309
+ # Report LLM env status up-front so users see if keys are missing
310
+ llm_env = LLMClient.env_status()
311
+
312
  # Run the pipeline (this will generate API calls that get tracked)
313
  results = pipeline.run_full_pipeline(user_input)
314
 
 
326
  status += f"- **LLM Calls:** {stats.llm_calls} | **GNS3 Calls:** {stats.gns3_calls}\n"
327
  status += f"- **Tokens:** {stats.total_tokens:,} ({stats.total_input_tokens:,} in / {stats.total_output_tokens:,} out)\n\n"
328
 
329
+ # LLM connectivity
330
+ status += "### πŸ€– LLM Connectivity\n"
331
+ status += f"- Provider selected: **{llm_env.get('provider', 'unknown')}**\n"
332
+ status += f"- Anthropic key: {llm_env.get('anthropic_key')}\n"
333
+ status += f"- OpenAI key: {llm_env.get('openai_key')}\n"
334
+ status += f"- OpenRouter key: {llm_env.get('openrouter_key')}\n\n"
335
+
336
+ # Budget visibility
337
+ if stats.budget_limit not in (None, 0):
338
+ remaining = stats.budget_remaining()
339
+ status += f"- **Budget:** ${stats.budget_limit:.2f} (remaining ${remaining:.2f})\n"
340
+ budget_note = stats.budget_status()
341
+ if stats.budget_alert_triggered or (stats.budget_usage_ratio() or 0) >= stats.budget_alert_fraction:
342
+ status += f"> 🚨 {budget_note}\n\n"
343
+ else:
344
+ status += f" - {budget_note}\n\n"
345
+
346
+ # Lab simulation link (GNS3)
347
+ gns3_base = os.getenv("GNS3_SERVER")
348
+ gns3_project = os.getenv("GNS3_PROJECT_NAME", "overgrowth")
349
+ if gns3_base:
350
+ status += "### πŸ§ͺ Lab Simulation\n"
351
+ status += f"- GNS3 API: {gns3_base}\n"
352
+ status += f"- Project: `{gns3_project}`\n"
353
+ status += f"- Web UI: {gns3_base.rstrip('/')}/static/webUi\n\n"
354
+
355
  # Pre-flight validation section
356
  if ready_to_deploy:
357
  status += "### βœ… Pre-flight Validation PASSED\n"
 
396
  status += "- `infra/network_model.yaml` - Source of Truth\n"
397
  status += "- `infra/bill_of_materials.json` - BOM data\n"
398
  status += "- `infra/setup_guide.md` - Deployment guide\n"
399
+
400
+ questions = results.get('questions', [])
401
+ if questions:
402
+ status += "\n### ❓ Clarifying Questions\n"
403
+ for q in questions:
404
+ status += f"- {q}\n"
405
 
406
  # Extract outputs
407
  sot_yaml = results.get('model', {})
 
413
  diagram = results.get('diagrams', {}).get('ascii', 'No diagram available')
414
 
415
  # Also return updated API stats and activity
416
+ updated_stats = monitor.get_stats().format_dashboard(include_heading=False)
417
  updated_activity = monitor.format_activity_feed()
418
 
419
  return status, sot_str, bom_md, setup_md, diagram, updated_stats, updated_activity
 
424
  error += f"\n\n```\n{traceback.format_exc()}\n```"
425
 
426
  # Still update API stats even on error
427
+ updated_stats = monitor.get_stats().format_dashboard(include_heading=False)
428
  updated_activity = monitor.format_activity_feed()
429
 
430
  return error, "", "", "", "", updated_stats, updated_activity
tests/test_api_monitor.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Basic coverage for API monitor bookkeeping and budget alerts."""
2
+
3
+ from agent.api_monitor import monitor
4
+
5
+
6
+ def test_api_monitor_tracks_calls_and_budget_alerts():
7
+ original_limit = monitor.stats.budget_limit
8
+ original_fraction = monitor.stats.budget_alert_fraction
9
+
10
+ monitor.reset()
11
+ monitor.stats.budget_limit = 0.02 # force a low budget for alerting
12
+ monitor.stats.budget_alert_fraction = 0.5
13
+
14
+ call_id = "test-call"
15
+ monitor.start_call(call_id, "llm", "openai", "gpt-4o")
16
+ monitor.complete_call(call_id, success=True, input_tokens=1000, output_tokens=2000)
17
+
18
+ stats = monitor.get_stats()
19
+ assert stats.total_calls == 1
20
+ assert stats.llm_calls == 1
21
+ assert stats.total_tokens == 3000
22
+ assert stats.total_cost > 0
23
+ assert stats.budget_alert_triggered is True
24
+ assert "Budget" in stats.budget_status()
25
+
26
+ feed = monitor.format_activity_feed()
27
+ assert "Budget alert" in feed
28
+
29
+ # Restore original budget settings for other tests/runs
30
+ monitor.stats.budget_limit = original_limit
31
+ monitor.stats.budget_alert_fraction = original_fraction
32
+ monitor.reset()