{"repo": "adityaanks/bug-hunter", "files": {"bughunter/config/settings.py": {"0a06d41bcd9cc8774ded276a376308838f39abd9": {"t": 1785746911.9966176, "n": 22856, "h": "c2ee3309b444b97e", "c": "\"\"\"Bug Hunter configuration management — load, save, and access settings.\n\nBuilt on the Bug Hunter engine.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom contextlib import suppress\nfrom pathlib import Path\nfrom typing import Any\n\n# Load .env file if present (before any env var reads)\ntry:\n from dotenv import load_dotenv\n load_dotenv(Path(__file__).resolve().parents[2] / \".env\", override=False)\nexcept ImportError:\n pass\n\nimport yaml\nfrom pydantic import ValidationError\n\nfrom .schema import (\n BUILTIN_MCP_SERVERS,\n PROVIDER_PRESETS,\n BugHunterConfig,\n FallbackProviderConfig,\n LLMProvider,\n MCPServerConfig,\n MCPServersConfig,\n MCPTransportConfig,\n ModelPoolEntry,\n)\n\n# ── Paths ──────────────────────────────────────────────────────────\n\nCONFIG_DIR = Path(os.environ.get(\"BUGHUNTER_CONFIG_DIR\", str(Path.home() / \".bughunter\")))\nCONFIG_FILE = CONFIG_DIR / \"config.yaml\"\nSESSIONS_DIR = CONFIG_DIR / \"sessions\"\nTARGETS_DIR = CONFIG_DIR / \"targets\"\nKB_DIR = CONFIG_DIR / \"kb\"\nSKILLS_DIR = CONFIG_DIR / \"skills\"\nWEB_TASKS_FILE = CONFIG_DIR / \"web_tasks.json\"\nSANDBOX_SESSIONS_FILE = CONFIG_DIR / \"sandbox_sessions.json\"\nPYTHON_EXECUTE_AUDIT_FILE = CONFIG_DIR / \"python_execute_audit.jsonl\"\nDEFAULT_OPENAI_USER_AGENT = \"Mozilla/5.0\"\n\n\ndef ensure_dirs() -> None:\n \"\"\"Create Bug Hunter config directories if they don't exist.\"\"\"\n for d in [CONFIG_DIR, SESSIONS_DIR, TARGETS_DIR, KB_DIR, SKILLS_DIR]:\n d.mkdir(parents=True, exist_ok=True)\n\n\ndef openai_default_headers(llm_config: Any = None) -> dict[str, str]:\n from bughunter.agent.tee_privacy import build_tee_headers\n\n headers = {\"User-Agent\": os.environ.get(\"BUGHUNTER_LLM_USER_AGENT\", DEFAULT_OPENAI_USER_AGENT)}\n headers.update(build_tee_headers(llm_config))\n return headers\n\n\ndef make_openai_client(api_key: str, base_url: str, timeout: float | None = None, llm_config: Any = None):\n from openai import OpenAI\n\n effective_url = base_url\n tee_cfg = getattr(llm_config, \"tee\", None) if llm_config is not None else None\n if tee_cfg and tee_cfg.enabled and tee_cfg.proxy_url:\n effective_url = tee_cfg.proxy_url\n elif os.environ.get(\"BUGHUNTER_TEE_PROXY_URL\"):\n effective_url = os.environ[\"BUGHUNTER_TEE_PROXY_URL\"]\n\n kwargs: dict[str, Any] = {\n \"api_key\": api_key,\n \"base_url\": effective_url,\n \"default_headers\": openai_default_headers(llm_config),\n }\n if timeout is not None:\n kwargs[\"timeout\"] = timeout\n return OpenAI(**kwargs)\n\n\n# ── Load / Save ────────────────────────────────────────────────────\n\n\ndef load_config() -> BugHunterConfig:\n \"\"\"Load configuration from file + env vars.\n\n Priority: env vars > config file > built-in defaults.\n \"\"\"\n ensure_dirs()\n\n # Start with built-in defaults + registered MCP servers\n servers: dict[str, MCPServerConfig] = {}\n for name, cfg in BUILTIN_MCP_SERVERS.items():\n servers[name] = _parse_mcp_server(name, cfg)\n\n config = BugHunterConfig(mcp=MCPServersConfig(servers=servers))\n\n # Overlay from config file\n if CONFIG_FILE.exists():\n try:\n with open(CONFIG_FILE, \"r\", encoding=\"utf-8\") as f:\n raw = yaml.safe_load(f) or {}\n config = _merge_config(config, raw)\n except (yaml.YAMLError, ValidationError) as e:\n # Log warning but don't crash\n print(f\"[!] Warning: Failed to parse config file {CONFIG_FILE}: {e}\")\n\n # Overlay from env vars\n config = _overlay_env(config)\n\n return config\n\n\ndef save_config(config: BugHunterConfig) -> None:\n \"\"\"Save configuration to YAML file.\"\"\"\n ensure_dirs()\n raw = config.model_dump(mode=\"json\")\n # Remove default values to keep config clean\n _strip_defaults(raw)\n with open(CONFIG_FILE, \"w\", encoding=\"utf-8\") as f:\n yaml.dump(raw, f, default_flow_style=False, allow_unicode=True)\n\n\ndef set_config_value(key: str, value: str) -> None:\n \"\"\"Set a nested config value using dot notation.\n\n Example: set_config_value(\"llm.api_key\", \"sk-xxx\")\n \"\"\"\n config = load_config()\n parts = key.split(\".\")\n obj: Any = config\n for part in parts[:-1]:\n obj = obj[part] if isinstance(obj, dict) else getattr(obj, part)\n field_name = parts[-1]\n\n # Type coercion based on field annotation\n model_fields = getattr(type(obj), \"model_fields\", {})\n if field_name in model_fields:\n field_info = model_fields[field_name]\n annotation = field_info.annotation\n if annotation is int:\n value = int(value)\n elif annotation is float:\n value = float(value)\n elif annotation is bool:\n value = value.lower() in (\"true\", \"1\", \"yes\")\n\n if isinstance(obj, dict):\n obj[field_name] = value\n else:\n setattr(obj, field_name, value)\n save_config(config)\n\n\n# ── Helpers ─────────────────────────────────────────────────────────\n\n\ndef _parse_mcp_server(name: str, raw: dict[str, Any]) -> MCPServerConfig:\n \"\"\"Parse a raw dict into MCPServerConfig.\"\"\"\n transport_raw = raw.get(\"transport\", {})\n return MCPServerConfig(\n name=raw.get(\"name\", name),\n enabled=raw.get(\"enabled\", True),\n priority=raw.get(\"priority\", 1),\n description=raw.get(\"description\", \"\"),\n transport=MCPTransportConfig(\n type=transport_raw.get(\"type\", \"stdio\"),\n command=transport_raw.get(\"command\"),\n args=transport_raw.get(\"args\"),\n url=transport_raw.get(\"url\"),\n env=transport_raw.get(\"env\"),\n startup_timeout=transport_raw.get(\"startup_timeout\", 30000),\n tool_timeout=transport_raw.get(\"tool_timeout\", 300000),\n ),\n )\n\n\ndef _merge_config(base: BugHunterConfig, raw: dict[str, Any]) -> BugHunterConfig:\n \"\"\"Merge raw dict into existing config, preserving unset defaults.\"\"\"\n data = base.model_dump(mode=\"json\")\n\n # Deep merge\n _deep_merge(data, raw)\n\n try:\n return BugHunterConfig(**data)\n except ValidationError:\n # If merged data is invalid, return base\n return base\n\n\ndef _deep_merge(base: dict, override: dict) -> None:\n \"\"\"Recursively merge override into base (mutates base).\"\"\"\n for key, val in override.items():\n if key in base and isinstance(base[key], dict) and isinstance(val, dict):\n _deep_merge(base[key], val)\n else:\n base[key] = val\n\n\ndef _overlay_env(config: BugHunterConfig) -> BugHunterConfig:\n \"\"\"Overlay environment variables onto config.\n\n Supported env var prefixes (checked in order): BUGHUNTER_, BUGHUNTER_\n LLM: API_KEY, BASE_URL, MODEL, PROVIDER, MAX_TOKENS, MAX_CONTEXT_TOKENS, TEMPERATURE\n Session: OUTPUT_DIR, AUTO_SAVE, REPORT_FORMAT, MAX_ROUNDS, SHOW_THINKING\n Safety: PYTHON_EXECUTE_ENABLED, PYTHON_EXECUTE_RESTRICTED, PYTHON_EXECUTE_MODE,\n PYTHON_EXECUTE_MAX_LINES, PYTHON_EXECUTE_SHOW_WARNING,\n PYTHON_EXECUTE_MAX_OUTPUT_CHARS, PYTHON_EXECUTE_AUDIT_ENABLED\n \"\"\"\n\n def _env(suffix: str) -> str | None:\n \"\"\"Check BUGHUNTER_ prefix first, then BUGHUNTER_ fallback.\"\"\"\n return os.environ.get(f\"BUGHUNTER_{suffix}\") or os.environ.get(f\"BUGHUNTER_{suffix}\")\n\n # ── LLM ──────────────────────────────────────────────────────────\n if v := _env(\"LLM_API_KEY\"):\n config.llm.api_key = v\n if v := _env(\"LLM_BASE_URL\"):\n config.llm.base_url = v\n if v := _env(\"LLM_MODEL\"):\n config.llm.model = v\n if v := _env(\"LLM_PROVIDER\"):\n config.llm.provider = v\n if v := _env(\"LLM_MAX_TOKENS\"):\n with suppress(ValueError):\n config.llm.max_tokens = int(v)\n if v := _env(\"LLM_MAX_CONTEXT_TOKENS\"):\n with suppress(ValueError):\n config.llm.max_context_tokens = int(v)\n if v := _env(\"LLM_TEMPERATURE\"):\n with suppress(ValueError):\n config.llm.temperature = float(v)\n\n # ── TEE & Privacy ────────────────────────────────────────────────\n _truthy = (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"TEE_ENABLED\"):\n config.llm.tee.enabled = v.lower() in _truthy\n if v := _env(\"ZERO_DATA_RETENTION\"):\n config.llm.tee.zero_data_retention = v.lower() in _truthy\n if v := _env(\"TEE_MODE\"):\n config.llm.tee.mode = v\n if v := _env(\"TEE_PROXY_URL\"):\n config.llm.tee.proxy_url = v\n if v := _env(\"TEE_ATTESTATION_TOKEN\"):\n config.llm.tee.attestation_token = v\n if v := _env(\"TEE_ANONYMIZE\"):\n config.llm.tee.anonymize_sensitive_data = v.lower() in _truthy\n\n # ── Session ──────────────────────────────────────────────────────\n if v := _env(\"SESSION_OUTPUT_DIR\"):\n config.session.output_dir = Path(v)\n if v := _env(\"SESSION_AUTO_SAVE\"):\n config.session.auto_save = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_REPORT_FORMAT\"):\n config.session.report_format = v\n if v := _env(\"SESSION_MAX_ROUNDS\"):\n with suppress(ValueError):\n config.session.max_rounds = int(v)\n if v := _env(\"SESSION_SHOW_THINKING\"):\n config.session.show_thinking = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_STALE_ROUNDS_THRESHOLD\"):\n with suppress(ValueError):\n config.session.stale_rounds_threshold = int(v)\n\n # ── Session: Reasoning state / Reflection engine / Plugin runtime ──────────────\n _truthy = (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_REASONING_STATE_ENABLED\"):\n config.session.reasoning_state_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_REFLEXION_ENABLED\"):\n config.session.reflexion_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_REFLEXION_MAX_SAME_VULN_FAILS\"):\n with suppress(ValueError):\n config.session.reflexion_max_same_vuln_fails = int(v)\n if v := _env(\"SESSION_REFLEXION_MAX_TOTAL_NO_PROGRESS\"):\n with suppress(ValueError):\n config.session.reflexion_max_total_no_progress = int(v)\n if v := _env(\"SESSION_ESCALATION_MAX_LEVEL\"):\n with suppress(ValueError):\n config.session.escalation_max_level = int(v)\n if v := _env(\"SESSION_PLUGIN_RUNTIME_ENABLED\"):\n config.session.plugin_runtime_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_PLUGIN_DEFAULT_TIMEOUT\"):\n with suppress(ValueError):\n config.session.plugin_default_timeout = int(v)\n if v := _env(\"SESSION_PLUGIN_MAX_REQUESTS_PER_TARGET\"):\n with suppress(ValueError):\n config.session.plugin_max_requests_per_target = int(v)\n if v := _env(\"SESSION_EVIDENCE_MIN_REPORT_LEVEL\"):\n config.session.evidence_min_report_level = v\n\n # ── Safety ───────────────────────────────────────────────────────\n if v := _env(\"SAFETY_PYTHON_EXECUTE_ENABLED\"):\n config.safety.enable_python_execute = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_RESTRICTED\"):\n config.safety.python_execute_restricted = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MODE\"):\n config.safety.python_execute_mode = v\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MAX_LINES\"):\n with suppress(ValueError):\n config.safety.python_execute_max_lines = int(v)\n if v := _env(\"SAFETY_PYTHON_EXECUTE_SHOW_WARNING\"):\n config.safety.python_execute_show_warning = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MAX_OUTPUT_CHARS\"):\n with suppress(ValueError):\n config.safety.python_execute_max_output_chars = int(v)\n if v := _env(\"SAFETY_PYTHON_EXECUTE_AUDIT_ENABLED\"):\n config.safety.python_execute_audit_enabled = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n\n # ── Recon: space-mapping API keys ────────────────────────────────\n # Accept short form (FOFA_KEY), BUGHUNTER_ prefix, and BUGHUNTER_ prefix;\n # short form wins if multiple are set.\n for field, names in {\n \"fofa_email\": (\"FOFA_EMAIL\", \"BUGHUNTER_RECON_FOFA_EMAIL\", \"BUGHUNTER_RECON_FOFA_EMAIL\"),\n \"fofa_key\": (\"FOFA_KEY\", \"BUGHUNTER_RECON_FOFA_KEY\", \"BUGHUNTER_RECON_FOFA_KEY\"),\n \"hunter_key\": (\"HUNTER_KEY\", \"BUGHUNTER_RECON_HUNTER_KEY\", \"BUGHUNTER_RECON_HUNTER_KEY\"),\n \"quake_key\": (\"QUAKE_KEY\", \"BUGHUNTER_RECON_QUAKE_KEY\", \"BUGHUNTER_RECON_QUAKE_KEY\"),\n \"zoomeye_key\": (\"ZOOMEYE_KEY\", \"BUGHUNTER_RECON_ZOOMEYE_KEY\", \"BUGHUNTER_RECON_ZOOMEYE_KEY\"),\n \"shodan_key\": (\"SHODAN_KEY\", \"BUGHUNTER_RECON_SHODAN_KEY\", \"BUGHUNTER_RECON_SHODAN_KEY\"),\n \"zerozone_key\": (\"ZEROZONE_KEY\", \"BUGHUNTER_RECON_ZEROZONE_KEY\", \"BUGHUNTER_RECON_ZEROZONE_KEY\"),\n }.items():\n for env_name in names:\n if v := os.environ.get(env_name):\n setattr(config.recon, field, v)\n break\n\n # ── Fallback providers: auto-detect from env vars ────────────────\n # Only auto-build if no fallback providers were loaded from config file\n if not config.llm.fallback_providers:\n _auto_fallbacks: list[FallbackProviderConfig] = []\n\n # Detect OpenRouter keys\n or_key = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n or_key1 = os.environ.get(\"OPENROUTER_API_KEY1\", \"\")\n if or_key:\n extra_keys = [or_key1] if or_key1 else []\n _auto_fallbacks.append(FallbackProviderConfig(\n name=\"openrouter\",\n api_key=or_key,\n api_keys=extra_keys,\n base_url=\"https://openrouter.ai/api/v1\",\n model=\"\", # use same model as primary\n priority=1,\n ))\n\n # Detect NVIDIA key (as fallback when primary is NOT NVIDIA)\n nvidia_key = os.environ.get(\"NVIDIA_API_KEY\", \"\")\n primary_is_nvidia = (\n \"nvidia\" in config.llm.base_url.lower()\n or config.llm.provider == \"nvidia\"\n )\n if nvidia_key and not primary_is_nvidia:\n _auto_fallbacks.append(FallbackProviderConfig(\n name=\"nvidia\",\n api_key=nvidia_key,\n base_url=\"https://integrate.api.nvidia.com/v1\",\n model=\"\",\n priority=2,\n ))\n\n # Sort by priority and assign\n _auto_fallbacks.sort(key=lambda fb: fb.priority)\n config.llm.fallback_providers = _auto_fallbacks\n\n # ── Model Pool: auto-build cooperative pool from env vars ────────\n if not config.llm.model_pool:\n _pool: list[ModelPoolEntry] = []\n\n # NVIDIA models (via NVIDIA_API_KEY)\n nvidia_key_val = os.environ.get(\"NVIDIA_API_KEY\", \"\")\n if nvidia_key_val:\n nvidia_base = \"https://integrate.api.nvidia.com/v1\"\n _pool.extend([\n ModelPoolEntry(\n name=\"GLM 5.2\",\n provider=\"nvidia\",\n model=\"z-ai/glm-5.2\",\n role=\"primary\",\n tier=1,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"DeepSeek V4 Pro\",\n provider=\"nvidia\",\n model=\"deepseek-ai/deepseek-v4-pro\",\n role=\"secondary\",\n tier=2,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"Kimi K2.6\",\n provider=\"nvidia\",\n model=\"moonshotai/kimi-k2.6\",\n role=\"secondary\",\n tier=3,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"MiniMax M3\",\n provider=\"nvidia\",\n model=\"minimaxai/minimax-m3\",\n role=\"secondary\",\n tier=4,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"Mistral Medium 3.5\",\n provider=\"nvidia\",\n model=\"mistralai/mistral-medium-3.5-128k\",\n role=\"fast\",\n tier=5,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ])\n\n # OpenRouter models (via OPENROUTER_API_KEY)\n or_key_val = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n if or_key_val:\n or_base = \"https://openrouter.ai/api/v1\"\n _pool.extend([\n ModelPoolEntry(\n name=\"Nemotron Ultra 550B\",\n provider=\"openrouter\",\n model=\"nvidia/nemotron-3-ultra-550b-a55b:free\",\n role=\"fallback\",\n tier=10,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ModelPoolEntry(\n name=\"GPT-OSS 120B\",\n provider=\"openrouter\",\n model=\"openai/gpt-oss-120b:free\",\n role=\"fallback\",\n tier=11,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ModelPoolEntry(\n name=\"Poolside Laguna\",\n provider=\"openrouter\",\n model=\"poolside/laguna-m.1:free\",\n role=\"fallback\",\n tier=12,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ])\n\n _pool.sort(key=lambda entry: entry.tier)\n config.llm.model_pool = _pool\n\n # Set primary model from pool tier-1 entry\n if _pool:\n primary = _pool[0]\n config.llm.model = primary.model\n if primary.base_url:\n config.llm.base_url = primary.base_url\n env_key = os.environ.get(primary.api_key_env, \"\")\n if env_key:\n config.llm.api_key = env_key\n\n return config\n\n\ndef _strip_defaults(raw: dict) -> None:\n \"\"\"Remove fields that match defaults to keep config file clean.\"\"\"\n # Keep it simple — just strip known default values\n if raw.get(\"llm\", {}).get(\"api_key\") == \"\":\n raw[\"llm\"].pop(\"api_key\", None)\n # Don't strip base_url/model if provider is set — they may be provider-specific\n # Only strip if still at NVIDIA defaults (the new built-in default)\n if raw.get(\"llm\", {}).get(\"provider\") in (\"nvidia\", \"openai\"):\n if raw.get(\"llm\", {}).get(\"base_url\") in (\n \"https://integrate.api.nvidia.com/v1\",\n \"https://api.openai.com/v1\",\n ):\n raw[\"llm\"].pop(\"base_url\", None)\n if raw.get(\"llm\", {}).get(\"model\") in (\"minimaxai/minimax-m3\", \"deepseek-ai/deepseek-v4-pro\", \"z-ai/glm-5.2\", \"gpt-4o\"):\n raw[\"llm\"].pop(\"model\", None)\n # Strip auto-detected fallback providers (they are rebuilt from env each time)\n raw.get(\"llm\", {}).pop(\"fallback_providers\", None)\n raw.get(\"llm\", {}).pop(\"model_pool\", None)\n\n\n# ── Provider Management ─────────────────────────────────────────────\n\n\ndef apply_provider_preset(config: BugHunterConfig, provider_name: str) -> BugHunterConfig:\n \"\"\"Apply a provider preset, auto-filling base_url and model.\n\n Only fills fields that haven't been explicitly changed from the previous\n provider's defaults. This way, if the user manually set a model, we don't\n overwrite it unless the provider itself changed.\n \"\"\"\n # Resolve provider enum\n try:\n provider = LLMProvider(provider_name.lower())\n except ValueError:\n # Unknown provider — treat as custom, don't auto-fill\n config.llm.provider = provider_name\n return config\n\n preset = PROVIDER_PRESETS.get(provider)\n if not preset:\n return config\n\n old_provider = config.llm.provider\n config.llm.provider = provider.value\n\n # Auto-fill base_url and model only when switching providers\n # (or when they still match the old provider's defaults)\n old_preset = PROVIDER_PRESETS.get(LLMProvider(old_provider)) if old_provider else None\n\n # Fill base_url: always fill from preset on provider switch\n if preset.get(\"base_url\"):\n config.llm.base_url = preset[\"base_url\"]\n\n # Fill model: fill from preset unless user has a custom model set\n # that doesn't match the old provider's default\n if old_preset and config.llm.model != old_preset.get(\"default_model\", \"\"):\n # User has a custom model, keep it\n pass\n elif preset.get(\"default_model\"):\n config.llm.model = preset[\"default_model\"]\n\n return config\n\n\ndef list_providers() -> list[dict[str, str]]:\n \"\"\"Return all available provider presets as a list of dicts.\"\"\"\n result = []\n for provider, preset in PROVIDER_PRESETS.items():\n result.append(\n {\n \"provider\": provider.value,\n \"label\": preset.get(\"label\", provider.value),\n \"base_url\": preset.get(\"base_url\", \"\"),\n \"default_model\": preset.get(\"default_model\", \"\"),\n }\n )\n return result\n\n\ndef fetch_provider_models(base_url: str, api_key: str, timeout: float = 10.0) -> list[str]:\n \"\"\"Fetch available models from a provider's OpenAI-compatible API.\n\n Uses the OpenAI SDK's ``client.models.list()`` endpoint.\n Returns a sorted list of model ID strings. Returns an empty list\n on any error (network, auth, timeout, etc.).\n \"\"\"\n if not base_url or not api_key:\n return []\n try:\n client = make_openai_client(api_key=api_key, base_url=base_url, timeout=timeout)\n models_page = client.models.list()\n model_ids = [m.id for m in models_page if m.id]\n return sorted(model_ids)\n except Exception:\n return []\n\n\ndef fetch_provider_models_async(\n base_url: str,\n api_key: str,\n timeout: float = 10.0,\n on_result: Any = None,\n):\n \"\"\"Fetch provider models in a background thread.\n\n Calls ``fetch_provider_models()`` in a daemon thread. When the\n fetch completes, *on_result* (if provided) is called with the\n model list on the **calling** thread via ``app.call_later()``-style\n scheduling — the caller is responsible for arranging thread-safe\n delivery (e.g. by passing a lambda that uses ``call_later``).\n\n Returns the ``Thread`` object so callers can track or join it.\n \"\"\"\n import threading\n\n def _worker() -> None:\n models = fetch_provider_models(base_url, api_key, timeout)\n if on_result is not None:\n on_result(models)\n\n t = threading.Thread(target=_worker, daemon=True)\n t.start()\n return t\n"}, "14d57554168c67868642b47d480b7e55130d60b3": {"t": 1785768842.1621654, "n": 22856, "h": "c2ee3309b444b97e", "c": "\"\"\"Bug Hunter configuration management — load, save, and access settings.\n\nBuilt on the Bug Hunter engine.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom contextlib import suppress\nfrom pathlib import Path\nfrom typing import Any\n\n# Load .env file if present (before any env var reads)\ntry:\n from dotenv import load_dotenv\n load_dotenv(Path(__file__).resolve().parents[2] / \".env\", override=False)\nexcept ImportError:\n pass\n\nimport yaml\nfrom pydantic import ValidationError\n\nfrom .schema import (\n BUILTIN_MCP_SERVERS,\n PROVIDER_PRESETS,\n BugHunterConfig,\n FallbackProviderConfig,\n LLMProvider,\n MCPServerConfig,\n MCPServersConfig,\n MCPTransportConfig,\n ModelPoolEntry,\n)\n\n# ── Paths ──────────────────────────────────────────────────────────\n\nCONFIG_DIR = Path(os.environ.get(\"BUGHUNTER_CONFIG_DIR\", str(Path.home() / \".bughunter\")))\nCONFIG_FILE = CONFIG_DIR / \"config.yaml\"\nSESSIONS_DIR = CONFIG_DIR / \"sessions\"\nTARGETS_DIR = CONFIG_DIR / \"targets\"\nKB_DIR = CONFIG_DIR / \"kb\"\nSKILLS_DIR = CONFIG_DIR / \"skills\"\nWEB_TASKS_FILE = CONFIG_DIR / \"web_tasks.json\"\nSANDBOX_SESSIONS_FILE = CONFIG_DIR / \"sandbox_sessions.json\"\nPYTHON_EXECUTE_AUDIT_FILE = CONFIG_DIR / \"python_execute_audit.jsonl\"\nDEFAULT_OPENAI_USER_AGENT = \"Mozilla/5.0\"\n\n\ndef ensure_dirs() -> None:\n \"\"\"Create Bug Hunter config directories if they don't exist.\"\"\"\n for d in [CONFIG_DIR, SESSIONS_DIR, TARGETS_DIR, KB_DIR, SKILLS_DIR]:\n d.mkdir(parents=True, exist_ok=True)\n\n\ndef openai_default_headers(llm_config: Any = None) -> dict[str, str]:\n from bughunter.agent.tee_privacy import build_tee_headers\n\n headers = {\"User-Agent\": os.environ.get(\"BUGHUNTER_LLM_USER_AGENT\", DEFAULT_OPENAI_USER_AGENT)}\n headers.update(build_tee_headers(llm_config))\n return headers\n\n\ndef make_openai_client(api_key: str, base_url: str, timeout: float | None = None, llm_config: Any = None):\n from openai import OpenAI\n\n effective_url = base_url\n tee_cfg = getattr(llm_config, \"tee\", None) if llm_config is not None else None\n if tee_cfg and tee_cfg.enabled and tee_cfg.proxy_url:\n effective_url = tee_cfg.proxy_url\n elif os.environ.get(\"BUGHUNTER_TEE_PROXY_URL\"):\n effective_url = os.environ[\"BUGHUNTER_TEE_PROXY_URL\"]\n\n kwargs: dict[str, Any] = {\n \"api_key\": api_key,\n \"base_url\": effective_url,\n \"default_headers\": openai_default_headers(llm_config),\n }\n if timeout is not None:\n kwargs[\"timeout\"] = timeout\n return OpenAI(**kwargs)\n\n\n# ── Load / Save ────────────────────────────────────────────────────\n\n\ndef load_config() -> BugHunterConfig:\n \"\"\"Load configuration from file + env vars.\n\n Priority: env vars > config file > built-in defaults.\n \"\"\"\n ensure_dirs()\n\n # Start with built-in defaults + registered MCP servers\n servers: dict[str, MCPServerConfig] = {}\n for name, cfg in BUILTIN_MCP_SERVERS.items():\n servers[name] = _parse_mcp_server(name, cfg)\n\n config = BugHunterConfig(mcp=MCPServersConfig(servers=servers))\n\n # Overlay from config file\n if CONFIG_FILE.exists():\n try:\n with open(CONFIG_FILE, \"r\", encoding=\"utf-8\") as f:\n raw = yaml.safe_load(f) or {}\n config = _merge_config(config, raw)\n except (yaml.YAMLError, ValidationError) as e:\n # Log warning but don't crash\n print(f\"[!] Warning: Failed to parse config file {CONFIG_FILE}: {e}\")\n\n # Overlay from env vars\n config = _overlay_env(config)\n\n return config\n\n\ndef save_config(config: BugHunterConfig) -> None:\n \"\"\"Save configuration to YAML file.\"\"\"\n ensure_dirs()\n raw = config.model_dump(mode=\"json\")\n # Remove default values to keep config clean\n _strip_defaults(raw)\n with open(CONFIG_FILE, \"w\", encoding=\"utf-8\") as f:\n yaml.dump(raw, f, default_flow_style=False, allow_unicode=True)\n\n\ndef set_config_value(key: str, value: str) -> None:\n \"\"\"Set a nested config value using dot notation.\n\n Example: set_config_value(\"llm.api_key\", \"sk-xxx\")\n \"\"\"\n config = load_config()\n parts = key.split(\".\")\n obj: Any = config\n for part in parts[:-1]:\n obj = obj[part] if isinstance(obj, dict) else getattr(obj, part)\n field_name = parts[-1]\n\n # Type coercion based on field annotation\n model_fields = getattr(type(obj), \"model_fields\", {})\n if field_name in model_fields:\n field_info = model_fields[field_name]\n annotation = field_info.annotation\n if annotation is int:\n value = int(value)\n elif annotation is float:\n value = float(value)\n elif annotation is bool:\n value = value.lower() in (\"true\", \"1\", \"yes\")\n\n if isinstance(obj, dict):\n obj[field_name] = value\n else:\n setattr(obj, field_name, value)\n save_config(config)\n\n\n# ── Helpers ─────────────────────────────────────────────────────────\n\n\ndef _parse_mcp_server(name: str, raw: dict[str, Any]) -> MCPServerConfig:\n \"\"\"Parse a raw dict into MCPServerConfig.\"\"\"\n transport_raw = raw.get(\"transport\", {})\n return MCPServerConfig(\n name=raw.get(\"name\", name),\n enabled=raw.get(\"enabled\", True),\n priority=raw.get(\"priority\", 1),\n description=raw.get(\"description\", \"\"),\n transport=MCPTransportConfig(\n type=transport_raw.get(\"type\", \"stdio\"),\n command=transport_raw.get(\"command\"),\n args=transport_raw.get(\"args\"),\n url=transport_raw.get(\"url\"),\n env=transport_raw.get(\"env\"),\n startup_timeout=transport_raw.get(\"startup_timeout\", 30000),\n tool_timeout=transport_raw.get(\"tool_timeout\", 300000),\n ),\n )\n\n\ndef _merge_config(base: BugHunterConfig, raw: dict[str, Any]) -> BugHunterConfig:\n \"\"\"Merge raw dict into existing config, preserving unset defaults.\"\"\"\n data = base.model_dump(mode=\"json\")\n\n # Deep merge\n _deep_merge(data, raw)\n\n try:\n return BugHunterConfig(**data)\n except ValidationError:\n # If merged data is invalid, return base\n return base\n\n\ndef _deep_merge(base: dict, override: dict) -> None:\n \"\"\"Recursively merge override into base (mutates base).\"\"\"\n for key, val in override.items():\n if key in base and isinstance(base[key], dict) and isinstance(val, dict):\n _deep_merge(base[key], val)\n else:\n base[key] = val\n\n\ndef _overlay_env(config: BugHunterConfig) -> BugHunterConfig:\n \"\"\"Overlay environment variables onto config.\n\n Supported env var prefixes (checked in order): BUGHUNTER_, BUGHUNTER_\n LLM: API_KEY, BASE_URL, MODEL, PROVIDER, MAX_TOKENS, MAX_CONTEXT_TOKENS, TEMPERATURE\n Session: OUTPUT_DIR, AUTO_SAVE, REPORT_FORMAT, MAX_ROUNDS, SHOW_THINKING\n Safety: PYTHON_EXECUTE_ENABLED, PYTHON_EXECUTE_RESTRICTED, PYTHON_EXECUTE_MODE,\n PYTHON_EXECUTE_MAX_LINES, PYTHON_EXECUTE_SHOW_WARNING,\n PYTHON_EXECUTE_MAX_OUTPUT_CHARS, PYTHON_EXECUTE_AUDIT_ENABLED\n \"\"\"\n\n def _env(suffix: str) -> str | None:\n \"\"\"Check BUGHUNTER_ prefix first, then BUGHUNTER_ fallback.\"\"\"\n return os.environ.get(f\"BUGHUNTER_{suffix}\") or os.environ.get(f\"BUGHUNTER_{suffix}\")\n\n # ── LLM ──────────────────────────────────────────────────────────\n if v := _env(\"LLM_API_KEY\"):\n config.llm.api_key = v\n if v := _env(\"LLM_BASE_URL\"):\n config.llm.base_url = v\n if v := _env(\"LLM_MODEL\"):\n config.llm.model = v\n if v := _env(\"LLM_PROVIDER\"):\n config.llm.provider = v\n if v := _env(\"LLM_MAX_TOKENS\"):\n with suppress(ValueError):\n config.llm.max_tokens = int(v)\n if v := _env(\"LLM_MAX_CONTEXT_TOKENS\"):\n with suppress(ValueError):\n config.llm.max_context_tokens = int(v)\n if v := _env(\"LLM_TEMPERATURE\"):\n with suppress(ValueError):\n config.llm.temperature = float(v)\n\n # ── TEE & Privacy ────────────────────────────────────────────────\n _truthy = (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"TEE_ENABLED\"):\n config.llm.tee.enabled = v.lower() in _truthy\n if v := _env(\"ZERO_DATA_RETENTION\"):\n config.llm.tee.zero_data_retention = v.lower() in _truthy\n if v := _env(\"TEE_MODE\"):\n config.llm.tee.mode = v\n if v := _env(\"TEE_PROXY_URL\"):\n config.llm.tee.proxy_url = v\n if v := _env(\"TEE_ATTESTATION_TOKEN\"):\n config.llm.tee.attestation_token = v\n if v := _env(\"TEE_ANONYMIZE\"):\n config.llm.tee.anonymize_sensitive_data = v.lower() in _truthy\n\n # ── Session ──────────────────────────────────────────────────────\n if v := _env(\"SESSION_OUTPUT_DIR\"):\n config.session.output_dir = Path(v)\n if v := _env(\"SESSION_AUTO_SAVE\"):\n config.session.auto_save = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_REPORT_FORMAT\"):\n config.session.report_format = v\n if v := _env(\"SESSION_MAX_ROUNDS\"):\n with suppress(ValueError):\n config.session.max_rounds = int(v)\n if v := _env(\"SESSION_SHOW_THINKING\"):\n config.session.show_thinking = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_STALE_ROUNDS_THRESHOLD\"):\n with suppress(ValueError):\n config.session.stale_rounds_threshold = int(v)\n\n # ── Session: Reasoning state / Reflection engine / Plugin runtime ──────────────\n _truthy = (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_REASONING_STATE_ENABLED\"):\n config.session.reasoning_state_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_REFLEXION_ENABLED\"):\n config.session.reflexion_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_REFLEXION_MAX_SAME_VULN_FAILS\"):\n with suppress(ValueError):\n config.session.reflexion_max_same_vuln_fails = int(v)\n if v := _env(\"SESSION_REFLEXION_MAX_TOTAL_NO_PROGRESS\"):\n with suppress(ValueError):\n config.session.reflexion_max_total_no_progress = int(v)\n if v := _env(\"SESSION_ESCALATION_MAX_LEVEL\"):\n with suppress(ValueError):\n config.session.escalation_max_level = int(v)\n if v := _env(\"SESSION_PLUGIN_RUNTIME_ENABLED\"):\n config.session.plugin_runtime_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_PLUGIN_DEFAULT_TIMEOUT\"):\n with suppress(ValueError):\n config.session.plugin_default_timeout = int(v)\n if v := _env(\"SESSION_PLUGIN_MAX_REQUESTS_PER_TARGET\"):\n with suppress(ValueError):\n config.session.plugin_max_requests_per_target = int(v)\n if v := _env(\"SESSION_EVIDENCE_MIN_REPORT_LEVEL\"):\n config.session.evidence_min_report_level = v\n\n # ── Safety ───────────────────────────────────────────────────────\n if v := _env(\"SAFETY_PYTHON_EXECUTE_ENABLED\"):\n config.safety.enable_python_execute = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_RESTRICTED\"):\n config.safety.python_execute_restricted = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MODE\"):\n config.safety.python_execute_mode = v\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MAX_LINES\"):\n with suppress(ValueError):\n config.safety.python_execute_max_lines = int(v)\n if v := _env(\"SAFETY_PYTHON_EXECUTE_SHOW_WARNING\"):\n config.safety.python_execute_show_warning = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MAX_OUTPUT_CHARS\"):\n with suppress(ValueError):\n config.safety.python_execute_max_output_chars = int(v)\n if v := _env(\"SAFETY_PYTHON_EXECUTE_AUDIT_ENABLED\"):\n config.safety.python_execute_audit_enabled = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n\n # ── Recon: space-mapping API keys ────────────────────────────────\n # Accept short form (FOFA_KEY), BUGHUNTER_ prefix, and BUGHUNTER_ prefix;\n # short form wins if multiple are set.\n for field, names in {\n \"fofa_email\": (\"FOFA_EMAIL\", \"BUGHUNTER_RECON_FOFA_EMAIL\", \"BUGHUNTER_RECON_FOFA_EMAIL\"),\n \"fofa_key\": (\"FOFA_KEY\", \"BUGHUNTER_RECON_FOFA_KEY\", \"BUGHUNTER_RECON_FOFA_KEY\"),\n \"hunter_key\": (\"HUNTER_KEY\", \"BUGHUNTER_RECON_HUNTER_KEY\", \"BUGHUNTER_RECON_HUNTER_KEY\"),\n \"quake_key\": (\"QUAKE_KEY\", \"BUGHUNTER_RECON_QUAKE_KEY\", \"BUGHUNTER_RECON_QUAKE_KEY\"),\n \"zoomeye_key\": (\"ZOOMEYE_KEY\", \"BUGHUNTER_RECON_ZOOMEYE_KEY\", \"BUGHUNTER_RECON_ZOOMEYE_KEY\"),\n \"shodan_key\": (\"SHODAN_KEY\", \"BUGHUNTER_RECON_SHODAN_KEY\", \"BUGHUNTER_RECON_SHODAN_KEY\"),\n \"zerozone_key\": (\"ZEROZONE_KEY\", \"BUGHUNTER_RECON_ZEROZONE_KEY\", \"BUGHUNTER_RECON_ZEROZONE_KEY\"),\n }.items():\n for env_name in names:\n if v := os.environ.get(env_name):\n setattr(config.recon, field, v)\n break\n\n # ── Fallback providers: auto-detect from env vars ────────────────\n # Only auto-build if no fallback providers were loaded from config file\n if not config.llm.fallback_providers:\n _auto_fallbacks: list[FallbackProviderConfig] = []\n\n # Detect OpenRouter keys\n or_key = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n or_key1 = os.environ.get(\"OPENROUTER_API_KEY1\", \"\")\n if or_key:\n extra_keys = [or_key1] if or_key1 else []\n _auto_fallbacks.append(FallbackProviderConfig(\n name=\"openrouter\",\n api_key=or_key,\n api_keys=extra_keys,\n base_url=\"https://openrouter.ai/api/v1\",\n model=\"\", # use same model as primary\n priority=1,\n ))\n\n # Detect NVIDIA key (as fallback when primary is NOT NVIDIA)\n nvidia_key = os.environ.get(\"NVIDIA_API_KEY\", \"\")\n primary_is_nvidia = (\n \"nvidia\" in config.llm.base_url.lower()\n or config.llm.provider == \"nvidia\"\n )\n if nvidia_key and not primary_is_nvidia:\n _auto_fallbacks.append(FallbackProviderConfig(\n name=\"nvidia\",\n api_key=nvidia_key,\n base_url=\"https://integrate.api.nvidia.com/v1\",\n model=\"\",\n priority=2,\n ))\n\n # Sort by priority and assign\n _auto_fallbacks.sort(key=lambda fb: fb.priority)\n config.llm.fallback_providers = _auto_fallbacks\n\n # ── Model Pool: auto-build cooperative pool from env vars ────────\n if not config.llm.model_pool:\n _pool: list[ModelPoolEntry] = []\n\n # NVIDIA models (via NVIDIA_API_KEY)\n nvidia_key_val = os.environ.get(\"NVIDIA_API_KEY\", \"\")\n if nvidia_key_val:\n nvidia_base = \"https://integrate.api.nvidia.com/v1\"\n _pool.extend([\n ModelPoolEntry(\n name=\"GLM 5.2\",\n provider=\"nvidia\",\n model=\"z-ai/glm-5.2\",\n role=\"primary\",\n tier=1,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"DeepSeek V4 Pro\",\n provider=\"nvidia\",\n model=\"deepseek-ai/deepseek-v4-pro\",\n role=\"secondary\",\n tier=2,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"Kimi K2.6\",\n provider=\"nvidia\",\n model=\"moonshotai/kimi-k2.6\",\n role=\"secondary\",\n tier=3,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"MiniMax M3\",\n provider=\"nvidia\",\n model=\"minimaxai/minimax-m3\",\n role=\"secondary\",\n tier=4,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"Mistral Medium 3.5\",\n provider=\"nvidia\",\n model=\"mistralai/mistral-medium-3.5-128k\",\n role=\"fast\",\n tier=5,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ])\n\n # OpenRouter models (via OPENROUTER_API_KEY)\n or_key_val = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n if or_key_val:\n or_base = \"https://openrouter.ai/api/v1\"\n _pool.extend([\n ModelPoolEntry(\n name=\"Nemotron Ultra 550B\",\n provider=\"openrouter\",\n model=\"nvidia/nemotron-3-ultra-550b-a55b:free\",\n role=\"fallback\",\n tier=10,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ModelPoolEntry(\n name=\"GPT-OSS 120B\",\n provider=\"openrouter\",\n model=\"openai/gpt-oss-120b:free\",\n role=\"fallback\",\n tier=11,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ModelPoolEntry(\n name=\"Poolside Laguna\",\n provider=\"openrouter\",\n model=\"poolside/laguna-m.1:free\",\n role=\"fallback\",\n tier=12,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ])\n\n _pool.sort(key=lambda entry: entry.tier)\n config.llm.model_pool = _pool\n\n # Set primary model from pool tier-1 entry\n if _pool:\n primary = _pool[0]\n config.llm.model = primary.model\n if primary.base_url:\n config.llm.base_url = primary.base_url\n env_key = os.environ.get(primary.api_key_env, \"\")\n if env_key:\n config.llm.api_key = env_key\n\n return config\n\n\ndef _strip_defaults(raw: dict) -> None:\n \"\"\"Remove fields that match defaults to keep config file clean.\"\"\"\n # Keep it simple — just strip known default values\n if raw.get(\"llm\", {}).get(\"api_key\") == \"\":\n raw[\"llm\"].pop(\"api_key\", None)\n # Don't strip base_url/model if provider is set — they may be provider-specific\n # Only strip if still at NVIDIA defaults (the new built-in default)\n if raw.get(\"llm\", {}).get(\"provider\") in (\"nvidia\", \"openai\"):\n if raw.get(\"llm\", {}).get(\"base_url\") in (\n \"https://integrate.api.nvidia.com/v1\",\n \"https://api.openai.com/v1\",\n ):\n raw[\"llm\"].pop(\"base_url\", None)\n if raw.get(\"llm\", {}).get(\"model\") in (\"minimaxai/minimax-m3\", \"deepseek-ai/deepseek-v4-pro\", \"z-ai/glm-5.2\", \"gpt-4o\"):\n raw[\"llm\"].pop(\"model\", None)\n # Strip auto-detected fallback providers (they are rebuilt from env each time)\n raw.get(\"llm\", {}).pop(\"fallback_providers\", None)\n raw.get(\"llm\", {}).pop(\"model_pool\", None)\n\n\n# ── Provider Management ─────────────────────────────────────────────\n\n\ndef apply_provider_preset(config: BugHunterConfig, provider_name: str) -> BugHunterConfig:\n \"\"\"Apply a provider preset, auto-filling base_url and model.\n\n Only fills fields that haven't been explicitly changed from the previous\n provider's defaults. This way, if the user manually set a model, we don't\n overwrite it unless the provider itself changed.\n \"\"\"\n # Resolve provider enum\n try:\n provider = LLMProvider(provider_name.lower())\n except ValueError:\n # Unknown provider — treat as custom, don't auto-fill\n config.llm.provider = provider_name\n return config\n\n preset = PROVIDER_PRESETS.get(provider)\n if not preset:\n return config\n\n old_provider = config.llm.provider\n config.llm.provider = provider.value\n\n # Auto-fill base_url and model only when switching providers\n # (or when they still match the old provider's defaults)\n old_preset = PROVIDER_PRESETS.get(LLMProvider(old_provider)) if old_provider else None\n\n # Fill base_url: always fill from preset on provider switch\n if preset.get(\"base_url\"):\n config.llm.base_url = preset[\"base_url\"]\n\n # Fill model: fill from preset unless user has a custom model set\n # that doesn't match the old provider's default\n if old_preset and config.llm.model != old_preset.get(\"default_model\", \"\"):\n # User has a custom model, keep it\n pass\n elif preset.get(\"default_model\"):\n config.llm.model = preset[\"default_model\"]\n\n return config\n\n\ndef list_providers() -> list[dict[str, str]]:\n \"\"\"Return all available provider presets as a list of dicts.\"\"\"\n result = []\n for provider, preset in PROVIDER_PRESETS.items():\n result.append(\n {\n \"provider\": provider.value,\n \"label\": preset.get(\"label\", provider.value),\n \"base_url\": preset.get(\"base_url\", \"\"),\n \"default_model\": preset.get(\"default_model\", \"\"),\n }\n )\n return result\n\n\ndef fetch_provider_models(base_url: str, api_key: str, timeout: float = 10.0) -> list[str]:\n \"\"\"Fetch available models from a provider's OpenAI-compatible API.\n\n Uses the OpenAI SDK's ``client.models.list()`` endpoint.\n Returns a sorted list of model ID strings. Returns an empty list\n on any error (network, auth, timeout, etc.).\n \"\"\"\n if not base_url or not api_key:\n return []\n try:\n client = make_openai_client(api_key=api_key, base_url=base_url, timeout=timeout)\n models_page = client.models.list()\n model_ids = [m.id for m in models_page if m.id]\n return sorted(model_ids)\n except Exception:\n return []\n\n\ndef fetch_provider_models_async(\n base_url: str,\n api_key: str,\n timeout: float = 10.0,\n on_result: Any = None,\n):\n \"\"\"Fetch provider models in a background thread.\n\n Calls ``fetch_provider_models()`` in a daemon thread. When the\n fetch completes, *on_result* (if provided) is called with the\n model list on the **calling** thread via ``app.call_later()``-style\n scheduling — the caller is responsible for arranging thread-safe\n delivery (e.g. by passing a lambda that uses ``call_later``).\n\n Returns the ``Thread`` object so callers can track or join it.\n \"\"\"\n import threading\n\n def _worker() -> None:\n models = fetch_provider_models(base_url, api_key, timeout)\n if on_result is not None:\n on_result(models)\n\n t = threading.Thread(target=_worker, daemon=True)\n t.start()\n return t\n"}, "a5873d53e0d2b0cea405ef9c59018f08502ec852": {"t": 1785768843.810289, "n": 21697, "h": "d12ce0a087c35a5d", "c": "\"\"\"Bug Hunter configuration management — load, save, and access settings.\n\nBuilt on the Bug Hunter engine.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom contextlib import suppress\nfrom pathlib import Path\nfrom typing import Any\n\n# Load .env file if present (before any env var reads)\ntry:\n from dotenv import load_dotenv\n load_dotenv(Path(__file__).resolve().parents[2] / \".env\", override=False)\nexcept ImportError:\n pass\n\nimport yaml\nfrom pydantic import ValidationError\n\nfrom .schema import (\n BUILTIN_MCP_SERVERS,\n PROVIDER_PRESETS,\n BugHunterConfig,\n FallbackProviderConfig,\n LLMProvider,\n MCPServerConfig,\n MCPServersConfig,\n MCPTransportConfig,\n ModelPoolEntry,\n)\n\n# ── Paths ──────────────────────────────────────────────────────────\n\nCONFIG_DIR = Path(os.environ.get(\"BUGHUNTER_CONFIG_DIR\", str(Path.home() / \".bughunter\")))\nCONFIG_FILE = CONFIG_DIR / \"config.yaml\"\nSESSIONS_DIR = CONFIG_DIR / \"sessions\"\nTARGETS_DIR = CONFIG_DIR / \"targets\"\nKB_DIR = CONFIG_DIR / \"kb\"\nSKILLS_DIR = CONFIG_DIR / \"skills\"\nWEB_TASKS_FILE = CONFIG_DIR / \"web_tasks.json\"\nSANDBOX_SESSIONS_FILE = CONFIG_DIR / \"sandbox_sessions.json\"\nPYTHON_EXECUTE_AUDIT_FILE = CONFIG_DIR / \"python_execute_audit.jsonl\"\nDEFAULT_OPENAI_USER_AGENT = \"Mozilla/5.0\"\n\n\ndef ensure_dirs() -> None:\n \"\"\"Create Bug Hunter config directories if they don't exist.\"\"\"\n for d in [CONFIG_DIR, SESSIONS_DIR, TARGETS_DIR, KB_DIR, SKILLS_DIR]:\n d.mkdir(parents=True, exist_ok=True)\n\n\ndef openai_default_headers() -> dict[str, str]:\n return {\"User-Agent\": os.environ.get(\"BUGHUNTER_LLM_USER_AGENT\", DEFAULT_OPENAI_USER_AGENT)}\n\n\ndef make_openai_client(api_key: str, base_url: str, timeout: float | None = None):\n from openai import OpenAI\n\n kwargs: dict[str, Any] = {\n \"api_key\": api_key,\n \"base_url\": base_url,\n \"default_headers\": openai_default_headers(),\n }\n if timeout is not None:\n kwargs[\"timeout\"] = timeout\n return OpenAI(**kwargs)\n\n\n# ── Load / Save ────────────────────────────────────────────────────\n\n\ndef load_config() -> BugHunterConfig:\n \"\"\"Load configuration from file + env vars.\n\n Priority: env vars > config file > built-in defaults.\n \"\"\"\n ensure_dirs()\n\n # Start with built-in defaults + registered MCP servers\n servers: dict[str, MCPServerConfig] = {}\n for name, cfg in BUILTIN_MCP_SERVERS.items():\n servers[name] = _parse_mcp_server(name, cfg)\n\n config = BugHunterConfig(mcp=MCPServersConfig(servers=servers))\n\n # Overlay from config file\n if CONFIG_FILE.exists():\n try:\n with open(CONFIG_FILE, \"r\", encoding=\"utf-8\") as f:\n raw = yaml.safe_load(f) or {}\n config = _merge_config(config, raw)\n except (yaml.YAMLError, ValidationError) as e:\n # Log warning but don't crash\n print(f\"[!] Warning: Failed to parse config file {CONFIG_FILE}: {e}\")\n\n # Overlay from env vars\n config = _overlay_env(config)\n\n return config\n\n\ndef save_config(config: BugHunterConfig) -> None:\n \"\"\"Save configuration to YAML file.\"\"\"\n ensure_dirs()\n raw = config.model_dump(mode=\"json\")\n # Remove default values to keep config clean\n _strip_defaults(raw)\n with open(CONFIG_FILE, \"w\", encoding=\"utf-8\") as f:\n yaml.dump(raw, f, default_flow_style=False, allow_unicode=True)\n\n\ndef set_config_value(key: str, value: str) -> None:\n \"\"\"Set a nested config value using dot notation.\n\n Example: set_config_value(\"llm.api_key\", \"sk-xxx\")\n \"\"\"\n config = load_config()\n parts = key.split(\".\")\n obj: Any = config\n for part in parts[:-1]:\n obj = obj[part] if isinstance(obj, dict) else getattr(obj, part)\n field_name = parts[-1]\n\n # Type coercion based on field annotation\n model_fields = getattr(type(obj), \"model_fields\", {})\n if field_name in model_fields:\n field_info = model_fields[field_name]\n annotation = field_info.annotation\n if annotation is int:\n value = int(value)\n elif annotation is float:\n value = float(value)\n elif annotation is bool:\n value = value.lower() in (\"true\", \"1\", \"yes\")\n\n if isinstance(obj, dict):\n obj[field_name] = value\n else:\n setattr(obj, field_name, value)\n save_config(config)\n\n\n# ── Helpers ─────────────────────────────────────────────────────────\n\n\ndef _parse_mcp_server(name: str, raw: dict[str, Any]) -> MCPServerConfig:\n \"\"\"Parse a raw dict into MCPServerConfig.\"\"\"\n transport_raw = raw.get(\"transport\", {})\n return MCPServerConfig(\n name=raw.get(\"name\", name),\n enabled=raw.get(\"enabled\", True),\n priority=raw.get(\"priority\", 1),\n description=raw.get(\"description\", \"\"),\n transport=MCPTransportConfig(\n type=transport_raw.get(\"type\", \"stdio\"),\n command=transport_raw.get(\"command\"),\n args=transport_raw.get(\"args\"),\n url=transport_raw.get(\"url\"),\n env=transport_raw.get(\"env\"),\n startup_timeout=transport_raw.get(\"startup_timeout\", 30000),\n tool_timeout=transport_raw.get(\"tool_timeout\", 300000),\n ),\n )\n\n\ndef _merge_config(base: BugHunterConfig, raw: dict[str, Any]) -> BugHunterConfig:\n \"\"\"Merge raw dict into existing config, preserving unset defaults.\"\"\"\n data = base.model_dump(mode=\"json\")\n\n # Deep merge\n _deep_merge(data, raw)\n\n try:\n return BugHunterConfig(**data)\n except ValidationError:\n # If merged data is invalid, return base\n return base\n\n\ndef _deep_merge(base: dict, override: dict) -> None:\n \"\"\"Recursively merge override into base (mutates base).\"\"\"\n for key, val in override.items():\n if key in base and isinstance(base[key], dict) and isinstance(val, dict):\n _deep_merge(base[key], val)\n else:\n base[key] = val\n\n\ndef _overlay_env(config: BugHunterConfig) -> BugHunterConfig:\n \"\"\"Overlay environment variables onto config.\n\n Supported env var prefixes (checked in order): BUGHUNTER_, BUGHUNTER_\n LLM: API_KEY, BASE_URL, MODEL, PROVIDER, MAX_TOKENS, MAX_CONTEXT_TOKENS, TEMPERATURE\n Session: OUTPUT_DIR, AUTO_SAVE, REPORT_FORMAT, MAX_ROUNDS, SHOW_THINKING\n Safety: PYTHON_EXECUTE_ENABLED, PYTHON_EXECUTE_RESTRICTED, PYTHON_EXECUTE_MODE,\n PYTHON_EXECUTE_MAX_LINES, PYTHON_EXECUTE_SHOW_WARNING,\n PYTHON_EXECUTE_MAX_OUTPUT_CHARS, PYTHON_EXECUTE_AUDIT_ENABLED\n \"\"\"\n\n def _env(suffix: str) -> str | None:\n \"\"\"Check BUGHUNTER_ prefix first, then BUGHUNTER_ fallback.\"\"\"\n return os.environ.get(f\"BUGHUNTER_{suffix}\") or os.environ.get(f\"BUGHUNTER_{suffix}\")\n\n # ── LLM ──────────────────────────────────────────────────────────\n if v := _env(\"LLM_API_KEY\"):\n config.llm.api_key = v\n if v := _env(\"LLM_BASE_URL\"):\n config.llm.base_url = v\n if v := _env(\"LLM_MODEL\"):\n config.llm.model = v\n if v := _env(\"LLM_PROVIDER\"):\n config.llm.provider = v\n if v := _env(\"LLM_MAX_TOKENS\"):\n with suppress(ValueError):\n config.llm.max_tokens = int(v)\n if v := _env(\"LLM_MAX_CONTEXT_TOKENS\"):\n with suppress(ValueError):\n config.llm.max_context_tokens = int(v)\n if v := _env(\"LLM_TEMPERATURE\"):\n with suppress(ValueError):\n config.llm.temperature = float(v)\n\n # ── Session ──────────────────────────────────────────────────────\n if v := _env(\"SESSION_OUTPUT_DIR\"):\n config.session.output_dir = Path(v)\n if v := _env(\"SESSION_AUTO_SAVE\"):\n config.session.auto_save = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_REPORT_FORMAT\"):\n config.session.report_format = v\n if v := _env(\"SESSION_MAX_ROUNDS\"):\n with suppress(ValueError):\n config.session.max_rounds = int(v)\n if v := _env(\"SESSION_SHOW_THINKING\"):\n config.session.show_thinking = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_STALE_ROUNDS_THRESHOLD\"):\n with suppress(ValueError):\n config.session.stale_rounds_threshold = int(v)\n\n # ── Session: Reasoning state / Reflection engine / Plugin runtime ──────────────\n _truthy = (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_REASONING_STATE_ENABLED\"):\n config.session.reasoning_state_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_REFLEXION_ENABLED\"):\n config.session.reflexion_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_REFLEXION_MAX_SAME_VULN_FAILS\"):\n with suppress(ValueError):\n config.session.reflexion_max_same_vuln_fails = int(v)\n if v := _env(\"SESSION_REFLEXION_MAX_TOTAL_NO_PROGRESS\"):\n with suppress(ValueError):\n config.session.reflexion_max_total_no_progress = int(v)\n if v := _env(\"SESSION_ESCALATION_MAX_LEVEL\"):\n with suppress(ValueError):\n config.session.escalation_max_level = int(v)\n if v := _env(\"SESSION_PLUGIN_RUNTIME_ENABLED\"):\n config.session.plugin_runtime_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_PLUGIN_DEFAULT_TIMEOUT\"):\n with suppress(ValueError):\n config.session.plugin_default_timeout = int(v)\n if v := _env(\"SESSION_PLUGIN_MAX_REQUESTS_PER_TARGET\"):\n with suppress(ValueError):\n config.session.plugin_max_requests_per_target = int(v)\n if v := _env(\"SESSION_EVIDENCE_MIN_REPORT_LEVEL\"):\n config.session.evidence_min_report_level = v\n\n # ── Safety ───────────────────────────────────────────────────────\n if v := _env(\"SAFETY_PYTHON_EXECUTE_ENABLED\"):\n config.safety.enable_python_execute = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_RESTRICTED\"):\n config.safety.python_execute_restricted = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MODE\"):\n config.safety.python_execute_mode = v\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MAX_LINES\"):\n with suppress(ValueError):\n config.safety.python_execute_max_lines = int(v)\n if v := _env(\"SAFETY_PYTHON_EXECUTE_SHOW_WARNING\"):\n config.safety.python_execute_show_warning = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MAX_OUTPUT_CHARS\"):\n with suppress(ValueError):\n config.safety.python_execute_max_output_chars = int(v)\n if v := _env(\"SAFETY_PYTHON_EXECUTE_AUDIT_ENABLED\"):\n config.safety.python_execute_audit_enabled = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n\n # ── Recon: space-mapping API keys ────────────────────────────────\n # Accept short form (FOFA_KEY), BUGHUNTER_ prefix, and BUGHUNTER_ prefix;\n # short form wins if multiple are set.\n for field, names in {\n \"fofa_email\": (\"FOFA_EMAIL\", \"BUGHUNTER_RECON_FOFA_EMAIL\", \"BUGHUNTER_RECON_FOFA_EMAIL\"),\n \"fofa_key\": (\"FOFA_KEY\", \"BUGHUNTER_RECON_FOFA_KEY\", \"BUGHUNTER_RECON_FOFA_KEY\"),\n \"hunter_key\": (\"HUNTER_KEY\", \"BUGHUNTER_RECON_HUNTER_KEY\", \"BUGHUNTER_RECON_HUNTER_KEY\"),\n \"quake_key\": (\"QUAKE_KEY\", \"BUGHUNTER_RECON_QUAKE_KEY\", \"BUGHUNTER_RECON_QUAKE_KEY\"),\n \"zoomeye_key\": (\"ZOOMEYE_KEY\", \"BUGHUNTER_RECON_ZOOMEYE_KEY\", \"BUGHUNTER_RECON_ZOOMEYE_KEY\"),\n \"shodan_key\": (\"SHODAN_KEY\", \"BUGHUNTER_RECON_SHODAN_KEY\", \"BUGHUNTER_RECON_SHODAN_KEY\"),\n \"zerozone_key\": (\"ZEROZONE_KEY\", \"BUGHUNTER_RECON_ZEROZONE_KEY\", \"BUGHUNTER_RECON_ZEROZONE_KEY\"),\n }.items():\n for env_name in names:\n if v := os.environ.get(env_name):\n setattr(config.recon, field, v)\n break\n\n # ── Fallback providers: auto-detect from env vars ────────────────\n # Only auto-build if no fallback providers were loaded from config file\n if not config.llm.fallback_providers:\n _auto_fallbacks: list[FallbackProviderConfig] = []\n\n # Detect OpenRouter keys\n or_key = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n or_key1 = os.environ.get(\"OPENROUTER_API_KEY1\", \"\")\n if or_key:\n extra_keys = [or_key1] if or_key1 else []\n _auto_fallbacks.append(FallbackProviderConfig(\n name=\"openrouter\",\n api_key=or_key,\n api_keys=extra_keys,\n base_url=\"https://openrouter.ai/api/v1\",\n model=\"\", # use same model as primary\n priority=1,\n ))\n\n # Detect NVIDIA key (as fallback when primary is NOT NVIDIA)\n nvidia_key = os.environ.get(\"NVIDIA_API_KEY\", \"\")\n primary_is_nvidia = (\n \"nvidia\" in config.llm.base_url.lower()\n or config.llm.provider == \"nvidia\"\n )\n if nvidia_key and not primary_is_nvidia:\n _auto_fallbacks.append(FallbackProviderConfig(\n name=\"nvidia\",\n api_key=nvidia_key,\n base_url=\"https://integrate.api.nvidia.com/v1\",\n model=\"\",\n priority=2,\n ))\n\n # Sort by priority and assign\n _auto_fallbacks.sort(key=lambda fb: fb.priority)\n config.llm.fallback_providers = _auto_fallbacks\n\n # ── Model Pool: auto-build cooperative pool from env vars ────────\n if not config.llm.model_pool:\n _pool: list[ModelPoolEntry] = []\n\n # NVIDIA models (via NVIDIA_API_KEY)\n nvidia_key_val = os.environ.get(\"NVIDIA_API_KEY\", \"\")\n if nvidia_key_val:\n nvidia_base = \"https://integrate.api.nvidia.com/v1\"\n _pool.extend([\n ModelPoolEntry(\n name=\"GLM 5.2\",\n provider=\"nvidia\",\n model=\"z-ai/glm-5.2\",\n role=\"primary\",\n tier=1,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"DeepSeek V4 Pro\",\n provider=\"nvidia\",\n model=\"deepseek-ai/deepseek-v4-pro\",\n role=\"secondary\",\n tier=2,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"Kimi K2.6\",\n provider=\"nvidia\",\n model=\"moonshotai/kimi-k2.6\",\n role=\"secondary\",\n tier=3,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"MiniMax M3\",\n provider=\"nvidia\",\n model=\"minimaxai/minimax-m3\",\n role=\"secondary\",\n tier=4,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"Mistral Medium 3.5\",\n provider=\"nvidia\",\n model=\"mistralai/mistral-medium-3.5-128k\",\n role=\"fast\",\n tier=5,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ])\n\n # OpenRouter models (via OPENROUTER_API_KEY)\n or_key_val = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n if or_key_val:\n or_base = \"https://openrouter.ai/api/v1\"\n _pool.extend([\n ModelPoolEntry(\n name=\"Nemotron Ultra 550B\",\n provider=\"openrouter\",\n model=\"nvidia/nemotron-3-ultra-550b-a55b:free\",\n role=\"fallback\",\n tier=10,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ModelPoolEntry(\n name=\"GPT-OSS 120B\",\n provider=\"openrouter\",\n model=\"openai/gpt-oss-120b:free\",\n role=\"fallback\",\n tier=11,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ModelPoolEntry(\n name=\"Poolside Laguna\",\n provider=\"openrouter\",\n model=\"poolside/laguna-m.1:free\",\n role=\"fallback\",\n tier=12,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ])\n\n _pool.sort(key=lambda entry: entry.tier)\n config.llm.model_pool = _pool\n\n # Set primary model from pool tier-1 entry\n if _pool:\n primary = _pool[0]\n config.llm.model = primary.model\n if primary.base_url:\n config.llm.base_url = primary.base_url\n env_key = os.environ.get(primary.api_key_env, \"\")\n if env_key:\n config.llm.api_key = env_key\n\n return config\n\n\ndef _strip_defaults(raw: dict) -> None:\n \"\"\"Remove fields that match defaults to keep config file clean.\"\"\"\n # Keep it simple — just strip known default values\n if raw.get(\"llm\", {}).get(\"api_key\") == \"\":\n raw[\"llm\"].pop(\"api_key\", None)\n # Don't strip base_url/model if provider is set — they may be provider-specific\n # Only strip if still at NVIDIA defaults (the new built-in default)\n if raw.get(\"llm\", {}).get(\"provider\") in (\"nvidia\", \"openai\"):\n if raw.get(\"llm\", {}).get(\"base_url\") in (\n \"https://integrate.api.nvidia.com/v1\",\n \"https://api.openai.com/v1\",\n ):\n raw[\"llm\"].pop(\"base_url\", None)\n if raw.get(\"llm\", {}).get(\"model\") in (\"minimaxai/minimax-m3\", \"deepseek-ai/deepseek-v4-pro\", \"z-ai/glm-5.2\", \"gpt-4o\"):\n raw[\"llm\"].pop(\"model\", None)\n # Strip auto-detected fallback providers (they are rebuilt from env each time)\n raw.get(\"llm\", {}).pop(\"fallback_providers\", None)\n raw.get(\"llm\", {}).pop(\"model_pool\", None)\n\n\n# ── Provider Management ─────────────────────────────────────────────\n\n\ndef apply_provider_preset(config: BugHunterConfig, provider_name: str) -> BugHunterConfig:\n \"\"\"Apply a provider preset, auto-filling base_url and model.\n\n Only fills fields that haven't been explicitly changed from the previous\n provider's defaults. This way, if the user manually set a model, we don't\n overwrite it unless the provider itself changed.\n \"\"\"\n # Resolve provider enum\n try:\n provider = LLMProvider(provider_name.lower())\n except ValueError:\n # Unknown provider — treat as custom, don't auto-fill\n config.llm.provider = provider_name\n return config\n\n preset = PROVIDER_PRESETS.get(provider)\n if not preset:\n return config\n\n old_provider = config.llm.provider\n config.llm.provider = provider.value\n\n # Auto-fill base_url and model only when switching providers\n # (or when they still match the old provider's defaults)\n old_preset = PROVIDER_PRESETS.get(LLMProvider(old_provider)) if old_provider else None\n\n # Fill base_url: always fill from preset on provider switch\n if preset.get(\"base_url\"):\n config.llm.base_url = preset[\"base_url\"]\n\n # Fill model: fill from preset unless user has a custom model set\n # that doesn't match the old provider's default\n if old_preset and config.llm.model != old_preset.get(\"default_model\", \"\"):\n # User has a custom model, keep it\n pass\n elif preset.get(\"default_model\"):\n config.llm.model = preset[\"default_model\"]\n\n return config\n\n\ndef list_providers() -> list[dict[str, str]]:\n \"\"\"Return all available provider presets as a list of dicts.\"\"\"\n result = []\n for provider, preset in PROVIDER_PRESETS.items():\n result.append(\n {\n \"provider\": provider.value,\n \"label\": preset.get(\"label\", provider.value),\n \"base_url\": preset.get(\"base_url\", \"\"),\n \"default_model\": preset.get(\"default_model\", \"\"),\n }\n )\n return result\n\n\ndef fetch_provider_models(base_url: str, api_key: str, timeout: float = 10.0) -> list[str]:\n \"\"\"Fetch available models from a provider's OpenAI-compatible API.\n\n Uses the OpenAI SDK's ``client.models.list()`` endpoint.\n Returns a sorted list of model ID strings. Returns an empty list\n on any error (network, auth, timeout, etc.).\n \"\"\"\n if not base_url or not api_key:\n return []\n try:\n client = make_openai_client(api_key=api_key, base_url=base_url, timeout=timeout)\n models_page = client.models.list()\n model_ids = [m.id for m in models_page if m.id]\n return sorted(model_ids)\n except Exception:\n return []\n\n\ndef fetch_provider_models_async(\n base_url: str,\n api_key: str,\n timeout: float = 10.0,\n on_result: Any = None,\n):\n \"\"\"Fetch provider models in a background thread.\n\n Calls ``fetch_provider_models()`` in a daemon thread. When the\n fetch completes, *on_result* (if provided) is called with the\n model list on the **calling** thread via ``app.call_later()``-style\n scheduling — the caller is responsible for arranging thread-safe\n delivery (e.g. by passing a lambda that uses ``call_later``).\n\n Returns the ``Thread`` object so callers can track or join it.\n \"\"\"\n import threading\n\n def _worker() -> None:\n models = fetch_provider_models(base_url, api_key, timeout)\n if on_result is not None:\n on_result(models)\n\n t = threading.Thread(target=_worker, daemon=True)\n t.start()\n return t\n"}, "aed49d707f8f9a5e78ec533a9dc93e7f691afa09": {"t": 1785768846.2459798, "n": 21697, "h": "4ff8f64e356bec9a", "c": "\"\"\"Bug Hunter configuration management — load, save, and access settings.\n\nBuilt on the Bug Hunter engine.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport os\nfrom contextlib import suppress\nfrom pathlib import Path\nfrom typing import Any\n\n# Load .env file if present (before any env var reads)\ntry:\n from dotenv import load_dotenv\n load_dotenv(Path(__file__).resolve().parents[2] / \".env\", override=False)\nexcept ImportError:\n pass\n\nimport yaml\nfrom pydantic import ValidationError\n\nfrom .schema import (\n BUILTIN_MCP_SERVERS,\n PROVIDER_PRESETS,\n FallbackProviderConfig,\n LLMProvider,\n MCPServerConfig,\n MCPServersConfig,\n MCPTransportConfig,\n ModelPoolEntry,\n BugHunterConfig,\n)\n\n# ── Paths ──────────────────────────────────────────────────────────\n\nCONFIG_DIR = Path(os.environ.get(\"BUGHUNTER_CONFIG_DIR\", str(Path.home() / \".bughunter\")))\nCONFIG_FILE = CONFIG_DIR / \"config.yaml\"\nSESSIONS_DIR = CONFIG_DIR / \"sessions\"\nTARGETS_DIR = CONFIG_DIR / \"targets\"\nKB_DIR = CONFIG_DIR / \"kb\"\nSKILLS_DIR = CONFIG_DIR / \"skills\"\nWEB_TASKS_FILE = CONFIG_DIR / \"web_tasks.json\"\nSANDBOX_SESSIONS_FILE = CONFIG_DIR / \"sandbox_sessions.json\"\nPYTHON_EXECUTE_AUDIT_FILE = CONFIG_DIR / \"python_execute_audit.jsonl\"\nDEFAULT_OPENAI_USER_AGENT = \"Mozilla/5.0\"\n\n\ndef ensure_dirs() -> None:\n \"\"\"Create Bug Hunter config directories if they don't exist.\"\"\"\n for d in [CONFIG_DIR, SESSIONS_DIR, TARGETS_DIR, KB_DIR, SKILLS_DIR]:\n d.mkdir(parents=True, exist_ok=True)\n\n\ndef openai_default_headers() -> dict[str, str]:\n return {\"User-Agent\": os.environ.get(\"BUGHUNTER_LLM_USER_AGENT\", DEFAULT_OPENAI_USER_AGENT)}\n\n\ndef make_openai_client(api_key: str, base_url: str, timeout: float | None = None):\n from openai import OpenAI\n\n kwargs: dict[str, Any] = {\n \"api_key\": api_key,\n \"base_url\": base_url,\n \"default_headers\": openai_default_headers(),\n }\n if timeout is not None:\n kwargs[\"timeout\"] = timeout\n return OpenAI(**kwargs)\n\n\n# ── Load / Save ────────────────────────────────────────────────────\n\n\ndef load_config() -> BugHunterConfig:\n \"\"\"Load configuration from file + env vars.\n\n Priority: env vars > config file > built-in defaults.\n \"\"\"\n ensure_dirs()\n\n # Start with built-in defaults + registered MCP servers\n servers: dict[str, MCPServerConfig] = {}\n for name, cfg in BUILTIN_MCP_SERVERS.items():\n servers[name] = _parse_mcp_server(name, cfg)\n\n config = BugHunterConfig(mcp=MCPServersConfig(servers=servers))\n\n # Overlay from config file\n if CONFIG_FILE.exists():\n try:\n with open(CONFIG_FILE, \"r\", encoding=\"utf-8\") as f:\n raw = yaml.safe_load(f) or {}\n config = _merge_config(config, raw)\n except (yaml.YAMLError, ValidationError) as e:\n # Log warning but don't crash\n print(f\"[!] Warning: Failed to parse config file {CONFIG_FILE}: {e}\")\n\n # Overlay from env vars\n config = _overlay_env(config)\n\n return config\n\n\ndef save_config(config: BugHunterConfig) -> None:\n \"\"\"Save configuration to YAML file.\"\"\"\n ensure_dirs()\n raw = config.model_dump(mode=\"json\")\n # Remove default values to keep config clean\n _strip_defaults(raw)\n with open(CONFIG_FILE, \"w\", encoding=\"utf-8\") as f:\n yaml.dump(raw, f, default_flow_style=False, allow_unicode=True)\n\n\ndef set_config_value(key: str, value: str) -> None:\n \"\"\"Set a nested config value using dot notation.\n\n Example: set_config_value(\"llm.api_key\", \"sk-xxx\")\n \"\"\"\n config = load_config()\n parts = key.split(\".\")\n obj: Any = config\n for part in parts[:-1]:\n obj = obj[part] if isinstance(obj, dict) else getattr(obj, part)\n field_name = parts[-1]\n\n # Type coercion based on field annotation\n model_fields = getattr(type(obj), \"model_fields\", {})\n if field_name in model_fields:\n field_info = model_fields[field_name]\n annotation = field_info.annotation\n if annotation is int:\n value = int(value)\n elif annotation is float:\n value = float(value)\n elif annotation is bool:\n value = value.lower() in (\"true\", \"1\", \"yes\")\n\n if isinstance(obj, dict):\n obj[field_name] = value\n else:\n setattr(obj, field_name, value)\n save_config(config)\n\n\n# ── Helpers ─────────────────────────────────────────────────────────\n\n\ndef _parse_mcp_server(name: str, raw: dict[str, Any]) -> MCPServerConfig:\n \"\"\"Parse a raw dict into MCPServerConfig.\"\"\"\n transport_raw = raw.get(\"transport\", {})\n return MCPServerConfig(\n name=raw.get(\"name\", name),\n enabled=raw.get(\"enabled\", True),\n priority=raw.get(\"priority\", 1),\n description=raw.get(\"description\", \"\"),\n transport=MCPTransportConfig(\n type=transport_raw.get(\"type\", \"stdio\"),\n command=transport_raw.get(\"command\"),\n args=transport_raw.get(\"args\"),\n url=transport_raw.get(\"url\"),\n env=transport_raw.get(\"env\"),\n startup_timeout=transport_raw.get(\"startup_timeout\", 30000),\n tool_timeout=transport_raw.get(\"tool_timeout\", 300000),\n ),\n )\n\n\ndef _merge_config(base: BugHunterConfig, raw: dict[str, Any]) -> BugHunterConfig:\n \"\"\"Merge raw dict into existing config, preserving unset defaults.\"\"\"\n data = base.model_dump(mode=\"json\")\n\n # Deep merge\n _deep_merge(data, raw)\n\n try:\n return BugHunterConfig(**data)\n except ValidationError:\n # If merged data is invalid, return base\n return base\n\n\ndef _deep_merge(base: dict, override: dict) -> None:\n \"\"\"Recursively merge override into base (mutates base).\"\"\"\n for key, val in override.items():\n if key in base and isinstance(base[key], dict) and isinstance(val, dict):\n _deep_merge(base[key], val)\n else:\n base[key] = val\n\n\ndef _overlay_env(config: BugHunterConfig) -> BugHunterConfig:\n \"\"\"Overlay environment variables onto config.\n\n Supported env var prefixes (checked in order): BUGHUNTER_, BUGHUNTER_\n LLM: API_KEY, BASE_URL, MODEL, PROVIDER, MAX_TOKENS, MAX_CONTEXT_TOKENS, TEMPERATURE\n Session: OUTPUT_DIR, AUTO_SAVE, REPORT_FORMAT, MAX_ROUNDS, SHOW_THINKING\n Safety: PYTHON_EXECUTE_ENABLED, PYTHON_EXECUTE_RESTRICTED, PYTHON_EXECUTE_MODE,\n PYTHON_EXECUTE_MAX_LINES, PYTHON_EXECUTE_SHOW_WARNING,\n PYTHON_EXECUTE_MAX_OUTPUT_CHARS, PYTHON_EXECUTE_AUDIT_ENABLED\n \"\"\"\n\n def _env(suffix: str) -> str | None:\n \"\"\"Check BUGHUNTER_ prefix first, then BUGHUNTER_ fallback.\"\"\"\n return os.environ.get(f\"BUGHUNTER_{suffix}\") or os.environ.get(f\"BUGHUNTER_{suffix}\")\n\n # ── LLM ──────────────────────────────────────────────────────────\n if v := _env(\"LLM_API_KEY\"):\n config.llm.api_key = v\n if v := _env(\"LLM_BASE_URL\"):\n config.llm.base_url = v\n if v := _env(\"LLM_MODEL\"):\n config.llm.model = v\n if v := _env(\"LLM_PROVIDER\"):\n config.llm.provider = v\n if v := _env(\"LLM_MAX_TOKENS\"):\n with suppress(ValueError):\n config.llm.max_tokens = int(v)\n if v := _env(\"LLM_MAX_CONTEXT_TOKENS\"):\n with suppress(ValueError):\n config.llm.max_context_tokens = int(v)\n if v := _env(\"LLM_TEMPERATURE\"):\n with suppress(ValueError):\n config.llm.temperature = float(v)\n\n # ── Session ──────────────────────────────────────────────────────\n if v := _env(\"SESSION_OUTPUT_DIR\"):\n config.session.output_dir = Path(v)\n if v := _env(\"SESSION_AUTO_SAVE\"):\n config.session.auto_save = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_REPORT_FORMAT\"):\n config.session.report_format = v\n if v := _env(\"SESSION_MAX_ROUNDS\"):\n with suppress(ValueError):\n config.session.max_rounds = int(v)\n if v := _env(\"SESSION_SHOW_THINKING\"):\n config.session.show_thinking = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_STALE_ROUNDS_THRESHOLD\"):\n with suppress(ValueError):\n config.session.stale_rounds_threshold = int(v)\n\n # ── Session: Reasoning state / Reflection engine / Plugin runtime ──────────────\n _truthy = (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SESSION_REASONING_STATE_ENABLED\"):\n config.session.reasoning_state_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_REFLEXION_ENABLED\"):\n config.session.reflexion_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_REFLEXION_MAX_SAME_VULN_FAILS\"):\n with suppress(ValueError):\n config.session.reflexion_max_same_vuln_fails = int(v)\n if v := _env(\"SESSION_REFLEXION_MAX_TOTAL_NO_PROGRESS\"):\n with suppress(ValueError):\n config.session.reflexion_max_total_no_progress = int(v)\n if v := _env(\"SESSION_ESCALATION_MAX_LEVEL\"):\n with suppress(ValueError):\n config.session.escalation_max_level = int(v)\n if v := _env(\"SESSION_PLUGIN_RUNTIME_ENABLED\"):\n config.session.plugin_runtime_enabled = v.lower() in _truthy\n if v := _env(\"SESSION_PLUGIN_DEFAULT_TIMEOUT\"):\n with suppress(ValueError):\n config.session.plugin_default_timeout = int(v)\n if v := _env(\"SESSION_PLUGIN_MAX_REQUESTS_PER_TARGET\"):\n with suppress(ValueError):\n config.session.plugin_max_requests_per_target = int(v)\n if v := _env(\"SESSION_EVIDENCE_MIN_REPORT_LEVEL\"):\n config.session.evidence_min_report_level = v\n\n # ── Safety ───────────────────────────────────────────────────────\n if v := _env(\"SAFETY_PYTHON_EXECUTE_ENABLED\"):\n config.safety.enable_python_execute = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_RESTRICTED\"):\n config.safety.python_execute_restricted = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MODE\"):\n config.safety.python_execute_mode = v\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MAX_LINES\"):\n with suppress(ValueError):\n config.safety.python_execute_max_lines = int(v)\n if v := _env(\"SAFETY_PYTHON_EXECUTE_SHOW_WARNING\"):\n config.safety.python_execute_show_warning = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n if v := _env(\"SAFETY_PYTHON_EXECUTE_MAX_OUTPUT_CHARS\"):\n with suppress(ValueError):\n config.safety.python_execute_max_output_chars = int(v)\n if v := _env(\"SAFETY_PYTHON_EXECUTE_AUDIT_ENABLED\"):\n config.safety.python_execute_audit_enabled = v.lower() in (\"1\", \"true\", \"yes\", \"on\")\n\n # ── Recon: space-mapping API keys ────────────────────────────────\n # Accept short form (FOFA_KEY), BUGHUNTER_ prefix, and BUGHUNTER_ prefix;\n # short form wins if multiple are set.\n for field, names in {\n \"fofa_email\": (\"FOFA_EMAIL\", \"BUGHUNTER_RECON_FOFA_EMAIL\", \"BUGHUNTER_RECON_FOFA_EMAIL\"),\n \"fofa_key\": (\"FOFA_KEY\", \"BUGHUNTER_RECON_FOFA_KEY\", \"BUGHUNTER_RECON_FOFA_KEY\"),\n \"hunter_key\": (\"HUNTER_KEY\", \"BUGHUNTER_RECON_HUNTER_KEY\", \"BUGHUNTER_RECON_HUNTER_KEY\"),\n \"quake_key\": (\"QUAKE_KEY\", \"BUGHUNTER_RECON_QUAKE_KEY\", \"BUGHUNTER_RECON_QUAKE_KEY\"),\n \"zoomeye_key\": (\"ZOOMEYE_KEY\", \"BUGHUNTER_RECON_ZOOMEYE_KEY\", \"BUGHUNTER_RECON_ZOOMEYE_KEY\"),\n \"shodan_key\": (\"SHODAN_KEY\", \"BUGHUNTER_RECON_SHODAN_KEY\", \"BUGHUNTER_RECON_SHODAN_KEY\"),\n \"zerozone_key\": (\"ZEROZONE_KEY\", \"BUGHUNTER_RECON_ZEROZONE_KEY\", \"BUGHUNTER_RECON_ZEROZONE_KEY\"),\n }.items():\n for env_name in names:\n if v := os.environ.get(env_name):\n setattr(config.recon, field, v)\n break\n\n # ── Fallback providers: auto-detect from env vars ────────────────\n # Only auto-build if no fallback providers were loaded from config file\n if not config.llm.fallback_providers:\n _auto_fallbacks: list[FallbackProviderConfig] = []\n\n # Detect OpenRouter keys\n or_key = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n or_key1 = os.environ.get(\"OPENROUTER_API_KEY1\", \"\")\n if or_key:\n extra_keys = [or_key1] if or_key1 else []\n _auto_fallbacks.append(FallbackProviderConfig(\n name=\"openrouter\",\n api_key=or_key,\n api_keys=extra_keys,\n base_url=\"https://openrouter.ai/api/v1\",\n model=\"\", # use same model as primary\n priority=1,\n ))\n\n # Detect NVIDIA key (as fallback when primary is NOT NVIDIA)\n nvidia_key = os.environ.get(\"NVIDIA_API_KEY\", \"\")\n primary_is_nvidia = (\n \"nvidia\" in config.llm.base_url.lower()\n or config.llm.provider == \"nvidia\"\n )\n if nvidia_key and not primary_is_nvidia:\n _auto_fallbacks.append(FallbackProviderConfig(\n name=\"nvidia\",\n api_key=nvidia_key,\n base_url=\"https://integrate.api.nvidia.com/v1\",\n model=\"\",\n priority=2,\n ))\n\n # Sort by priority and assign\n _auto_fallbacks.sort(key=lambda fb: fb.priority)\n config.llm.fallback_providers = _auto_fallbacks\n\n # ── Model Pool: auto-build cooperative pool from env vars ────────\n if not config.llm.model_pool:\n _pool: list[ModelPoolEntry] = []\n\n # NVIDIA models (via NVIDIA_API_KEY)\n nvidia_key_val = os.environ.get(\"NVIDIA_API_KEY\", \"\")\n if nvidia_key_val:\n nvidia_base = \"https://integrate.api.nvidia.com/v1\"\n _pool.extend([\n ModelPoolEntry(\n name=\"GLM 5.2\",\n provider=\"nvidia\",\n model=\"z-ai/glm-5.2\",\n role=\"primary\",\n tier=1,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"DeepSeek V4 Pro\",\n provider=\"nvidia\",\n model=\"deepseek-ai/deepseek-v4-pro\",\n role=\"secondary\",\n tier=2,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"Kimi K2.6\",\n provider=\"nvidia\",\n model=\"moonshotai/kimi-k2.6\",\n role=\"secondary\",\n tier=3,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"MiniMax M3\",\n provider=\"nvidia\",\n model=\"minimaxai/minimax-m3\",\n role=\"secondary\",\n tier=4,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ModelPoolEntry(\n name=\"Mistral Medium 3.5\",\n provider=\"nvidia\",\n model=\"mistralai/mistral-medium-3.5-128k\",\n role=\"fast\",\n tier=5,\n api_key_env=\"NVIDIA_API_KEY\",\n base_url=nvidia_base,\n ),\n ])\n\n # OpenRouter models (via OPENROUTER_API_KEY)\n or_key_val = os.environ.get(\"OPENROUTER_API_KEY\", \"\")\n if or_key_val:\n or_base = \"https://openrouter.ai/api/v1\"\n _pool.extend([\n ModelPoolEntry(\n name=\"Nemotron Ultra 550B\",\n provider=\"openrouter\",\n model=\"nvidia/nemotron-3-ultra-550b-a55b:free\",\n role=\"fallback\",\n tier=10,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ModelPoolEntry(\n name=\"GPT-OSS 120B\",\n provider=\"openrouter\",\n model=\"openai/gpt-oss-120b:free\",\n role=\"fallback\",\n tier=11,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ModelPoolEntry(\n name=\"Poolside Laguna\",\n provider=\"openrouter\",\n model=\"poolside/laguna-m.1:free\",\n role=\"fallback\",\n tier=12,\n api_key_env=\"OPENROUTER_API_KEY\",\n base_url=or_base,\n ),\n ])\n\n _pool.sort(key=lambda entry: entry.tier)\n config.llm.model_pool = _pool\n\n # Set primary model from pool tier-1 entry\n if _pool:\n primary = _pool[0]\n config.llm.model = primary.model\n if primary.base_url:\n config.llm.base_url = primary.base_url\n env_key = os.environ.get(primary.api_key_env, \"\")\n if env_key:\n config.llm.api_key = env_key\n\n return config\n\n\ndef _strip_defaults(raw: dict) -> None:\n \"\"\"Remove fields that match defaults to keep config file clean.\"\"\"\n # Keep it simple — just strip known default values\n if raw.get(\"llm\", {}).get(\"api_key\") == \"\":\n raw[\"llm\"].pop(\"api_key\", None)\n # Don't strip base_url/model if provider is set — they may be provider-specific\n # Only strip if still at NVIDIA defaults (the new built-in default)\n if raw.get(\"llm\", {}).get(\"provider\") in (\"nvidia\", \"openai\"):\n if raw.get(\"llm\", {}).get(\"base_url\") in (\n \"https://integrate.api.nvidia.com/v1\",\n \"https://api.openai.com/v1\",\n ):\n raw[\"llm\"].pop(\"base_url\", None)\n if raw.get(\"llm\", {}).get(\"model\") in (\"minimaxai/minimax-m3\", \"deepseek-ai/deepseek-v4-pro\", \"z-ai/glm-5.2\", \"gpt-4o\"):\n raw[\"llm\"].pop(\"model\", None)\n # Strip auto-detected fallback providers (they are rebuilt from env each time)\n raw.get(\"llm\", {}).pop(\"fallback_providers\", None)\n raw.get(\"llm\", {}).pop(\"model_pool\", None)\n\n\n# ── Provider Management ─────────────────────────────────────────────\n\n\ndef apply_provider_preset(config: BugHunterConfig, provider_name: str) -> BugHunterConfig:\n \"\"\"Apply a provider preset, auto-filling base_url and model.\n\n Only fills fields that haven't been explicitly changed from the previous\n provider's defaults. This way, if the user manually set a model, we don't\n overwrite it unless the provider itself changed.\n \"\"\"\n # Resolve provider enum\n try:\n provider = LLMProvider(provider_name.lower())\n except ValueError:\n # Unknown provider — treat as custom, don't auto-fill\n config.llm.provider = provider_name\n return config\n\n preset = PROVIDER_PRESETS.get(provider)\n if not preset:\n return config\n\n old_provider = config.llm.provider\n config.llm.provider = provider.value\n\n # Auto-fill base_url and model only when switching providers\n # (or when they still match the old provider's defaults)\n old_preset = PROVIDER_PRESETS.get(LLMProvider(old_provider)) if old_provider else None\n\n # Fill base_url: always fill from preset on provider switch\n if preset.get(\"base_url\"):\n config.llm.base_url = preset[\"base_url\"]\n\n # Fill model: fill from preset unless user has a custom model set\n # that doesn't match the old provider's default\n if old_preset and config.llm.model != old_preset.get(\"default_model\", \"\"):\n # User has a custom model, keep it\n pass\n elif preset.get(\"default_model\"):\n config.llm.model = preset[\"default_model\"]\n\n return config\n\n\ndef list_providers() -> list[dict[str, str]]:\n \"\"\"Return all available provider presets as a list of dicts.\"\"\"\n result = []\n for provider, preset in PROVIDER_PRESETS.items():\n result.append(\n {\n \"provider\": provider.value,\n \"label\": preset.get(\"label\", provider.value),\n \"base_url\": preset.get(\"base_url\", \"\"),\n \"default_model\": preset.get(\"default_model\", \"\"),\n }\n )\n return result\n\n\ndef fetch_provider_models(base_url: str, api_key: str, timeout: float = 10.0) -> list[str]:\n \"\"\"Fetch available models from a provider's OpenAI-compatible API.\n\n Uses the OpenAI SDK's ``client.models.list()`` endpoint.\n Returns a sorted list of model ID strings. Returns an empty list\n on any error (network, auth, timeout, etc.).\n \"\"\"\n if not base_url or not api_key:\n return []\n try:\n client = make_openai_client(api_key=api_key, base_url=base_url, timeout=timeout)\n models_page = client.models.list()\n model_ids = [m.id for m in models_page if m.id]\n return sorted(model_ids)\n except Exception:\n return []\n\n\ndef fetch_provider_models_async(\n base_url: str,\n api_key: str,\n timeout: float = 10.0,\n on_result: Any = None,\n):\n \"\"\"Fetch provider models in a background thread.\n\n Calls ``fetch_provider_models()`` in a daemon thread. When the\n fetch completes, *on_result* (if provided) is called with the\n model list on the **calling** thread via ``app.call_later()``-style\n scheduling — the caller is responsible for arranging thread-safe\n delivery (e.g. by passing a lambda that uses ``call_later``).\n\n Returns the ``Thread`` object so callers can track or join it.\n \"\"\"\n import threading\n\n def _worker() -> None:\n models = fetch_provider_models(base_url, api_key, timeout)\n if on_result is not None:\n on_result(models)\n\n t = threading.Thread(target=_worker, daemon=True)\n t.start()\n return t\n"}}}}