From 46b4ea677f215a562d39d39a8b1ee57fc5340027 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Dec 2025 04:58:42 +0000 Subject: [PATCH 1/6] feat: implement server-grouped model list with health monitoring and resource conflict detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend changes: - Add comprehensive schemas for model health, size, server info, and selection analysis - Implement LiteLLM client methods for /v1/model/info and /health/latest endpoints - Create model processor service with intelligent grouping, size parsing, and provider inference - Add new API endpoints: /models/by-server and /models/analyze-selection - Support for duplicate detection across multiple Ollama servers Frontend changes: - Create ServerGroup component to display models grouped by Ollama host - Enhance ModelCard with health status, size badges, and performance metrics - Add SelectionAnalysis component with real-time conflict warnings and recommendations - Update useLLMModels hook to support server grouping and selection analysis - Create utility functions for model selection and formatting - Add comprehensive CSS styling with color-coded health/size indicators Key features: - Group models by Ollama server (10.0.1.14, 10.0.1.150, etc.) - Display health status with response times from LiteLLM health checks - Show model sizes (tiny/small/medium/large) with memory estimates - Detect and warn about resource conflicts (multiple large models on same host) - Identify duplicate models across servers with performance recommendations - Resolve :latest tags to actual versions (e.g., llama3.2:latest → 3b) - Backward compatible with simple flat model list view This helps users avoid memory contention by preventing selection of multiple large models on the same Ollama server, improving council debate performance. --- backend/api/config.py | 44 +- backend/models/schemas.py | 90 +++++ backend/services/litellm_client.py | 28 ++ backend/services/model_processor.py | 377 ++++++++++++++++++ frontend/src/App.jsx | 15 +- frontend/src/components/ModelCard.jsx | 112 ++++++ frontend/src/components/QueryInput.jsx | 170 +++++--- frontend/src/components/SelectionAnalysis.jsx | 89 +++++ frontend/src/components/ServerGroup.jsx | 84 ++++ frontend/src/hooks/useLLMModels.js | 81 +++- frontend/src/styles/index.css | 269 +++++++++++++ frontend/src/utils/modelSelection.js | 87 ++++ 12 files changed, 1373 insertions(+), 73 deletions(-) create mode 100644 backend/services/model_processor.py create mode 100644 frontend/src/components/ModelCard.jsx create mode 100644 frontend/src/components/SelectionAnalysis.jsx create mode 100644 frontend/src/components/ServerGroup.jsx create mode 100644 frontend/src/utils/modelSelection.js diff --git a/backend/api/config.py b/backend/api/config.py index c3e6815..4d033f7 100644 --- a/backend/api/config.py +++ b/backend/api/config.py @@ -2,11 +2,15 @@ from typing import List from pydantic import BaseModel -from models.schemas import ModelInfo +from models.schemas import ModelInfo, ServerGroup, SelectionAnalysis from services.litellm_client import LiteLLMClient +from services.model_processor import process_models_with_health, analyze_selection router = APIRouter(prefix="/api/config", tags=["config"]) +# Global cache for server groups (refresh periodically) +_server_groups_cache = None + class TestModelRequest(BaseModel): model_id: str @@ -39,3 +43,41 @@ async def test_model(request: TestModelRequest): model_id=request.model_id, available=available, ) + + +@router.get("/models/by-server", response_model=List[ServerGroup]) +async def get_models_by_server(): + """ + Get models grouped by Ollama server with health status and size info + """ + global _server_groups_cache + + # Use cache if available (TODO: add expiration logic) + if _server_groups_cache is not None: + return _server_groups_cache + + client = LiteLLMClient() + raw_data = await client.get_model_info() + + server_groups = await process_models_with_health(raw_data) + _server_groups_cache = server_groups + + return server_groups + + +class AnalyzeSelectionRequest(BaseModel): + model_ids: List[str] + + +@router.post("/models/analyze-selection", response_model=SelectionAnalysis) +async def analyze_model_selection(request: AnalyzeSelectionRequest): + """ + Analyze selected models for resource conflicts and provide recommendations + """ + # Get current server groups + server_groups = await get_models_by_server() + + # Analyze selection + analysis = await analyze_selection(request.model_ids, server_groups) + + return analysis diff --git a/backend/models/schemas.py b/backend/models/schemas.py index 1cd57fb..56a44ef 100644 --- a/backend/models/schemas.py +++ b/backend/models/schemas.py @@ -16,6 +16,96 @@ class ModelInfo(BaseModel): provider: Optional[str] = None +class ModelHealth(BaseModel): + status: str # "healthy", "unhealthy", "unknown" + healthy_count: int + unhealthy_count: int + response_time_ms: float + last_checked: Optional[datetime] + error_message: Optional[str] = None + + +class ModelSize(BaseModel): + parameters: str # "70B", "32B", "3B", etc. + parameters_billions: float # Numeric for sorting + estimated_memory_gb: int # Rough estimate for warnings + size_tier: str # "tiny" (<2B), "small" (2-10B), "medium" (10-30B), "large" (30B+) + + +class OllamaServerInfo(BaseModel): + api_base: str + host: str + tpm: int + rpm: int + performance_tier: str + health_status: str = "unknown" + model_count: int = 0 + selected_model_count: int = 0 + total_selected_memory_gb: int = 0 + + +class ModelInfoDetailed(BaseModel): + id: str + display_name: str + base_model: str + actual_tag: Optional[str] = None + is_latest_alias: bool = False + resolves_to: Optional[str] = None + + # Server information + api_base: str + server_host: str + server_tpm: int + server_rpm: int + + # Metadata + provider: str + model_family: str + model_category: str + + # Model specs + size: ModelSize + health: Optional[ModelHealth] = None + max_tokens: int = 4096 + supports_function_calling: bool = False + + # Duplication info + is_duplicate: bool = False + better_server: Optional[str] = None + duplicate_count: int = 1 + + +class ServerGroup(BaseModel): + server: OllamaServerInfo + models: List[ModelInfoDetailed] + warnings: List[str] = [] + recommendations: List[str] = [] + + +class SelectionWarning(BaseModel): + severity: str # "high", "medium", "info" + server: str + message: str + models: Optional[List[str]] = None + estimated_total_memory: Optional[str] = None + + +class SelectionRecommendation(BaseModel): + type: str + model: str + from_server: str + to_server: str + reason: str + + +class SelectionAnalysis(BaseModel): + warnings: List[SelectionWarning] + recommendations: List[SelectionRecommendation] + total_models_selected: int + servers_used: int + diversity_score: int + + class ResponseSchema(BaseModel): id: int decision_id: int diff --git a/backend/services/litellm_client.py b/backend/services/litellm_client.py index 62d9777..ec817f1 100644 --- a/backend/services/litellm_client.py +++ b/backend/services/litellm_client.py @@ -52,6 +52,34 @@ async def get_available_models(self) -> List[Dict[str, any]]: print(f"Error fetching models: {e}") return [] + async def get_model_info(self) -> dict: + """Fetch detailed model info from /v1/model/info endpoint""" + try: + async with httpx.AsyncClient(timeout=self.timeout, verify=False) as client: + response = await client.get( + f"{self.base_url}/v1/model/info", + headers=self._get_headers() + ) + response.raise_for_status() + return response.json() + except Exception as e: + print(f"Error fetching model info: {e}") + return {"data": []} + + async def get_health_status(self) -> dict: + """Fetch health status from /health/latest endpoint""" + try: + async with httpx.AsyncClient(timeout=self.timeout, verify=False) as client: + response = await client.get( + f"{self.base_url}/health/latest", + headers=self._get_headers() + ) + response.raise_for_status() + return response.json() + except Exception as e: + print(f"Error fetching health status: {e}") + return {"latest_health_checks": {}, "total_models": 0} + async def test_model(self, model_id: str) -> bool: """Test if a model is available and responding""" try: diff --git a/backend/services/model_processor.py b/backend/services/model_processor.py new file mode 100644 index 0000000..44eb34f --- /dev/null +++ b/backend/services/model_processor.py @@ -0,0 +1,377 @@ +""" +Model processing service for organizing and analyzing LiteLLM models +""" +import re +from typing import Dict, List, Tuple +from datetime import datetime + +from models.schemas import ( + ModelInfoDetailed, + ModelHealth, + ModelSize, + OllamaServerInfo, + ServerGroup, + SelectionWarning, + SelectionRecommendation, + SelectionAnalysis, +) +from services.litellm_client import LiteLLMClient + + +def parse_model_name(model_name: str, litellm_model: str) -> Dict[str, any]: + """ + Parse model_name and litellm_params.model to extract metadata + + Examples: + - model_name="gpt-oss:latest", litellm_model="ollama_chat/gpt-oss:20b" + → base="gpt-oss", tag="latest", actual_tag="20b", is_latest=True + + - model_name="llama3.2:3b", litellm_model="ollama_chat/llama3.2:3b" + → base="llama3.2", tag="3b", actual_tag="3b", is_latest=False + """ + # Split model_name on ":" + parts = model_name.split(":") + base = parts[0] + tag = parts[1] if len(parts) > 1 else None + + # Extract actual tag from litellm_model + # "ollama_chat/gpt-oss:20b" → "20b" + litellm_parts = litellm_model.split("/")[-1].split(":") + actual_tag = litellm_parts[1] if len(litellm_parts) > 1 else None + + return { + "base_model": base, + "tag": tag, + "actual_tag": actual_tag, + "is_latest_alias": tag == "latest", + "display_name": model_name + } + + +def parse_model_size(model_name: str, litellm_model: str) -> ModelSize: + """ + Extract model size from name + + Examples: + - llama3.3:70b → 70B + - deepseek-r1:32b → 32B + - llama3.2:3b → 3B + - tinyllama:1.1b → 1.1B + """ + # Try to find pattern like "70b", "32b", "3b", "1.1b" + patterns = [model_name, litellm_model] + for pattern in patterns: + match = re.search(r'(\d+\.?\d*)b', pattern.lower()) + if match: + size_b = float(match.group(1)) + + # Determine tier + if size_b < 2: + tier = "tiny" + elif size_b < 10: + tier = "small" + elif size_b < 30: + tier = "medium" + else: + tier = "large" + + # Estimate memory (rough approximation) + # Rule of thumb: ~1.2-1.5 GB per billion parameters for float16 + memory_gb = int(size_b * 1.5) + + return ModelSize( + parameters=f"{size_b}B", + parameters_billions=size_b, + estimated_memory_gb=memory_gb, + size_tier=tier + ) + + # Default if size not found in name + return ModelSize( + parameters="Unknown", + parameters_billions=0, + estimated_memory_gb=4, + size_tier="small" + ) + + +def infer_provider(model_name: str) -> str: + """Infer provider from model name""" + lower = model_name.lower() + + if "llama" in lower: + return "Meta" + elif "mistral" in lower or "codestral" in lower: + return "Mistral AI" + elif "gemma" in lower or "codegemma" in lower: + return "Google" + elif "deepseek" in lower: + return "DeepSeek" + elif "qwen" in lower: + return "Alibaba (Qwen)" + elif "phi" in lower: + return "Microsoft" + elif "granite" in lower: + return "IBM" + elif "gpt-oss" in lower: + return "GPT-OSS" + else: + return "Other" + + +def infer_category(model_name: str) -> str: + """Infer model category from name""" + lower = model_name.lower() + + if "coder" in lower or "code" in lower: + return "code" + elif "vision" in lower: + return "vision" + elif "r1" in lower or "reasoning" in lower: + return "reasoning" + else: + return "chat" + + +def get_performance_tier(tpm: int) -> str: + """Determine performance tier based on TPM""" + if tpm >= 50000: + return "high" + elif tpm >= 20000: + return "medium" + else: + return "low" + + +async def process_models_with_health(raw_data: dict) -> List[ServerGroup]: + """ + Process models with health checks and group by server + """ + client = LiteLLMClient() + + # Fetch health data + health_data = await client.get_health_status() + health_map = health_data.get("latest_health_checks", {}) + + # Step 1: Group models by server + server_map = {} # Key: api_base, Value: {server_info, models} + + for item in raw_data["data"]: + model_name = item["model_name"] + litellm_params = item["litellm_params"] + api_base = litellm_params["api_base"] + + # Initialize server if first time seeing it + if api_base not in server_map: + host = api_base.split("//")[1].split(":")[0] + server_map[api_base] = { + "server_info": OllamaServerInfo( + api_base=api_base, + host=host, + tpm=litellm_params.get("tpm", 0), + rpm=litellm_params.get("rpm", 0), + performance_tier=get_performance_tier(litellm_params.get("tpm", 0)), + health_status="unknown", + model_count=0 + ), + "models": [] + } + + # Parse model info + parsed = parse_model_name(model_name, litellm_params["model"]) + size = parse_model_size(model_name, litellm_params["model"]) + + # Get health info for this model + health = None + if model_name in health_map: + h = health_map[model_name] + health = ModelHealth( + status=h.get("status", "unknown"), + healthy_count=h.get("healthy_count", 0), + unhealthy_count=h.get("unhealthy_count", 0), + response_time_ms=h.get("response_time_ms", 0), + last_checked=h.get("checked_at"), + error_message=h.get("error_message") + ) + + # Extract host from api_base + host = api_base.split("//")[1].split(":")[0] + + # Infer model family from base_model + base_parts = parsed["base_model"].split(".")[0].split("-")[0] + model_family = base_parts.capitalize() + + # Create model info + model = ModelInfoDetailed( + id=f"{model_name}_{host}", + display_name=model_name, + base_model=parsed["base_model"], + actual_tag=parsed["actual_tag"], + is_latest_alias=parsed["is_latest_alias"], + resolves_to=f"{parsed['base_model']}:{parsed['actual_tag']}" if parsed["is_latest_alias"] and parsed["actual_tag"] else None, + api_base=api_base, + server_host=host, + server_tpm=litellm_params.get("tpm", 0), + server_rpm=litellm_params.get("rpm", 0), + provider=infer_provider(parsed["base_model"]), + model_family=model_family, + model_category=infer_category(model_name), + size=size, + health=health, + max_tokens=litellm_params.get("max_tokens", 4096), + supports_function_calling=litellm_params.get("supports_function_calling", False) + ) + + server_map[api_base]["models"].append(model) + server_map[api_base]["server_info"].model_count += 1 + + # Step 2: Detect duplicates and find better servers + all_models = [] + for server_data in server_map.values(): + all_models.extend(server_data["models"]) + + # Group by base_model:actual_tag to find duplicates + model_groups = {} + for model in all_models: + key = f"{model.base_model}:{model.actual_tag}" + if key not in model_groups: + model_groups[key] = [] + model_groups[key].append(model) + + # Mark duplicates and set better_server + for key, models in model_groups.items(): + if len(models) > 1: + # Sort by TPM (descending) to find best server + models_sorted = sorted( + models, + key=lambda m: m.server_tpm, + reverse=True + ) + best_server = models_sorted[0].api_base + + for model in models: + model.is_duplicate = True + model.duplicate_count = len(models) + if model.api_base != best_server: + model.better_server = best_server + + # Step 3: Create ServerGroup list + result = [] + for api_base in sorted(server_map.keys()): + server_data = server_map[api_base] + models = sorted(server_data["models"], key=lambda m: m.display_name.lower()) + + result.append(ServerGroup( + server=server_data["server_info"], + models=models, + warnings=[], + recommendations=[] + )) + + return result + + +async def analyze_selection(selected_model_ids: List[str], all_server_groups: List[ServerGroup]) -> SelectionAnalysis: + """ + Analyze selected models for resource conflicts + + Returns warnings and recommendations + """ + # Build a map of model_id -> model + model_map = {} + for group in all_server_groups: + for model in group.models: + model_map[model.id] = model + + # Get selected models + selected_models = [model_map.get(mid) for mid in selected_model_ids if mid in model_map] + selected_models = [m for m in selected_models if m is not None] + + # Group by server + by_server = {} + for model in selected_models: + if model.api_base not in by_server: + by_server[model.api_base] = [] + by_server[model.api_base].append(model) + + warnings = [] + recommendations = [] + + # Check each server for resource conflicts + for api_base, server_models in by_server.items(): + total_memory = sum(m.size.estimated_memory_gb for m in server_models) + large_models = [m for m in server_models if m.size.size_tier == "large"] + medium_models = [m for m in server_models if m.size.size_tier == "medium"] + + # Warning: Multiple large models on same server + if len(large_models) > 1: + warnings.append(SelectionWarning( + severity="high", + server=api_base, + message=f"⚠️ {len(large_models)} large models selected on {api_base.split('//')[1]}. Expect significant delays during model swapping.", + models=[m.display_name for m in large_models], + estimated_total_memory=f"{total_memory}GB" + )) + + # Recommend moving one to another server + for model in large_models[1:]: # Skip first one + if model.is_duplicate and model.better_server: + recommendations.append(SelectionRecommendation( + type="move_to_better_server", + model=model.display_name, + from_server=api_base, + to_server=model.better_server, + reason="Higher TPM and avoids memory contention" + )) + + # Warning: Large + medium models + if len(large_models) >= 1 and len(medium_models) >= 1: + warnings.append(SelectionWarning( + severity="medium", + server=api_base, + message=f"⚠️ Large model + medium model on {api_base.split('//')[1]}. May cause delays.", + estimated_total_memory=f"{total_memory}GB" + )) + + # Info: High memory usage + if total_memory > 50: + warnings.append(SelectionWarning( + severity="info", + server=api_base, + message=f"ℹ️ Selected models require ~{total_memory}GB on {api_base.split('//')[1]}" + )) + + # Calculate diversity score + providers = set(m.provider for m in selected_models) + categories = set(m.model_category for m in selected_models) + unique_base_models = set(m.base_model for m in selected_models) + + diversity_score = calculate_diversity_score(len(providers), len(categories), len(unique_base_models)) + + return SelectionAnalysis( + warnings=warnings, + recommendations=recommendations, + total_models_selected=len(selected_models), + servers_used=len(by_server), + diversity_score=diversity_score + ) + + +def calculate_diversity_score(providers: int, categories: int, unique_models: int) -> int: + """Calculate diversity score (0-100)""" + score = 0 + + if providers >= 3: + score += 40 + elif providers >= 2: + score += 25 + + if categories >= 2: + score += 30 + + if unique_models >= 3: + score += 30 + elif unique_models >= 2: + score += 15 + + return min(score, 100) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 29fec8e..da27bac 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,7 +7,15 @@ import SynthesisPanel from './components/SynthesisPanel'; import HistoryBrowser from './components/HistoryBrowser'; export default function App() { - const { models, loading: modelsLoading } = useLLMModels(); + const { + models, + serverGroups, + selectedModels, + selectionAnalysis, + loading: modelsLoading, + handleModelSelect, + clearSelection + } = useLLMModels(); const { debating, decisionId, @@ -136,8 +144,13 @@ export default function App() {
diff --git a/frontend/src/components/ModelCard.jsx b/frontend/src/components/ModelCard.jsx new file mode 100644 index 0000000..1354d5c --- /dev/null +++ b/frontend/src/components/ModelCard.jsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { + formatResponseTime, + formatMemory, + getHealthStatusIcon, + getSizeClassName +} from '../utils/modelSelection'; + +export function ModelCard({ model, isSelected, onSelect }) { + const [showDetails, setShowDetails] = useState(false); + + return ( +
+
+ e.stopPropagation()} + /> + +
+
+ {model.display_name} + + {/* Badges */} + {model.is_latest_alias && ( + + Latest → {model.actual_tag} + + )} + + {model.size.parameters} + + {model.is_duplicate && ( + + {model.duplicate_count} servers + + )} +
+ +
+ {model.provider} + {model.model_category} + {formatMemory(model.size.estimated_memory_gb)} + + {/* Health status */} + {model.health && ( + + {getHealthStatusIcon(model.health.status)} + {' '} + {formatResponseTime(model.health.response_time_ms)} + + )} +
+ + {/* Better server recommendation */} + {model.is_duplicate && model.better_server && ( +
+ 💡 Also on {model.better_server.split('//')[1]} (better performance) +
+ )} +
+ + {/* Details toggle */} + {model.health && ( + + )} +
+ + {/* Health details */} + {showDetails && model.health && ( +
+
+ Health: + {model.health.healthy_count} healthy, {model.health.unhealthy_count} unhealthy +
+ {model.health.last_checked && ( +
+ Last checked: + {new Date(model.health.last_checked).toLocaleString()} +
+ )} + {model.health.error_message && ( +
+ Error: + {model.health.error_message} +
+ )} +
+ Max tokens: + {model.max_tokens.toLocaleString()} +
+
+ Function calling: + {model.supports_function_calling ? 'Yes' : 'No'} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/QueryInput.jsx b/frontend/src/components/QueryInput.jsx index dd0007c..ba9a6e2 100644 --- a/frontend/src/components/QueryInput.jsx +++ b/frontend/src/components/QueryInput.jsx @@ -1,4 +1,6 @@ import { useState } from 'react'; +import { ServerGroup } from './ServerGroup'; +import { SelectionAnalysis } from './SelectionAnalysis'; // Quick prompt examples - moved outside component for performance const QUICK_PROMPTS = { @@ -7,7 +9,7 @@ const QUICK_PROMPTS = { 'Architecture': 'What are the pros and cons of different database architectures for a high-traffic e-commerce platform?', }; -// Helper function to get model provider label +// Helper function to get model provider label (for legacy flat view) const getModelProviderLabel = (modelId) => { if (modelId.includes('gpt')) return 'OpenAI Flagship'; if (modelId.includes('claude')) return 'Anthropic Flagship'; @@ -19,44 +21,63 @@ const getModelProviderLabel = (modelId) => { return 'AI Model'; }; -export default function QueryInput({ models, onSubmit, disabled }) { +export default function QueryInput({ + models, + serverGroups = [], + selectedModels = [], + selectionAnalysis = null, + onModelSelect, + onSubmit, + disabled, + useServerGrouping = true +}) { const [query, setQuery] = useState(''); - const [selectedModels, setSelectedModels] = useState([]); const [chairman, setChairman] = useState(''); + // For backward compatibility - local state if not using hook + const [localSelectedModels, setLocalSelectedModels] = useState([]); + + // Use provided or local state + const activeSelectedModels = onModelSelect ? selectedModels : localSelectedModels; + const handleSubmit = (e) => { e.preventDefault(); - if (query.trim() && selectedModels.length >= 2 && chairman) { - onSubmit(query, selectedModels, chairman); + if (query.trim() && activeSelectedModels.length >= 2 && chairman) { + // Extract model display names for submission + const modelNames = activeSelectedModels.map(id => { + // id format is "model_name_host", so extract just the model name + return id.split('_').slice(0, -1).join('_'); + }); + onSubmit(query, modelNames, chairman.split('_').slice(0, -1).join('_')); } }; - const toggleModel = (modelId) => { - setSelectedModels(prev => { - if (prev.includes(modelId)) { - return prev.filter(id => id !== modelId); - } else { - return [...prev, modelId]; - } - }); + const handleLocalModelSelect = (modelId, isSelected) => { + if (onModelSelect) { + onModelSelect(modelId, isSelected); + } else { + setLocalSelectedModels(prev => { + if (isSelected) { + return [...prev, modelId]; + } else { + return prev.filter(id => id !== modelId); + } + }); + } }; const handleQuickPrompt = (prompt) => { setQuery(QUICK_PROMPTS[prompt] || ''); }; - const handleModelKeyDown = (e, modelId) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleModel(modelId); - } - }; - - const availableModels = models.filter(m => m.available); + // Get all available models (flatten from serverGroups or use models) + const availableModels = useServerGrouping && serverGroups.length > 0 + ? serverGroups.flatMap(group => group.models) + : models.filter(m => m.available); // Set default chairman to Claude Opus 4.5 if available const recommendedChairman = availableModels.find(m => - m.id.includes('claude-opus-4') || m.name.includes('Claude Opus') + m.display_name?.includes('claude-opus-4') || m.name?.includes('Claude Opus') ); return ( @@ -93,6 +114,11 @@ export default function QueryInput({ models, onSubmit, disabled }) { + {/* Selection Analysis */} + {selectionAnalysis && activeSelectedModels.length > 0 && ( + + )} + {/* Council Members */}
@@ -100,50 +126,64 @@ export default function QueryInput({ models, onSubmit, disabled }) { Council Members - Select 2+ models for best results + Select 2+ models for best results ({activeSelectedModels.length} selected)
-
- {availableModels.length === 0 ? ( -

No models available

- ) : ( - availableModels.map(model => { - const isSelected = selectedModels.includes(model.id); - return ( -
!disabled && toggleModel(model.id)} - onKeyDown={(e) => !disabled && handleModelKeyDown(e, model.id)} - role="checkbox" - aria-checked={isSelected} - tabIndex={disabled ? -1 : 0} - className={`model-card ${isSelected ? 'model-card-selected' : ''}`} - > -
- toggleModel(model.id)} - disabled={disabled} - tabIndex={-1} - aria-hidden="true" - className="mt-1 rounded text-blue-600 focus:ring-blue-500 bg-transparent border-gray-600" - /> -
-
- {model.name} -
-
- {getModelProviderLabel(model.id)} + {/* Server-grouped or flat layout */} + {useServerGrouping && serverGroups.length > 0 ? ( +
+ {serverGroups.map(group => ( + + ))} +
+ ) : ( +
+ {availableModels.length === 0 ? ( +

No models available

+ ) : ( + availableModels.map(model => { + const modelId = model.id || model.name; + const isSelected = activeSelectedModels.includes(modelId); + return ( +
!disabled && handleLocalModelSelect(modelId, !isSelected)} + role="checkbox" + aria-checked={isSelected} + tabIndex={disabled ? -1 : 0} + className={`model-card ${isSelected ? 'model-card-selected' : ''}`} + > +
+ handleLocalModelSelect(modelId, !isSelected)} + disabled={disabled} + tabIndex={-1} + aria-hidden="true" + className="mt-1 rounded text-blue-600 focus:ring-blue-500 bg-transparent border-gray-600" + /> +
+
+ {model.name || model.display_name} +
+
+ {getModelProviderLabel(modelId)} +
-
- ); - }) - )} -
+ ); + }) + )} +
+ )}
{/* Chairman Model */} @@ -159,10 +199,12 @@ export default function QueryInput({ models, onSubmit, disabled }) { > {availableModels.map(model => { - const isRecommended = recommendedChairman && model.id === recommendedChairman.id; + const modelId = model.id || model.name; + const modelName = model.name || model.display_name; + const isRecommended = recommendedChairman && modelId === recommendedChairman.id; return ( - ); })} @@ -172,7 +214,7 @@ export default function QueryInput({ models, onSubmit, disabled }) { {/* Submit Button */} diff --git a/frontend/src/components/SelectionAnalysis.jsx b/frontend/src/components/SelectionAnalysis.jsx index ca8dc84..b472980 100644 --- a/frontend/src/components/SelectionAnalysis.jsx +++ b/frontend/src/components/SelectionAnalysis.jsx @@ -1,5 +1,21 @@ import { getSeverityClassName } from '../utils/modelSelection'; +/** + * Safely extract hostname from URL string + * @param {string} url - URL to parse (e.g., "http://192.168.1.100:11434") + * @returns {string} hostname or full URL if parsing fails + */ +function extractHostname(url) { + try { + const parsed = new URL(url); + return parsed.hostname; + } catch { + // Fallback: try to extract after // and before : + const match = url.match(/\/\/([^:\/]+)/); + return match ? match[1] : url; + } +} + export function SelectionAnalysis({ analysis }) { if (!analysis || analysis.total_models_selected === 0) { return null; @@ -62,8 +78,8 @@ export function SelectionAnalysis({ analysis }) {
Move {rec.model} from{' '} - {rec.from_server.split('//')[1]} to{' '} - {rec.to_server.split('//')[1]} + {extractHostname(rec.from_server)} to{' '} + {extractHostname(rec.to_server)}
{rec.reason}
diff --git a/frontend/src/components/ServerGroup.jsx b/frontend/src/components/ServerGroup.jsx index 48f0905..3c9603d 100644 --- a/frontend/src/components/ServerGroup.jsx +++ b/frontend/src/components/ServerGroup.jsx @@ -13,11 +13,17 @@ export function ServerGroup({ // Calculate server usage stats const stats = getServerStats(serverGroup, selectedModels); + // Create unique ID for aria-controls + const contentId = `server-content-${server.api_base.replace(/[^a-zA-Z0-9]/g, '-')}`; + return (
-
setExpanded(!expanded)} + aria-expanded={expanded} + aria-controls={contentId} >
🖥️ @@ -36,8 +42,9 @@ export function ServerGroup({ )}
-
+ +
{/* Resource warnings for this server */} {expanded && stats.selectedCount > 0 && (
@@ -79,6 +86,7 @@ export function ServerGroup({ ))}
)} +
); } diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 8fada25..f35ef1d 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -47,7 +47,7 @@ } .server-header { - @apply bg-[#1a2332] p-4 cursor-pointer hover:bg-[#1f2938] transition-colors; + @apply w-full text-left bg-[#1a2332] p-4 cursor-pointer hover:bg-[#1f2938] transition-colors border-0; } .server-title { From 3cf9f8af49627c6b69c511f5cd7709c9e0c30303 Mon Sep 17 00:00:00 2001 From: Barnaby Jeans Date: Sat, 20 Dec 2025 13:03:19 -0800 Subject: [PATCH 6/6] refactor: consolidate extractHostname utility and improve error handling - Consolidate duplicate extractHostname() function into shared modelSelection.js utility - Add error handling for CACHE_TTL_SECONDS parsing with validation and logging - Update CLAUDE.md with Docker build validation requirements - Addresses Copilot review feedback: DRY principle and crash prevention --- CLAUDE.md | 26 +++++++++++++++++++ backend/api/config.py | 21 ++++++++++++++- frontend/src/components/ModelCard.jsx | 19 ++------------ frontend/src/components/SelectionAnalysis.jsx | 18 +------------ frontend/src/utils/modelSelection.js | 16 ++++++++++++ 5 files changed, 65 insertions(+), 35 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4c33ae3..626770e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,32 @@ Backend runs on port 8000, frontend on port 5173. If port 8000 is in use: lsof -ti:8000 | xargs kill -9 # Kill process on port 8000 ``` +## Code Validation + +**IMPORTANT**: After making code changes, always validate that the Docker image still builds successfully: + +```bash +docker compose build +``` + +This command: +- Validates frontend code compiles (Vite build) +- Validates backend dependencies install correctly +- Ensures production build artifacts are created +- Catches build-time errors before deployment + +Common build failures: +- CSS class name errors (invalid Tailwind utilities) +- TypeScript/JavaScript import errors +- Python dependency conflicts +- Missing environment variables in build process + +If the build fails, fix the errors before committing. A successful build should complete with: +``` +✓ built in XXXms +bjeans/multi-ai-chat:latest Built +``` + ## Architecture ### Backend Request Flow diff --git a/backend/api/config.py b/backend/api/config.py index a8a39e9..7a4712b 100644 --- a/backend/api/config.py +++ b/backend/api/config.py @@ -4,18 +4,37 @@ import asyncio import time import os +import logging from models.schemas import ModelInfo, ServerGroup, SelectionAnalysis from services.litellm_client import LiteLLMClient from services.model_processor import process_models_with_health, analyze_selection +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/config", tags=["config"]) # Global cache for server groups with expiration _server_groups_cache: Optional[List[ServerGroup]] = None _cache_timestamp: Optional[float] = None _cache_lock = asyncio.Lock() -CACHE_TTL_SECONDS = int(os.getenv("CACHE_TTL_SECONDS", "120")) # Default 2 minutes + +# Parse cache TTL with error handling +def _get_cache_ttl() -> int: + """Get cache TTL from environment with validation""" + default_ttl = 120 + try: + ttl_str = os.getenv("CACHE_TTL_SECONDS", str(default_ttl)) + ttl = int(ttl_str) + if ttl <= 0: + logger.warning(f"Invalid CACHE_TTL_SECONDS={ttl_str} (must be > 0), using default {default_ttl}") + return default_ttl + return ttl + except ValueError: + logger.warning(f"Invalid CACHE_TTL_SECONDS={os.getenv('CACHE_TTL_SECONDS')} (must be integer), using default {default_ttl}") + return default_ttl + +CACHE_TTL_SECONDS = _get_cache_ttl() class TestModelRequest(BaseModel): diff --git a/frontend/src/components/ModelCard.jsx b/frontend/src/components/ModelCard.jsx index 8b10c60..9e93dd7 100644 --- a/frontend/src/components/ModelCard.jsx +++ b/frontend/src/components/ModelCard.jsx @@ -3,25 +3,10 @@ import { formatResponseTime, formatMemory, getHealthStatusIcon, - getSizeClassName + getSizeClassName, + extractHostname } from '../utils/modelSelection'; -/** - * Safely extract hostname from URL string - * @param {string} url - URL to parse (e.g., "http://192.168.1.100:11434") - * @returns {string} hostname or full URL if parsing fails - */ -function extractHostname(url) { - try { - const parsed = new URL(url); - return parsed.hostname; - } catch { - // Fallback: try to extract after // and before : - const match = url.match(/\/\/([^:\/]+)/); - return match ? match[1] : url; - } -} - export function ModelCard({ model, isSelected, onSelect }) { const [showDetails, setShowDetails] = useState(false); diff --git a/frontend/src/components/SelectionAnalysis.jsx b/frontend/src/components/SelectionAnalysis.jsx index b472980..74bfa00 100644 --- a/frontend/src/components/SelectionAnalysis.jsx +++ b/frontend/src/components/SelectionAnalysis.jsx @@ -1,20 +1,4 @@ -import { getSeverityClassName } from '../utils/modelSelection'; - -/** - * Safely extract hostname from URL string - * @param {string} url - URL to parse (e.g., "http://192.168.1.100:11434") - * @returns {string} hostname or full URL if parsing fails - */ -function extractHostname(url) { - try { - const parsed = new URL(url); - return parsed.hostname; - } catch { - // Fallback: try to extract after // and before : - const match = url.match(/\/\/([^:\/]+)/); - return match ? match[1] : url; - } -} +import { getSeverityClassName, extractHostname } from '../utils/modelSelection'; export function SelectionAnalysis({ analysis }) { if (!analysis || analysis.total_models_selected === 0) { diff --git a/frontend/src/utils/modelSelection.js b/frontend/src/utils/modelSelection.js index eff5d68..a5fc944 100644 --- a/frontend/src/utils/modelSelection.js +++ b/frontend/src/utils/modelSelection.js @@ -2,6 +2,22 @@ * Utility functions for model selection and analysis */ +/** + * Safely extract hostname from URL string + * @param {string} url - URL to parse (e.g., "http://192.168.1.100:11434") + * @returns {string} hostname or full URL if parsing fails + */ +export function extractHostname(url) { + try { + const parsed = new URL(url); + return parsed.hostname; + } catch { + // Fallback: try to extract after // and before : + const match = url.match(/\/\/([^:\/]+)/); + return match ? match[1] : url; + } +} + export function findModelById(modelId, serverGroups) { for (const group of serverGroups) { const model = group.models.find(m => m.id === modelId);