Spaces:
Running
Running
Graham Paasch
commited on
Commit
Β·
1209812
1
Parent(s):
e667ba8
Improve monitoring UX and deterministic offline outputs
Browse files- API_MONITORING.md +4 -0
- agent/api_monitor.py +102 -44
- agent/hardware_pricing.py +16 -0
- agent/llm_client.py +20 -0
- agent/pipeline_engine.py +126 -15
- app.py +40 -4
- tests/test_api_monitor.py +32 -0
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
| 112 |
-
|
| 113 |
-
|
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 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 |
-
|
| 181 |
-
|
| 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
|
| 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
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
| 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 = (
|
| 220 |
-
output_cost = (
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=[], #
|
| 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 |
-
#
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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()
|