From 2e217deaa1cbe0e56f6015ba1427b146d1d7a627 Mon Sep 17 00:00:00 2001 From: Clemente-H Date: Wed, 17 Jun 2026 11:59:35 -0400 Subject: [PATCH 1/2] feat: add --budget subcommand for hardware-aware model recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `llmfit budget ` which, given a USD purchase budget, lists all matching hardware configurations from a built-in catalog and shows which LLM models would run on each one — using the same fit pipeline as `llmfit fit`. - Add `llmfit-core/src/hardware_catalog.rs`: static catalog of ~16 hardware configs (mini PCs, Apple Silicon, NVIDIA/AMD discrete GPUs) with prices, VRAM, RAM, and CPU specs. `configs_within_budget(max_price)` filters by price. - Add `SystemSpecs::synthetic()` constructor to `hardware.rs` for building simulated specs without real hardware detection. - Add `Budget` subcommand in `llmfit-tui/src/main.rs` with `--limit`, `--min-fit`, and `--json` flags. Supports plain, CSV, and JSON output. Closes #541 (hardware-aware model selection, budget variant) Co-Authored-By: Claude Sonnet 4.6 --- llmfit-core/src/hardware.rs | 23 +++ llmfit-core/src/hardware_catalog.rs | 244 ++++++++++++++++++++++++++++ llmfit-core/src/lib.rs | 1 + llmfit-tui/src/main.rs | 188 +++++++++++++++++++++ 4 files changed, 456 insertions(+) create mode 100644 llmfit-core/src/hardware_catalog.rs diff --git a/llmfit-core/src/hardware.rs b/llmfit-core/src/hardware.rs index c832d924..475ec93f 100644 --- a/llmfit-core/src/hardware.rs +++ b/llmfit-core/src/hardware.rs @@ -1612,6 +1612,29 @@ impl SystemSpecs { self } + /// Build a fully synthetic `SystemSpecs` for a hypothetical hardware + /// configuration (used by `llmfit budget` to simulate catalog hardware). + /// No real hardware detection is performed. + pub fn synthetic(ram_gb: f64, cpu_cores: usize, backend: GpuBackend) -> Self { + let unified_memory = matches!(backend, GpuBackend::Metal); + SystemSpecs { + total_ram_gb: ram_gb, + available_ram_gb: ram_gb * 0.9, + total_cpu_cores: cpu_cores, + cpu_name: "Simulated".to_string(), + has_gpu: false, + gpu_vram_gb: None, + total_gpu_vram_gb: None, + gpu_name: None, + gpu_count: 0, + unified_memory, + backend, + gpus: Vec::new(), + cluster_mode: false, + cluster_node_count: 0, + } + } + pub fn display(&self) { println!("\n=== System Specifications ==="); println!("CPU: {} ({} cores)", self.cpu_name, self.total_cpu_cores); diff --git a/llmfit-core/src/hardware_catalog.rs b/llmfit-core/src/hardware_catalog.rs new file mode 100644 index 00000000..6cf4b567 --- /dev/null +++ b/llmfit-core/src/hardware_catalog.rs @@ -0,0 +1,244 @@ +use crate::hardware::{GpuBackend, GpuInfo, SystemSpecs}; + +/// A hardware configuration from the purchase catalog. +#[derive(Debug, Clone, serde::Serialize)] +pub struct HardwareConfig { + /// Human-readable name (e.g. "RTX 4070 Ti PC") + pub name: &'static str, + /// Approximate price in USD at time of catalog publication + pub price_usd: u32, + /// GPU VRAM in GB (None = CPU-only / integrated GPU) + pub vram_gb: Option, + /// System RAM in GB + pub ram_gb: f64, + /// CPU thread / core count + pub cpu_cores: usize, + /// GPU acceleration backend + pub gpu_backend: GpuBackend, + /// Short note for the user (e.g. "Unified memory", "Dual GPU") + pub notes: &'static str, +} + +impl HardwareConfig { + /// Build a `SystemSpecs` that represents this hardware configuration. + /// The specs are synthetic (not from real hardware detection) and are + /// used to run the same fit pipeline as the regular `llmfit fit` command. + pub fn to_specs(&self) -> SystemSpecs { + let mut specs = SystemSpecs::synthetic(self.ram_gb, self.cpu_cores, self.gpu_backend); + if let Some(vram) = self.vram_gb { + let unified = matches!(self.gpu_backend, GpuBackend::Metal); + specs.gpus.push(GpuInfo { + name: self.name.to_string(), + vram_gb: Some(vram), + backend: self.gpu_backend, + count: 1, + unified_memory: unified, + }); + specs.has_gpu = true; + specs.gpu_vram_gb = Some(vram); + specs.total_gpu_vram_gb = Some(vram); + specs.gpu_name = Some(self.name.to_string()); + specs.gpu_count = 1; + specs.unified_memory = unified; + } + specs + } +} + +/// The built-in hardware catalog. +/// Prices are approximate USD street prices as of mid-2025. Add new entries +/// in ascending price order so `configs_within_budget` preserves that order. +pub static CATALOG: &[HardwareConfig] = &[ + // ── CPU-only / mini PCs ──────────────────────────────────────────────── + HardwareConfig { + name: "Beelink SEi12 Mini PC", + price_usd: 200, + vram_gb: None, + ram_gb: 16.0, + cpu_cores: 8, + gpu_backend: GpuBackend::CpuX86, + notes: "Compact x86 mini PC, CPU inference only", + }, + HardwareConfig { + name: "Beelink EQ12 Pro (32 GB)", + price_usd: 280, + vram_gb: None, + ram_gb: 32.0, + cpu_cores: 8, + gpu_backend: GpuBackend::CpuX86, + notes: "32 GB RAM, good for mid-size CPU-only models", + }, + HardwareConfig { + name: "Intel NUC 13 Pro (64 GB)", + price_usd: 420, + vram_gb: None, + ram_gb: 64.0, + cpu_cores: 12, + gpu_backend: GpuBackend::CpuX86, + notes: "High-RAM NUC for large CPU-offload models", + }, + // ── Apple Silicon ────────────────────────────────────────────────────── + HardwareConfig { + name: "Mac mini M4 (16 GB)", + price_usd: 599, + vram_gb: Some(16.0), + ram_gb: 16.0, + cpu_cores: 10, + gpu_backend: GpuBackend::Metal, + notes: "Unified memory; GPU+CPU share the same 16 GB pool", + }, + HardwareConfig { + name: "Mac mini M4 Pro (24 GB)", + price_usd: 799, + vram_gb: Some(24.0), + ram_gb: 24.0, + cpu_cores: 14, + gpu_backend: GpuBackend::Metal, + notes: "Unified memory; 24 GB pool, excellent perf/watt", + }, + HardwareConfig { + name: "Mac mini M4 Pro (48 GB)", + price_usd: 999, + vram_gb: Some(48.0), + ram_gb: 48.0, + cpu_cores: 14, + gpu_backend: GpuBackend::Metal, + notes: "Unified memory; fits 70B models comfortably", + }, + HardwareConfig { + name: "MacBook Pro M4 Max (128 GB)", + price_usd: 2499, + vram_gb: Some(128.0), + ram_gb: 128.0, + cpu_cores: 16, + gpu_backend: GpuBackend::Metal, + notes: "Unified memory; runs virtually any open model", + }, + // ── NVIDIA GPUs (discrete, paired with ~32 GB system RAM) ───────────── + HardwareConfig { + name: "RTX 3060 (12 GB) PC", + price_usd: 400, + vram_gb: Some(12.0), + ram_gb: 32.0, + cpu_cores: 8, + gpu_backend: GpuBackend::Cuda, + notes: "Entry CUDA GPU; good for 7B–13B models", + }, + HardwareConfig { + name: "RTX 4060 Ti (16 GB) PC", + price_usd: 500, + vram_gb: Some(16.0), + ram_gb: 32.0, + cpu_cores: 8, + gpu_backend: GpuBackend::Cuda, + notes: "16 GB VRAM at mid-range price", + }, + HardwareConfig { + name: "RTX 3090 (24 GB) PC", + price_usd: 700, + vram_gb: Some(24.0), + ram_gb: 32.0, + cpu_cores: 12, + gpu_backend: GpuBackend::Cuda, + notes: "Prosumer GPU; handles most 30B–34B models", + }, + HardwareConfig { + name: "RTX 4090 (24 GB) PC", + price_usd: 1800, + vram_gb: Some(24.0), + ram_gb: 64.0, + cpu_cores: 16, + gpu_backend: GpuBackend::Cuda, + notes: "Fastest consumer CUDA GPU available", + }, + HardwareConfig { + name: "2× RTX 3090 (48 GB total) PC", + price_usd: 1400, + vram_gb: Some(48.0), + ram_gb: 64.0, + cpu_cores: 16, + gpu_backend: GpuBackend::Cuda, + notes: "Dual-GPU tensor parallel; 48 GB VRAM total", + }, + // ── AMD GPUs ─────────────────────────────────────────────────────────── + HardwareConfig { + name: "RX 7800 XT (16 GB) PC", + price_usd: 430, + vram_gb: Some(16.0), + ram_gb: 32.0, + cpu_cores: 8, + gpu_backend: GpuBackend::Rocm, + notes: "16 GB at mid price; ROCm support on Linux", + }, + HardwareConfig { + name: "RX 7900 XTX (24 GB) PC", + price_usd: 750, + vram_gb: Some(24.0), + ram_gb: 32.0, + cpu_cores: 12, + gpu_backend: GpuBackend::Rocm, + notes: "Top AMD consumer GPU; ROCm on Linux", + }, + // ── NVIDIA workstation / server ──────────────────────────────────────── + HardwareConfig { + name: "NVIDIA RTX 4000 Ada (20 GB) Workstation", + price_usd: 1250, + vram_gb: Some(20.0), + ram_gb: 64.0, + cpu_cores: 16, + gpu_backend: GpuBackend::Cuda, + notes: "Professional GPU; ECC memory, great driver support", + }, + HardwareConfig { + name: "NVIDIA L4 (24 GB) Cloud Instance", + price_usd: 2000, + vram_gb: Some(24.0), + ram_gb: 64.0, + cpu_cores: 24, + gpu_backend: GpuBackend::Cuda, + notes: "Data-center GPU; approximate monthly cost as purchase price", + }, +]; + +/// Returns all catalog entries whose price is at or below `max_price_usd`. +pub fn configs_within_budget(max_price_usd: u32) -> Vec<&'static HardwareConfig> { + CATALOG + .iter() + .filter(|c| c.price_usd <= max_price_usd) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_is_non_empty() { + assert!(!CATALOG.is_empty()); + } + + #[test] + fn budget_zero_returns_nothing() { + assert!(configs_within_budget(0).is_empty()); + } + + #[test] + fn budget_filters_correctly() { + let results = configs_within_budget(600); + assert!(results.iter().all(|c| c.price_usd <= 600)); + assert!(results.iter().any(|c| c.price_usd <= 600)); + } + + #[test] + fn budget_max_returns_all() { + let all = configs_within_budget(u32::MAX); + assert_eq!(all.len(), CATALOG.len()); + } + + #[test] + fn to_specs_does_not_panic() { + for config in CATALOG { + let _ = config.to_specs(); + } + } +} diff --git a/llmfit-core/src/lib.rs b/llmfit-core/src/lib.rs index 34bfd7c2..3c231deb 100644 --- a/llmfit-core/src/lib.rs +++ b/llmfit-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod bench; pub mod benchmarks; pub mod fit; pub mod hardware; +pub mod hardware_catalog; pub mod models; pub mod plan; pub mod providers; diff --git a/llmfit-tui/src/main.rs b/llmfit-tui/src/main.rs index 480a4bb5..56c28f48 100644 --- a/llmfit-tui/src/main.rs +++ b/llmfit-tui/src/main.rs @@ -695,6 +695,48 @@ AGENT USAGE: nats_url: String, }, + /// Recommend hardware configurations within a purchase budget + #[command(long_about = "\ +Recommend hardware configurations within a purchase budget. + +Given a maximum spend in USD, lists all catalog configurations at or below +that price and shows which LLM models would run on each one — using the same +fit analysis as `llmfit fit`. + +PRECONDITIONS: + None — no hardware detection is performed; all specs are simulated. + +SIDE EFFECTS: + None — read-only. + +EXIT CODES: + 0 Success (even if no configurations found below budget) + 1 Internal error + +AGENT USAGE: + llmfit budget 800 --json + llmfit budget 1500 --limit 5 --min-fit good + + JSON output fields: { configurations: [{ name, price_usd, vram_gb, + ram_gb, cpu_cores, gpu_backend, notes, + models: [{ name, fit_level, score, estimated_tps, ... }] }] }")] + Budget { + /// Maximum budget in USD (e.g. 800) + max_price: u32, + + /// Limit number of LLM models shown per hardware configuration + #[arg(short = 'n', long, default_value = "5")] + limit: usize, + + /// Minimum fit level to show: perfect, good, marginal (default: marginal) + #[arg(long, default_value = "marginal")] + min_fit: String, + + /// Output results as JSON + #[arg(long)] + json: bool, + }, + /// Benchmark inference performance against running providers Bench { /// Model name to benchmark (auto-detects provider if omitted) @@ -2735,6 +2777,15 @@ fn main() { } } + Commands::Budget { + max_price, + limit, + min_fit, + json, + } => { + run_budget(max_price, limit, &min_fit, json || cli.json, cli.csv); + } + Commands::Bench { model, provider, @@ -2797,6 +2848,143 @@ fn main() { } } +fn run_budget(max_price: u32, limit: usize, min_fit: &str, json: bool, csv: bool) { + use llmfit_core::fit::{FitLevel, rank_models_by_fit_opts_col}; + use llmfit_core::hardware_catalog::configs_within_budget; + use llmfit_core::models::ModelDatabase; + + let configs = configs_within_budget(max_price); + + if configs.is_empty() { + if json { + println!("{{\"configurations\":[]}}"); + } else { + println!("No hardware configurations found within a ${} budget.", max_price); + println!("The cheapest option in the catalog is ${}.", + llmfit_core::hardware_catalog::CATALOG + .iter() + .map(|c| c.price_usd) + .min() + .unwrap_or(0) + ); + } + return; + } + + let min_level = match min_fit.to_lowercase().as_str() { + "perfect" => FitLevel::Perfect, + "good" => FitLevel::Good, + _ => FitLevel::Marginal, + }; + + let db = ModelDatabase::new(); + let installed = llmfit_core::analysis::InstalledIndex::detect_all(); + + if json { + let mut out_configs = Vec::new(); + for config in &configs { + let specs = config.to_specs(); + let mut fits = + llmfit_core::analysis::build_model_fits(&db, &specs, &installed, None, None); + fits.retain(|f| match (min_level, f.fit_level) { + (FitLevel::Marginal, FitLevel::TooTight) => false, + (FitLevel::Good, FitLevel::TooTight | FitLevel::Marginal) => false, + (FitLevel::Perfect, FitLevel::Perfect) => true, + (FitLevel::Perfect, _) => false, + _ => true, + }); + fits = rank_models_by_fit_opts_col(fits, false, llmfit_core::fit::SortColumn::Score); + fits.truncate(limit); + + let models_json: Vec<_> = fits + .iter() + .map(|f| { + serde_json::json!({ + "name": f.model.name, + "provider": f.model.provider, + "parameter_count": f.model.parameter_count, + "fit_level": format!("{:?}", f.fit_level), + "run_mode": format!("{:?}", f.run_mode), + "score": f.score, + "estimated_tps": f.estimated_tps, + "memory_required_gb": f.memory_required_gb, + "utilization_pct": f.utilization_pct, + "best_quant": f.best_quant, + "runtime": format!("{:?}", f.runtime), + }) + }) + .collect(); + + out_configs.push(serde_json::json!({ + "name": config.name, + "price_usd": config.price_usd, + "vram_gb": config.vram_gb, + "ram_gb": config.ram_gb, + "cpu_cores": config.cpu_cores, + "gpu_backend": format!("{}", config.gpu_backend.label()), + "notes": config.notes, + "models": models_json, + })); + } + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "budget_usd": max_price, + "configurations": out_configs, + })) + .unwrap() + ); + return; + } + + println!("\n=== Hardware Budget: up to ${} ===", max_price); + println!("{} configuration(s) found\n", configs.len()); + + for config in &configs { + let specs = config.to_specs(); + let mut fits = + llmfit_core::analysis::build_model_fits(&db, &specs, &installed, None, None); + fits.retain(|f| match (min_level, f.fit_level) { + (FitLevel::Marginal, FitLevel::TooTight) => false, + (FitLevel::Good, FitLevel::TooTight | FitLevel::Marginal) => false, + (FitLevel::Perfect, FitLevel::Perfect) => true, + (FitLevel::Perfect, _) => false, + _ => true, + }); + fits = rank_models_by_fit_opts_col(fits, false, llmfit_core::fit::SortColumn::Score); + fits.truncate(limit); + + let vram_str = config + .vram_gb + .map(|v| format!("{:.0} GB VRAM", v)) + .unwrap_or_else(|| "CPU-only".to_string()); + + println!( + "┌─ {} — ${} │ {} │ {:.0} GB RAM │ {} cores │ {}", + config.name, + config.price_usd, + vram_str, + config.ram_gb, + config.cpu_cores, + config.gpu_backend.label(), + ); + if !config.notes.is_empty() { + println!("│ Note: {}", config.notes); + } + + if fits.is_empty() { + println!("│ (no models match the '{}' fit filter)", min_fit); + } else { + if csv { + display::display_csv_fits(&fits); + } else { + display::display_model_fits(&fits); + } + } + println!(); + } +} + #[cfg(test)] mod tests { use super::*; From 2903b15496c846ab4f71197f09b8b546c77fa037 Mon Sep 17 00:00:00 2001 From: Clemente-H Date: Wed, 17 Jun 2026 14:44:06 -0400 Subject: [PATCH 2/2] feat: add Budget Advisor to web dashboard - Add GET /api/v1/budget?max_price=N endpoint in serve_api.rs - Add fetchBudget() in api.js - Add BudgetPanel.jsx component (form + per-config model tables) - Wire up in App.jsx as an isolated BudgetSection component The panel uses existing CSS classes (simulation-panel, table-wrap, fitClass) so it matches the dashboard's visual style. Co-Authored-By: Claude Sonnet 4.6 --- llmfit-tui/src/serve_api.rs | 77 +++++++++ llmfit-web/src/App.jsx | 23 +++ llmfit-web/src/api.js | 10 ++ llmfit-web/src/components/BudgetPanel.jsx | 196 ++++++++++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 llmfit-web/src/components/BudgetPanel.jsx diff --git a/llmfit-tui/src/serve_api.rs b/llmfit-tui/src/serve_api.rs index 27519a13..37534876 100644 --- a/llmfit-tui/src/serve_api.rs +++ b/llmfit-tui/src/serve_api.rs @@ -216,6 +216,7 @@ fn build_router(state: Arc) -> Router { .route("/api/v1/download", post(start_download)) .route("/api/v1/download/{id}/status", get(download_status)) .route("/api/v1/plan", post(plan_estimate)) + .route("/api/v1/budget", get(budget)) .route("/{*path}", get(spa_fallback)) .with_state(state) } @@ -955,6 +956,82 @@ fn system_json(specs: &SystemSpecs) -> serde_json::Value { serve_shared::system_json(specs) } +#[derive(Deserialize)] +struct BudgetQuery { + max_price: Option, + #[serde(default = "default_budget_limit")] + limit: usize, + #[serde(default = "default_min_fit")] + min_fit: String, +} + +fn default_budget_limit() -> usize { + 5 +} + +fn default_min_fit() -> String { + "marginal".to_string() +} + +async fn budget( + State(state): State>, + Query(query): Query, +) -> ApiResult> { + use llmfit_core::hardware_catalog::configs_within_budget; + + let max_price = query.max_price.unwrap_or(0); + let configs = configs_within_budget(max_price); + + let min_level = match query.min_fit.to_lowercase().as_str() { + "perfect" => FitLevel::Perfect, + "good" => FitLevel::Good, + _ => FitLevel::Marginal, + }; + + let out: Vec = configs + .iter() + .map(|config| { + let specs = config.to_specs(); + let is_apple_silicon = + specs.backend == GpuBackend::Metal && specs.unified_memory; + let mut fits: Vec = state + .models + .iter() + .filter(|m| backend_compatible(m, &specs)) + .map(|m| ModelFit::analyze_with_context_limit(m, &specs, state.context_limit)) + .collect(); + if !is_apple_silicon { + fits.retain(|f| !f.model.is_mlx_only()); + } + fits.retain(|f| match (min_level, f.fit_level) { + (FitLevel::Marginal, FitLevel::TooTight) => false, + (FitLevel::Good, FitLevel::TooTight | FitLevel::Marginal) => false, + (FitLevel::Perfect, FitLevel::Perfect) => true, + (FitLevel::Perfect, _) => false, + _ => true, + }); + fits = rank_models_by_fit_opts_col(fits, false, SortColumn::Score); + fits.truncate(query.limit); + + serde_json::json!({ + "name": config.name, + "price_usd": config.price_usd, + "vram_gb": config.vram_gb, + "ram_gb": config.ram_gb, + "cpu_cores": config.cpu_cores, + "gpu_backend": config.gpu_backend.label(), + "notes": config.notes, + "models": fits.iter().map(fit_to_json).collect::>(), + }) + }) + .collect(); + + Ok(Json(serde_json::json!({ + "budget_usd": max_price, + "configurations": out, + }))) +} + fn fit_to_json(fit: &ModelFit) -> serde_json::Value { serve_shared::fit_to_json(fit) } diff --git a/llmfit-web/src/App.jsx b/llmfit-web/src/App.jsx index b8b765ee..b6fb1f11 100644 --- a/llmfit-web/src/App.jsx +++ b/llmfit-web/src/App.jsx @@ -10,6 +10,7 @@ import FilterBar from './components/FilterBar'; import ModelTable from './components/ModelTable'; import DetailPanel from './components/DetailPanel'; import ComparePanel from './components/ComparePanel'; +import BudgetPanel from './components/BudgetPanel'; function DataLoader() { useModels(); @@ -69,6 +70,27 @@ function ModelsSection() { ); } +function BudgetSection() { + const [showBudget, setShowBudget] = useState(false); + return ( +
+ {!showBudget ? ( +
+ +
+ ) : ( + setShowBudget(false)} /> + )} +
+ ); +} + export default function App() { return ( @@ -81,6 +103,7 @@ export default function App() {
+ diff --git a/llmfit-web/src/api.js b/llmfit-web/src/api.js index 5819f311..93525455 100644 --- a/llmfit-web/src/api.js +++ b/llmfit-web/src/api.js @@ -185,3 +185,13 @@ export async function fetchPlanEstimate( }); return parseJsonOrThrow(response); } + +export async function fetchBudget(maxPrice, limit = 5, minFit = 'marginal', signal) { + const params = new URLSearchParams({ + max_price: maxPrice, + limit, + min_fit: minFit, + }); + const response = await fetch(`/api/v1/budget?${params}`, { signal }); + return parseJsonOrThrow(response); +} diff --git a/llmfit-web/src/components/BudgetPanel.jsx b/llmfit-web/src/components/BudgetPanel.jsx new file mode 100644 index 00000000..7f0df8cc --- /dev/null +++ b/llmfit-web/src/components/BudgetPanel.jsx @@ -0,0 +1,196 @@ +import { useState, useRef } from 'react'; +import { fetchBudget } from '../api'; +import { round, fitClass } from '../utils'; + +function ConfigCard({ config }) { + const vramLabel = config.vram_gb != null + ? `${config.vram_gb} GB VRAM` + : 'CPU-only'; + + return ( +
+
+
+

{config.name}

+

{config.notes}

+
+
+ ${config.price_usd} + {vramLabel} + {round(config.ram_gb, 0)} GB RAM + {config.cpu_cores} cores + {config.gpu_backend} +
+
+ + {config.models.length === 0 ? ( +

+ No models match the current fit filter. +

+ ) : ( +
+ + + + + + + + + + + + + + + {config.models.map((m, i) => ( + + + + + + + + + + + ))} + +
ModelSizeFitScoretok/sMemQuantRuntime
+ + {m.name.length > 48 ? m.name.slice(0, 46) + '…' : m.name} + + {m.parameter_count} + + {m.fit_label ?? m.fit_level} + + {Math.round(m.score)}{round(m.estimated_tps, 1)}{round(m.memory_required_gb, 1)} GB{m.best_quant}{m.runtime_label ?? m.runtime}
+
+ )} +
+ ); +} + +function BudgetForm({ onResult }) { + const [budget, setBudget] = useState('800'); + const [limit, setLimit] = useState('5'); + const [minFit, setMinFit] = useState('marginal'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + + async function handleSubmit(e) { + e.preventDefault(); + if (abortRef.current) abortRef.current.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + setLoading(true); + setError(null); + + try { + const data = await fetchBudget(Number(budget), Number(limit), minFit, ctrl.signal); + onResult(data); + } catch (err) { + if (err.name !== 'AbortError') setError(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

Configure

+

Find hardware within your purchase budget.

+
+
+ +
+
+ +
+ + + +
+ + {error && ( +
+ {error} +
+ )} +
+ ); +} + +export default function BudgetPanel({ onClose }) { + const [result, setResult] = useState(null); + + return ( +
+
+

Hardware Budget Advisor

+
+ {result && ( + + {result.configurations.length} config{result.configurations.length !== 1 ? 's' : ''} within ${result.budget_usd} + + )} + +
+
+ +

+ Enter a purchase budget and see which hardware configurations fit — and which LLM models each one can run. +

+ + + + {result && ( +
+ {result.configurations.length === 0 ? ( +

No configurations found within a ${result.budget_usd} budget.

+ ) : ( + result.configurations.map((config, i) => ( + + )) + )} +
+ )} +
+ ); +}