#!/usr/bin/env python3 """ Model Fallback Verification Script Issue #514: [Robustness] Model fallback verification — test before trusting Tests model switches with verification prompts, validates context windows, and ensures at least one viable model is available before starting loops. Usage: python3 model-fallback-verify.py # Run full verification python3 model-fallback-verify.py check # Test specific model python3 model-fallback-verify.py context # Check context window python3 model-fallback-verify.py list # List available models """ import os, sys, json, yaml, urllib.request from datetime import datetime, timezone from pathlib import Path # Configuration HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes")) CONFIG_FILE = HERMES_HOME / "config.yaml" LOG_DIR = HERMES_HOME / "logs" LOG_FILE = LOG_DIR / "model-verify.log" MIN_CONTEXT_WINDOW = 64 * 1024 # 64K tokens minimum # Provider endpoints PROVIDER_CONFIGS = { "openrouter": { "base_url": "https://openrouter.ai/api/v1", "headers": lambda api_key: {"Authorization": "Bearer " + api_key}, "chat_url": "/chat/completions", }, "anthropic": { "base_url": "https://api.anthropic.com/v1", "headers": lambda api_key: {"x-api-key": api_key, "anthropic-version": "2023-06-01"}, "chat_url": "/messages", }, "nous": { "base_url": "https://inference.nousresearch.com/v1", "headers": lambda api_key: {"Authorization": "Bearer " + api_key}, "chat_url": "/chat/completions", }, "kimi-coding": { "base_url": "https://api.kimi.com/coding/v1", "headers": lambda api_key: {"x-api-key": api_key, "x-api-provider": "kimi-coding"}, "chat_url": "/chat/completions", }, "custom": { "base_url": None, "headers": lambda api_key: {"Authorization": "Bearer " + api_key}, "chat_url": "/chat/completions", }, } # Known context windows for common models KNOWN_CONTEXT_WINDOWS = { "claude-opus-4-6": 200000, "claude-sonnet-4": 200000, "claude-3.5-sonnet": 200000, "gpt-4o": 128000, "gpt-4": 128000, "gpt-3.5-turbo": 16385, "qwen3:30b": 32768, "qwen2.5:7b": 32768, "hermes4:14b": 32768, "gemma3:1b": 8192, "gemma4": 32768, "phi3:3.8b": 128000, "kimi-k2.5": 128000, "google/gemini-2.5-pro": 1048576, "xiaomi/mimo-v2-pro": 131072, "deepseek/deepseek-r1": 131072, "deepseek/deepseek-chat-v3-0324": 131072, } def log(msg): """Log message to file and optionally to console.""" timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") log_entry = "[" + timestamp + "] " + msg LOG_DIR.mkdir(parents=True, exist_ok=True) with open(LOG_FILE, "a") as f: f.write(log_entry + "\n") if "--quiet" not in sys.argv: print(log_entry) def load_config(): """Load Hermes config.yaml.""" if not CONFIG_FILE.exists(): return None with open(CONFIG_FILE) as f: return yaml.safe_load(f) def get_provider_api_key(provider): """Get API key for a provider from .env or environment.""" env_file = HERMES_HOME / ".env" if env_file.exists(): with open(env_file) as f: for line in f: line = line.strip() if line.startswith(provider.upper() + "_API_KEY="): return line.split("=", 1)[1].strip().strip("'\"") return os.environ.get(provider.upper() + "_API_KEY") def get_ollama_models(): """Get list of available Ollama models.""" ollama_host = os.environ.get("OLLAMA_HOST", "localhost:11434") try: resp = urllib.request.urlopen("http://" + ollama_host + "/api/tags", timeout=5) data = json.loads(resp.read()) return [m["name"] for m in data.get("models", [])] except: return [] def test_model(model, provider, api_key=None, base_url=None): """ Test a model with a verification prompt. Returns (success, response, error_message) """ if provider == "ollama" or ":" in model: # Local Ollama model ollama_host = os.environ.get("OLLAMA_HOST", "localhost:11434") try: body = json.dumps({ "model": model, "prompt": "Say exactly VERIFIED and nothing else.", "stream": False, "options": {"num_predict": 10} }).encode() req = urllib.request.Request( "http://" + ollama_host + "/api/generate", data=body, headers={"Content-Type": "application/json"} ) resp = urllib.request.urlopen(req, timeout=30) result = json.loads(resp.read()) response_text = result.get("response", "").strip() if "VERIFIED" in response_text.upper(): return True, response_text, None return False, response_text, "Unexpected response: " + response_text[:100] except Exception as e: return False, "", "Ollama error: " + str(e)[:200] # Cloud provider config = PROVIDER_CONFIGS.get(provider) if not config: return False, "", "Unknown provider: " + provider url = base_url or config["base_url"] if not url: return False, "", "No base URL for provider: " + provider headers = config["headers"](api_key or "") headers["Content-Type"] = "application/json" try: body = json.dumps({ "model": model, "max_tokens": 20, "messages": [{"role": "user", "content": "Say exactly VERIFIED and nothing else."}] }).encode() req = urllib.request.Request( url + config["chat_url"], data=body, headers=headers ) resp = urllib.request.urlopen(req, timeout=30) result = json.loads(resp.read()) if provider == "anthropic": content = result.get("content", [{}])[0].get("text", "") else: choices = result.get("choices", [{}]) content = choices[0].get("message", {}).get("content", "") if choices else "" if "VERIFIED" in content.upper(): return True, content, None return False, content, "Unexpected response: " + content[:100] except urllib.error.HTTPError as e: error_body = e.read().decode() if e.fp else str(e) if e.code == 404: return False, "", "Model not found (404): " + error_body[:200] elif e.code == 429: return True, "", "Rate limited but model exists" elif e.code >= 500: return False, "", "Server error (" + str(e.code) + "): " + error_body[:200] else: return False, "", "HTTP " + str(e.code) + ": " + error_body[:200] except Exception as e: return False, "", "Request error: " + str(e)[:200] def get_context_window(model, provider): """ Get the context window size for a model. Returns (window_size, source) """ if model in KNOWN_CONTEXT_WINDOWS: return KNOWN_CONTEXT_WINDOWS[model], "known" model_lower = model.lower() if "claude" in model_lower: return 200000, "inferred (claude)" elif "gpt-4" in model_lower: return 128000, "inferred (gpt-4)" elif "gemini" in model_lower: return 1048576, "inferred (gemini)" elif "qwen" in model_lower: return 32768, "inferred (qwen)" elif "gemma" in model_lower: return 8192, "inferred (gemma)" elif "phi" in model_lower: return 128000, "inferred (phi)" return 32768, "default" def verify_model(model, provider, api_key=None, base_url=None): """ Full verification of a model: test prompt + context window. Returns dict with verification results. """ result = { "model": model, "provider": provider, "tested": False, "responded": False, "response": "", "error": None, "context_window": 0, "context_source": "unknown", "meets_minimum": False, "viable": False, } success, response, error = test_model(model, provider, api_key, base_url) result["tested"] = True result["responded"] = success result["response"] = response[:200] if response else "" result["error"] = error window, source = get_context_window(model, provider) result["context_window"] = window result["context_source"] = source result["meets_minimum"] = window >= MIN_CONTEXT_WINDOW result["viable"] = success and result["meets_minimum"] return result def get_fallback_chain(config): """Get the fallback chain from config or defaults.""" chain = [] model_config = config.get("model", {}) if isinstance(model_config, dict): primary = model_config.get("default", "") provider = model_config.get("provider", "") if primary and provider: chain.append({"model": primary, "provider": provider, "role": "primary"}) elif model_config: chain.append({"model": str(model_config), "provider": "unknown", "role": "primary"}) auxiliary = config.get("auxiliary", {}) for aux_name, aux_config in auxiliary.items(): if isinstance(aux_config, dict): aux_model = aux_config.get("model", "") aux_provider = aux_config.get("provider", "") if aux_model and aux_provider and aux_provider != "auto": chain.append({"model": aux_model, "provider": aux_provider, "role": "auxiliary:" + aux_name}) ollama_models = get_ollama_models() for model in ollama_models[:3]: if not any(c["model"] == model for c in chain): chain.append({"model": model, "provider": "ollama", "role": "local-fallback"}) return chain def run_verification(): """Run full model fallback verification.""" log("=== Model Fallback Verification ===") config = load_config() if not config: log("ERROR: No config.yaml found") return {"success": False, "error": "No config file"} chain = get_fallback_chain(config) if not chain: log("ERROR: No models configured") return {"success": False, "error": "No models in chain"} results = [] viable_models = [] for entry in chain: model = entry["model"] provider = entry["provider"] role = entry["role"] api_key = get_provider_api_key(provider) if provider != "ollama" else None base_url = None if provider == "custom": provider_config = config.get("auxiliary", {}).get("vision", {}) base_url = provider_config.get("base_url") log("Testing [" + role + "] " + model + " (" + provider + ")...") result = verify_model(model, provider, api_key, base_url) result["role"] = role results.append(result) status = "PASS" if result["viable"] else "FAIL" details = [] if not result["responded"]: details.append("no response: " + str(result["error"])) if not result["meets_minimum"]: details.append("context " + str(result["context_window"]) + " < " + str(MIN_CONTEXT_WINDOW)) log(" [" + status + "] " + model + " - " + (", ".join(details) if details else "verified")) if result["viable"]: viable_models.append(result) log("=== Results: " + str(len(viable_models)) + "/" + str(len(results)) + " models viable ===") if not viable_models: log("CRITICAL: No viable models found!") for r in results: log(" - " + r["model"] + " (" + r["provider"] + "): responded=" + str(r["responded"]) + ", context=" + str(r["context_window"])) return {"success": False, "results": results, "viable": []} log("Viable models (in priority order):") for i, r in enumerate(viable_models, 1): log(" " + str(i) + ". " + r["model"] + " (" + r["provider"] + ") - context: " + str(r["context_window"]) + " tokens [" + r["role"] + "]") return { "success": True, "results": results, "viable": viable_models, "primary": viable_models[0] if viable_models else None, } def check_single_model(model): """Check a specific model.""" if ":" in model: provider = "ollama" elif "/" in model: provider = "openrouter" else: provider = "unknown" config = load_config() or {} api_key = get_provider_api_key(provider) if provider != "ollama" else None result = verify_model(model, provider, api_key) if result["viable"]: print("PASS: " + model) print(" Context window: " + str(result["context_window"]) + " tokens") print(" Response: " + result["response"][:100]) else: print("FAIL: " + model) if result["error"]: print(" Error: " + str(result["error"])) if not result["meets_minimum"]: print(" Context window: " + str(result["context_window"]) + " < " + str(MIN_CONTEXT_WINDOW) + " minimum") return result["viable"] def check_context_window(model): """Check context window for a model.""" if ":" in model: provider = "ollama" elif "/" in model: provider = "openrouter" else: provider = "unknown" window, source = get_context_window(model, provider) meets = window >= MIN_CONTEXT_WINDOW print("Model: " + model) print("Provider: " + provider) print("Context window: " + str(window) + " tokens (" + source + ")") print("Minimum (" + str(MIN_CONTEXT_WINDOW) + "): " + ("PASS" if meets else "FAIL")) return meets def list_models(): """List all available models.""" config = load_config() or {} chain = get_fallback_chain(config) print("Configured models:") for entry in chain: print(" " + entry["model"].ljust(30) + " " + entry["provider"].ljust(15) + " [" + entry["role"] + "]") ollama = get_ollama_models() if ollama: print("") print("Ollama models:") for m in ollama: print(" " + m) def main(): if len(sys.argv) < 2: result = run_verification() sys.exit(0 if result["success"] else 1) cmd = sys.argv[1] if cmd == "check" and len(sys.argv) > 2: model = sys.argv[2] success = check_single_model(model) sys.exit(0 if success else 1) elif cmd == "context" and len(sys.argv) > 2: model = sys.argv[2] meets = check_context_window(model) sys.exit(0 if meets else 1) elif cmd == "list": list_models() elif cmd == "test": result = run_verification() sys.exit(0 if result["success"] else 1) else: print("Usage:") print(" model-fallback-verify.py Run full verification") print(" model-fallback-verify.py check Test specific model") print(" model-fallback-verify.py context Check context window") print(" model-fallback-verify.py list List available models") sys.exit(1) if __name__ == "__main__": main()