{"repo":"NousResearch/hermes-agent","public_id":"2007","display_label":"Custom endpoint context length and model detection","summary":"These issues and PRs address detecting or manually configuring context window size for custom/local model endpoints, and fixing model auto-detection problems (such as llama.cpp compatibility) during onboarding and setup.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2158},"resolution":{"state":"partially_resolved","by":"pr-2158"},"freshness":{"state":"verified","last_verified_at":"2026-09-09T10:33:07+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2906666666666667},"fix_edges":[{"issue_number":2007,"pr_number":2158,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":63472,"pr_number":106462,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":106184,"pr_number":106462,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":2007,"title":"[Feature]: Manual context length entry on initial setup for custom endpoints","url":null,"state":"closed"},{"kind":"issue","number":63472,"title":"[Bug]: Desktop: /v1/models auto-detection reports \"no models\" for llama.cpp endpoints that the CLI reads fine (reads data[]?)","url":null,"state":"closed"},{"kind":"pr","number":2052,"title":"feat(setup): add context length entry for custom endpoints","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2158","number":2158}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":2158,"title":"feat: overhaul context length detection with models.dev and provider-aware resolution","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["AGENTS.md","agent/context_compressor.py","agent/model_metadata.py","agent/models_dev.py","hermes_cli/main.py","hermes_cli/setup.py","run_agent.py","tests/agent/test_model_metadata.py","tests/agent/test_models_dev.py","tests/hermes_cli/test_setup.py","tests/hermes_cli/test_setup_model_provider.py","tests/test_cli_provider_resolution.py","website/docs/user-guide/configuration.md"],"fix_hunks":[{"file":"AGENTS.md","hunks":["@@ -23,6 +23,7 @@ hermes-agent/\n │   ├── prompt_caching.py     # Anthropic prompt caching\n │   ├── auxiliary_client.py   # Auxiliary LLM client (vision, summarization)\n │   ├── model_metadata.py     # Model context lengths, token estimation\n+│   ├── models_dev.py         # models.dev registry integration (provider-aware context)\n │   ├── display.py            # KawaiiSpinner, tool preview formatting\n │   ├── skill_commands.py     # Skill slash commands (shared CLI/gateway)\n │   └── trajectory.py         # Trajectory saving helpers"]},{"file":"agent/context_compressor.py","hunks":["@@ -47,10 +47,12 @@ def __init__(\n         base_url: str = \"\",\n         api_key: str = \"\",\n         config_context_length: int | None = None,\n+        provider: str = \"\",\n     ):\n         self.model = model\n         self.base_url = base_url\n         self.api_key = [redacted:secret-assignment]\n+        self.provider = provider\n         self.threshold_percent = threshold_percent\n         self.protect_first_n = protect_first_n\n         self.protect_last_n = protect_last_n","@@ -60,6 +62,7 @@ def __init__(\n         self.context_length = get_model_context_length(\n             model, base_url=base_url, api_key=[redacted:secret-assignment]\n             config_context_length=config_context_length,\n+            provider=provider,\n         )\n         self.threshold_tokens = [redacted:secret-assignment] * threshold_percent)\n         self.compression_count = 0"]},{"file":"agent/model_metadata.py","hunks":["@@ -55,104 +55,52 @@ def _strip_provider_prefix(model: str) -> str:\n _ENDPOINT_MODEL_CACHE_TTL = 300\n \n # Descending tiers for context length probing when the model is unknown.\n-# We start high and step down on context-length errors until one works.\n+# We start at 128K (a safe default for most modern models) and step down\n+# on context-length errors until one works.\n CONTEXT_PROBE_TIERS = [\n-    2_000_000,\n-    1_000_000,\n-    512_000,\n-    200_000,\n     128_000,\n     64_000,\n     32_000,\n+    16_000,\n+    8_000,\n ]\n \n+# Default context length when no detection method succeeds.\n+DEFAULT_FALLBACK_CONTEXT = CONTEXT_PROBE_TIERS[0]\n+\n+# Thin fallback defaults — only broad model family patterns.\n+# These fire only when provider is unknown AND models.dev/OpenRouter/Anthropic\n+# all miss. Replaced the previous 80+ entry dict.\n+# For provider-specific context lengths, models.dev is the primary source.\n DEFAULT_CONTEXT_LENGTHS = {\n-    \"anthropic/claude-opus-4\": 200000,\n-    \"anthropic/claude-opus-4.5\": 200000,\n-    \"anthropic/claude-opus-4.6\": 1000000,\n-    \"anthropic/claude-sonnet-4\": 200000,\n-    \"anthropic/claude-sonnet-4-20250514\": 200000,\n-    \"anthropic/claude-sonnet-4.5\": 200000,\n-    \"anthropic/claude-sonnet-4.6\": 1000000,\n-    \"anthropic/claude-haiku-4.5\": 200000,\n-    # Bare Anthropic model IDs (for native API provider)\n+    # Anthropic Claude 4.6 (1M context) — bare IDs only to avoid\n+    # fuzzy-match collisions (e.g. \"anthropic/claude-sonnet-4\" is a\n+    # substring of \"anthropic/claude-sonnet-4.6\").\n+    # OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.\n     \"claude-opus-4-6\": 1000000,\n     \"claude-sonnet-4-6\": 1000000,\n-    \"claude-opus-4-5-20251101\": 200000,\n-    \"claude-sonnet-4-5-20250929\": 200000,\n-    \"claude-opus-4-1-20250805\": 200000,\n-    \"claude-opus-4-20250514\": 200000,\n-    \"claude-sonnet-4-20250514\": 200000,\n-    \"claude-haiku-4-5-20251001\": 200000,\n-    \"openai/gpt-5\": 128000,\n-    \"openai/gpt-4.1\": 1047576,\n-    \"openai/gpt-4.1-mini\": 1047576,\n-    \"openai/gpt-4o\": 128000,\n-    \"openai/gpt-4-turbo\": 128000,\n-    \"openai/gpt-4o-mini\": 128000,\n-    \"google/gemini-3-pro-preview\": 1048576,\n-    \"google/gemini-3-flash\": 1048576,\n-    \"google/gemini-2.5-flash\": 1048576,\n-    \"google/gemini-2.0-flash\": 1048576,\n-    \"google/gemini-2.5-pro\": 1048576,\n-    \"deepseek/deepseek-v3.2\": 65536,\n-    \"meta-llama/llama-3.3-70b-instruct\": 131072,\n-    \"deepseek/deepseek-chat-v3\": 65536,\n-    \"qwen/qwen-2.5-72b-instruct\": 32768,\n-    \"glm-4.7\": 202752,\n-    \"glm-5\": 202752,\n-    \"glm-4.5\": 131072,\n-    \"glm-4.5-flash\": 131072,\n-    \"kimi-for-coding\": 262144,\n-    \"kimi-k2.5\": 262144,\n-    \"kimi-k2-thinking\": 262144,\n-    \"kimi-k2-thinking-turbo\": 262144,\n-    \"kimi-k2-turbo-preview\": 262144,\n-    \"kimi-k2-0905-preview\": 131072,\n-    \"MiniMax-M2.7\": 204800,\n-    \"MiniMax-M2.7-highspeed\": 204800,\n-    \"MiniMax-M2.5\": 204800,\n-    \"MiniMax-M2.5-highspeed\": 204800,\n-    \"MiniMax-M2.1\": 204800,\n-    # OpenCode Zen models\n-    \"gpt-5.4-pro\": 128000,\n-    \"gpt-5.4\": 128000,\n-    \"gpt-5.3-codex\": 128000,\n-    \"gpt-5.3-codex-spark\": 128000,\n-    \"gpt-5.2\": 128000,\n-    \"gpt-5.2-codex\": 128000,\n-    \"gpt-5.1\": 128000,\n-    \"gpt-5.1-codex\": 128000,\n-    \"gpt-5.1-codex-max\": 128000,\n-    \"gpt-5.1-codex-mini\": 128000,\n+    \"claude-opus-4.6\": 1000000,\n+    \"claude-sonnet-4.6\": 1000000,\n+    # Catch-all for older Claude models (must sort after specific entries)\n+    \"claude\": 200000,\n+    # OpenAI\n+    \"gpt-4.1\": 1047576,\n     \"gpt-5\": 128000,\n-    \"gpt-5-codex\": 128000,\n-    \"gpt-5-nano\": 128000,\n-    # Bare model IDs without provider prefix (avoid duplicates with entries above)\n-    \"claude-opus-4-5\": 200000,\n-    \"claude-opus-4-1\": 200000,\n-    \"claude-sonnet-4-5\": 200000,\n-    \"claude-sonnet-4\": 200000,\n-    \"claude-haiku-4-5\": 200000,\n-    \"claude-3-5-haiku\": 200000,\n-    \"gemini-3.1-pro\": 1048576,\n-    \"gemini-3-pro\": 1048576,\n-    \"gemini-3-flash\": 1048576,\n-    \"minimax-m2.5\": 204800,\n-    \"minimax-m2.5-free\": 204800,\n-    \"minimax-m2.1\": 204800,\n-    \"glm-4.6\": 202752,\n-    \"kimi-k2\": 262144,\n-    \"qwen3-coder\": 32768,\n-    \"big-pickle\": 128000,\n-    # Alibaba Cloud / DashScope Qwen models\n-    \"qwen3.5-plus\": 131072,\n-    \"qwen3-max\": 131072,\n-    \"qwen3-coder-plus\": 131072,\n-    \"qwen3-coder-next\": 131072,\n-    \"qwen-plus-latest\": 131072,\n-    \"qwen3.5-flash\": 131072,\n-    \"qwen-vl-max\": 32768,\n+    \"gpt-4\": 128000,\n+    # Google\n+    \"gemini\": 1048576,\n+    # DeepSeek\n+    \"deepseek\": 128000,\n+    # Meta\n+    \"llama\": 131072,\n+    # Qwen\n+    \"qwen\": 131072,\n+    # MiniMax\n+    \"minimax\": 204800,\n+    # GLM\n+    \"glm\": 202752,\n+    # Kimi\n+    \"kimi\": 262144,\n }\n \n _CONTEXT_LENGTH_KEYS = (","@@ -693,22 +641,100 @@ def _query_local_context_length(model: str, base_url: str) -> Optional[int]:\n     return None\n \n \n+def _normalize_model_version(model: str) -> str:\n+    \"\"\"Normalize version separators for matching.\n+\n+    Nous uses dashes: claude-opus-4-6, claude-sonnet-4-5\n+    OpenRouter uses dots: claude-opus-4.6, claude-sonnet-4.5\n+    Normalize both to dashes for comparison.\n+    \"\"\"\n+    return model.replace(\".\", \"-\")\n+\n+\n+def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> Optional[int]:\n+    \"\"\"Query Anthropic's /v1/models endpoint for context length.\n+\n+    Only works with regular ANTHROPIC_API_KEY (sk-ant-api*).\n+    OAuth tokens (sk-ant-oat*) from Claude Code return 401.\n+    \"\"\"\n+    if not api_key or api_key.startswith(\"sk-ant-oat\"):\n+        return None  # OAuth tokens can't access /v1/models\n+    try:\n+        base = base_url.rstrip(\"/\")\n+        if base.endswith(\"/v1\"):\n+            base = base[:-3]\n+        url = f\"{base}/v1/models?limit=1000\"\n+        headers = {\n+            \"x-api-key\": api_key,\n+            \"anthropic-version\": \"2023-06-01\",\n+        }\n+        resp = requests.get(url, headers=headers, timeout=10)\n+        if resp.status_code != 200:\n+            return None\n+        data = resp.json()\n+        for m in data.get(\"data\", []):\n+            if m.get(\"id\") == model:\n+                ctx = m.get(\"max_input_tokens\")\n+                if isinstance(ctx, int) and ctx > 0:\n+                    return ctx\n+    except Exception as e:\n+        logger.debug(\"Anthropic /v1/models query failed: %s\", e)\n+    return None\n+\n+\n+def _resolve_nous_context_length(model: str) -> Optional[int]:\n+    \"\"\"Resolve Nous Portal model context length via OpenRouter metadata.\n+\n+    Nous model IDs are bare (e.g. 'claude-opus-4-6') while OpenRouter uses\n+    prefixed IDs (e.g. 'anthropic/claude-opus-4.6'). Try suffix matching\n+    with version normalization (dot↔dash).\n+    \"\"\"\n+    metadata = fetch_model_metadata()  # OpenRouter cache\n+    # Exact match first\n+    if model in metadata:\n+        return metadata[model].get(\"context_length\")\n+\n+    normalized = _normalize_model_version(model).lower()\n+\n+    for or_id, entry in metadata.items():\n+        bare = or_id.split(\"/\", 1)[1] if \"/\" in or_id else or_id\n+        if bare.lower() == model.lower() or _normalize_model_version(bare).lower() == normalized:\n+            return entry.get(\"context_length\")\n+\n+    # Partial prefix match for cases like gemini-3-flash → gemini-3-flash-preview\n+    # Require match to be at a word boundary (followed by -, :, or end of string)\n+    model_lower = model.lower()\n+    for or_id, entry in metadata.items():\n+        bare = or_id.split(\"/\", 1)[1] if \"/\" in or_id else or_id\n+        for candidate, query in [(bare.lower(), model_lower), (_normalize_model_version(bare).lower(), normalized)]:\n+            if candidate.startswith(query) and (\n+                len(candidate) == len(query) or candidate[len(query)] in \"-:.\"\n+            ):\n+                return entry.get(\"context_length\")\n+\n+    return None\n+\n+\n def get_model_context_length(\n     model: str,\n     base_url: str = \"\",\n     api_key: str = \"\",\n     config_context_length: int | None = None,\n+    provider: str = \"\",\n ) -> int:\n     \"\"\"Get the context length for a model.\n \n     Resolution order:\n-    0. Explicit config override (model.context_length in config.yaml)\n+    0. Explicit config override (model.context_length or custom_providers per-model)\n     1. Persistent cache (previously discovered via probing)\n     2. Active endpoint metadata (/models for explicit custom endpoints)\n-    3. Local server query (for local endpoints when model not in /models list)\n-    4. OpenRouter API metadata\n-    5. Hardcoded DEFAULT_CONTEXT_LENGTHS (fuzzy match for hosted routes only)\n-    6. First probe tier (2M) — will be narrowed on first context error\n+    3. Local server query (for local endpoints)\n+    4. Anthropic /v1/models API (API-key users only, not OAuth)\n+    5. OpenRouter live API metadata\n+    6. Nous suffix-match via OpenRouter cache\n+    7. models.dev registry lookup (provider-aware)\n+    8. Thin hardcoded defaults (broad family patterns)\n+    9. Default fallback (128K)\n     \"\"\"\n     # 0. Explicit config override — user knows best\n     if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:","@@ -744,9 +770,7 @@ def get_model_context_length(\n             if isinstance(context_length, int):\n                 return context_length\n         if not _is_known_provider_base_url(base_url):\n-            # Explicit third-party endpoints should not borrow fuzzy global\n-            # defaults from unrelated providers with similarly named models.\n-            # But first try querying the local server directly.\n+            # 3. Try querying local server directly\n             if is_local_endpoint(base_url):\n                 local_ctx = _query_local_context_length(model, base_url)\n                 if local_ctx and local_ctx > 0:","@@ -756,31 +780,53 @@ def get_model_context_length(\n                 \"Could not detect context length for model %r at %s — \"\n                 \"defaulting to %s tokens (probe-down). Set model.context_length \"\n                 \"in config.yaml to override.\",\n-                model, base_url, f\"{CONTEXT_PROBE_TIERS[0]:,}\",\n+                model, base_url, f\"{DEFAULT_FALLBACK_CONTEXT:,}\",\n             )\n-            return CONTEXT_PROBE_TIERS[0]\n+            return DEFAULT_FALLBACK_CONTEXT\n \n-    # 3. OpenRouter API metadata\n+    # 4. Anthropic /v1/models API (only for regular API keys, not OAuth)\n+    if provider == \"anthropic\" or (\n+        base_url and \"api.anthropic.com\" in base_url\n+    ):\n+        ctx = _query_anthropic_context_length(model, base_url or \"https://api.anthropic.com\", api_key)\n+        if ctx:\n+            return ctx\n+\n+    # 5. Provider-aware lookups (before generic OpenRouter cache)\n+    # These are provider-specific and take priority over the generic OR cache,\n+    # since the same model can have different context limits per provider\n+    # (e.g. claude-opus-4.6 is 1M on Anthropic but 128K on GitHub Copilot).\n+    if provider == \"nous\":\n+        ctx = _resolve_nous_context_length(model)\n+        if ctx:\n+            return ctx\n+    elif provider:\n+        from agent.models_dev import lookup_models_dev_context\n+        ctx = lookup_models_dev_context(provider, model)\n+        if ctx:\n+            return ctx\n+\n+    # 6. OpenRouter live API metadata (provider-unaware fallback)\n     metadata = fetch_model_metadata()\n     if model in metadata:\n         return metadata[model].get(\"context_length\", 128000)\n \n-    # 4. Hardcoded defaults (fuzzy match — longest key first for specificity)\n+    # 8. Hardcoded defaults (fuzzy match — longest key first for specificity)\n     for default_model, length in sorted(\n         DEFAULT_CONTEXT_LENGTHS.items(), key=lambda x: len(x[0]), reverse=True\n     ):\n         if default_model in model or model in default_model:\n             return length\n \n-    # 5. Query local server for unknown models before defaulting to 2M\n+    # 9. Query local server as last resort\n     if base_url and is_local_endpoint(base_url):\n         local_ctx = _query_local_context_length(model, base_url)\n         if local_ctx and local_ctx > 0:\n             save_context_length(model, base_url, local_ctx)\n             return local_ctx\n \n-    # 6. Unknown model — start at highest probe tier\n-    return CONTEXT_PROBE_TIERS[0]\n+    # 10. Default fallback — 128K\n+    return DEFAULT_FALLBACK_CONTEXT\n \n \n def estimate_tokens_rough(text: str) -> int:"]},{"file":"agent/models_dev.py","hunks":["@@ -0,0 +1,170 @@\n+\"\"\"Models.dev registry integration for provider-aware context length detection.\n+\n+Fetches model metadata from https://models.dev/api.json — a community-maintained\n+database of 3800+ models across 100+ providers, including per-provider context\n+windows, pricing, and capabilities.\n+\n+Data is cached in memory (1hr TTL) and on disk (~/.hermes/models_dev_cache.json)\n+to avoid cold-start network latency.\n+\"\"\"\n+\n+import json\n+import logging\n+import os\n+import time\n+from pathlib import Path\n+from typing import Any, Dict, Optional\n+\n+import requests\n+\n+logger = logging.getLogger(__name__)\n+\n+MODELS_DEV_URL = \"https://models.dev/api.json\"\n+_MODELS_DEV_CACHE_TTL = 3600  # 1 hour in-memory\n+\n+# In-memory cache\n+_models_dev_cache: Dict[str, Any] = {}\n+_models_dev_cache_time: float = 0\n+\n+# Provider ID mapping: Hermes provider names → models.dev provider IDs\n+PROVIDER_TO_MODELS_DEV: Dict[str, str] = {\n+    \"openrouter\": \"openrouter\",\n+    \"anthropic\": \"anthropic\",\n+    \"zai\": \"zai\",\n+    \"kimi-coding\": \"kimi-for-coding\",\n+    \"minimax\": \"minimax\",\n+    \"minimax-cn\": \"minimax-cn\",\n+    \"deepseek\": \"deepseek\",\n+    \"alibaba\": \"alibaba\",\n+    \"copilot\": \"github-copilot\",\n+    \"ai-gateway\": \"vercel\",\n+    \"opencode-zen\": \"opencode\",\n+    \"opencode-go\": \"opencode-go\",\n+    \"kilocode\": \"kilo\",\n+}\n+\n+\n+def _get_cache_path() -> Path:\n+    \"\"\"Return path to disk cache file.\"\"\"\n+    env_val = os.environ.get(\"HERMES_HOME\", \"\")\n+    hermes_home = Path(env_val) if env_val else Path.home() / \".hermes\"\n+    return hermes_home / \"models_dev_cache.json\"\n+\n+\n+def _load_disk_cache() -> Dict[str, Any]:\n+    \"\"\"Load models.dev data from disk cache.\"\"\"\n+    try:\n+        cache_path = _get_cache_path()\n+        if cache_path.exists():\n+            with open(cache_path, encoding=\"utf-8\") as f:\n+                return json.load(f)\n+    except Exception as e:\n+        logger.debug(\"Failed to load models.dev disk cache: %s\", e)\n+    return {}\n+\n+\n+def _save_disk_cache(data: Dict[str, Any]) -> None:\n+    \"\"\"Save models.dev data to disk cache.\"\"\"\n+    try:\n+        cache_path = _get_cache_path()\n+        cache_path.parent.mkdir(parents=True, exist_ok=True)\n+        with open(cache_path, \"w\", encoding=\"utf-8\") as f:\n+            json.dump(data, f, separators=(\",\", \":\"))\n+    except Exception as e:\n+        logger.debug(\"Failed to save models.dev disk cache: %s\", e)\n+\n+\n+def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:\n+    \"\"\"Fetch models.dev registry. In-memory cache (1hr) + disk fallback.\n+\n+    Returns the full registry dict keyed by provider ID, or empty dict on failure.\n+    \"\"\"\n+    global _models_dev_cache, _models_dev_cache_time\n+\n+    # Check in-memory cache\n+    if (\n+        not force_refresh\n+        and _models_dev_cache\n+        and (time.time() - _models_dev_cache_time) < _MODELS_DEV_CACHE_TTL\n+    ):\n+        return _models_dev_cache\n+\n+    # Try network fetch\n+    try:\n+        response = requests.get(MODELS_DEV_URL, timeout=15)\n+        response.raise_for_status()\n+        data = response.json()\n+        if isinstance(data, dict) and len(data) > 0:\n+            _models_dev_cache = data\n+            _models_dev_cache_time = time.time()\n+            _save_disk_cache(data)\n+            logger.debug(\n+                \"Fetched models.dev registry: %d providers, %d total models\",\n+                len(data),\n+                sum(len(p.get(\"models\", {})) for p in data.values() if isinstance(p, dict)),\n+            )\n+            return data\n+    except Exception as e:\n+        logger.debug(\"Failed to fetch models.dev: %s\", e)\n+\n+    # Fall back to disk cache\n+    if not _models_dev_cache:\n+        _models_dev_cache = _load_disk_cache()\n+        if _models_dev_cache:\n+            _models_dev_cache_time = time.time()\n+            logger.debug(\"Loaded models.dev from disk cache (%d providers)\", len(_models_dev_cache))\n+\n+    return _models_dev_cache\n+\n+\n+def lookup_models_dev_context(provider: str, model: str) -> Optional[int]:\n+    \"\"\"Look up context_length for a provider+model combo in models.dev.\n+\n+    Returns the context window in tokens, or None if not found.\n+    Handles case-insensitive matching and filters out context=0 entries.\n+    \"\"\"\n+    mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider)\n+    if not mdev_provider_id:\n+        return None\n+\n+    data = fetch_models_dev()\n+    provider_data = data.get(mdev_provider_id)\n+    if not isinstance(provider_data, dict):\n+        return None\n+\n+    models = provider_data.get(\"models\", {})\n+    if not isinstance(models, dict):\n+        return None\n+\n+    # Exact match\n+    entry = models.get(model)\n+    if entry:\n+        ctx = _extract_context(entry)\n+        if ctx:\n+            return ctx\n+\n+    # Case-insensitive match\n+    model_lower = model.lower()\n+    for mid, mdata in models.items():\n+        if mid.lower() == model_lower:\n+            ctx = _extract_context(mdata)\n+            if ctx:\n+                return ctx\n+\n+    return None\n+\n+\n+def _extract_context(entry: Dict[str, Any]) -> Optional[int]:\n+    \"\"\"Extract context_length from a models.dev model entry.\n+\n+    Returns None for invalid/zero values (some audio/image models have context=0).\n+    \"\"\"\n+    if not isinstance(entry, dict):\n+        return None\n+    limit = entry.get(\"limit\")\n+    if not isinstance(limit, dict):\n+        return None\n+    ctx = limit.get(\"context\")\n+    if isinstance(ctx, (int, float)) and ctx > 0:\n+        return int(ctx)\n+    return None"]},{"file":"hermes_cli/main.py","hunks":["@@ -1137,10 +1137,21 @@ def _model_flow_custom(config):\n         base_url = input(f\"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: \").strip()\n         api_key = [redacted:secret-assignment]\"API key [{current_key[:8] + '...' if current_key else 'optional'}]: \").strip()\n         model_name = input(\"Model name (e.g. gpt-4, llama-3-70b): \").strip()\n+        context_length_str = input(\"Context length in tokens [leave blank for auto-detect]: \").strip()\n     except (KeyboardInterrupt, EOFError):\n         print(\"\\nCancelled.\")\n         return\n \n+    context_length = None\n+    if context_length_str:\n+        try:\n+            context_length = int(context_length_str.replace(\",\", \"\").replace(\"k\", \"000\").replace(\"K\", \"000\"))\n+            if context_length <= 0:\n+                context_length = None\n+        except ValueError:\n+            print(f\"Invalid context length: {context_length_str} — will auto-detect.\")\n+            context_length = None\n+\n     if not base_url and not current_url:\n         print(\"No URL provided. Cancelled.\")\n         return","@@ -1203,14 +1214,14 @@ def _model_flow_custom(config):\n         print(\"Endpoint saved. Use `/model` in chat or `hermes model` to set a model.\")\n \n     # Auto-save to custom_providers so it appears in the menu next time\n-    _save_custom_provider(effective_url, effective_key, model_name or \"\")\n+    _save_custom_provider(effective_url, effective_key, model_name or \"\", context_length=context_length)\n \n \n-def _save_custom_provider(base_url, api_key=\"\", model=\"\"):\n+def _save_custom_provider(base_url, api_key=\"\", model=\"\", context_length=None):\n     \"\"\"Save a custom endpoint to custom_providers in config.yaml.\n \n     Deduplicates by base_url — if the URL already exists, updates the\n-    model name but doesn't add a duplicate entry.\n+    model name and context_length but doesn't add a duplicate entry.\n     Auto-generates a display name from the URL hostname.\n     \"\"\"\n     from hermes_cli.config import load_config, save_config","@@ -1220,14 +1231,24 @@ def _save_custom_provider(base_url, api_key=\"\", model=\"\"):\n     if not isinstance(providers, list):\n         providers = []\n \n-    # Check if this URL is already saved — update model if so\n+    # Check if this URL is already saved — update model/context_length if so\n     for entry in providers:\n         if isinstance(entry, dict) and entry.get(\"base_url\", \"\").rstrip(\"/\") == base_url.rstrip(\"/\"):\n+            changed = False\n             if model and entry.get(\"model\") != model:\n                 entry[\"model\"] = model\n+                changed = True\n+            if model and context_length:\n+                models_cfg = entry.get(\"models\", {})\n+                if not isinstance(models_cfg, dict):\n+                    models_cfg = {}\n+                models_cfg[model] = {\"context_length\": context_length}\n+                entry[\"models\"] = models_cfg\n+                changed = True\n+            if changed:\n                 cfg[\"custom_providers\"] = providers\n                 save_config(cfg)\n-            return  # already saved, updated model if needed\n+            return  # already saved, updated if needed\n \n     # Auto-generate a name from the URL\n     import re","@@ -1249,6 +1270,8 @@ def _save_custom_provider(base_url, api_key=\"\", model=\"\"):\n         entry[\"api_key\"] = api_key\n     if model:\n         entry[\"model\"] = model\n+    if model and context_length:\n+        entry[\"models\"] = {model: {\"context_length\": context_length}}\n \n     providers.append(entry)\n     cfg[\"custom_providers\"] = providers"]},{"file":"hermes_cli/setup.py","hunks":["@@ -1045,93 +1045,17 @@ def setup_model_provider(config: dict):\n         print()\n         print_header(\"Custom OpenAI-Compatible Endpoint\")\n         print_info(\"Works with any API that follows OpenAI's chat completions spec\")\n+        print()\n \n-        current_url = get_env_value(\"OPENAI_BASE_URL\") or \"\"\n-        current_key = get_env_value(\"OPENAI_API_KEY\")\n-        _raw_model = config.get(\"model\", \"\")\n-        current_model = (\n-            _raw_model.get(\"default\", \"\")\n-            if isinstance(_raw_model, dict)\n-            else (_raw_model or \"\")\n-        )\n-\n-        if current_url:\n-            print_info(f\"  Current URL: {current_url}\")\n-        if current_key:\n-            print_info(f\"  Current key: {current_key[:8]}... (configured)\")\n-\n-        base_url = prompt(\n-            \"  API base URL (e.g., https://api.example.com/v1)\", current_url\n-        ).strip()\n-        api_key = [redacted:secret-assignment]\"  API key\", password=True)\n-        model_name = prompt(\"  Model name (e.g., gpt-4, claude-3-opus)\", current_model)\n-\n-        if base_url:\n-            from hermes_cli.models import probe_api_models\n-\n-            probe = probe_api_models(api_key, base_url)\n-            if probe.get(\"used_fallback\") and probe.get(\"resolved_base_url\"):\n-                print_warning(\n-                    f\"Endpoint verification worked at {probe['resolved_base_url']}/models, \"\n-                    f\"not the exact URL you entered. Saving the working base URL instead.\"\n-                )\n-                base_url = probe[\"resolved_base_url\"]\n-            elif probe.get(\"models\") is not None:\n-                print_success(\n-                    f\"Verified endpoint via {probe.get('probed_url')} \"\n-                    f\"({len(probe.get('models') or [])} model(s) visible)\"\n-                )\n-            else:\n-                print_warning(\n-                    f\"Could not verify this endpoint via {probe.get('probed_url')}. \"\n-                    f\"Hermes will still save it.\"\n-                )\n-                if probe.get(\"suggested_base_url\"):\n-                    print_info(\n-                        f\"  If this server expects /v1, try base URL: {probe['suggested_base_url']}\"\n-                    )\n-\n-            save_env_value(\"OPENAI_BASE_URL\", base_url)\n-        if api_key:\n-            save_env_value(\"OPENAI_API_KEY\", api_key)\n-        if model_name:\n-            _set_default_model(config, model_name)\n-\n-        try:\n-            from hermes_cli.auth import deactivate_provider\n-\n-            deactivate_provider()\n-        except Exception:\n-            pass\n-\n-        # Save provider and base_url to config.yaml so the gateway and CLI\n-        # both resolve the correct provider without relying on env-var heuristics.\n-        if base_url:\n-            import yaml\n-\n-            config_path = (\n-                Path(os.environ.get(\"HERMES_HOME\", Path.home() / \".hermes\"))\n-                / \"config.yaml\"\n-            )\n-            try:\n-                disk_cfg = {}\n-                if config_path.exists():\n-                    disk_cfg = yaml.safe_load(config_path.read_text()) or {}\n-                model_section = disk_cfg.get(\"model\", {})\n-                if isinstance(model_section, str):\n-                    model_section = {\"default\": model_section}\n-                model_section[\"provider\"] = \"custom\"\n-                model_section[\"base_url\"] = base_url.rstrip(\"/\")\n-                if model_name:\n-                    model_section[\"default\"] = model_name\n-                disk_cfg[\"model\"] = model_section\n-                config_path.write_text(yaml.safe_dump(disk_cfg, sort_keys=False))\n-            except Exception as e:\n-                logger.debug(\"Could not save provider to config.yaml: %s\", e)\n-\n-            _set_model_provider(config, \"custom\", base_url)\n-\n-        print_success(\"Custom endpoint configured\")\n+        # Reuse the shared custom endpoint flow from `hermes model`.\n+        # This handles: URL/key/model/context-length prompts, endpoint probing,\n+        # env saving, config.yaml updates, and custom_providers persistence.\n+        from hermes_cli.main import _model_flow_custom\n+        _model_flow_custom(config)\n+        # _model_flow_custom handles model selection, config, env vars,\n+        # and custom_providers. Keep selected_provider = \"custom\" so\n+        # the model selection step below is skipped (line 1631 check)\n+        # but vision and TTS setup still run.\n \n     elif provider_idx == 4:  # Z.AI / GLM\n         selected_provider = \"zai\""]},{"file":"run_agent.py","hunks":["@@ -981,6 +981,27 @@ def __init__(\n                 _config_context_length = int(_config_context_length)\n             except (TypeError, ValueError):\n                 _config_context_length = None\n+\n+        # Check custom_providers per-model context_length\n+        if _config_context_length is None:\n+            _custom_providers = _agent_cfg.get(\"custom_providers\")\n+            if isinstance(_custom_providers, list):\n+                for _cp_entry in _custom_providers:\n+                    if not isinstance(_cp_entry, dict):\n+                        continue\n+                    _cp_url = (_cp_entry.get(\"base_url\") or \"\").rstrip(\"/\")\n+                    if _cp_url and _cp_url == self.base_url.rstrip(\"/\"):\n+                        _cp_models = _cp_entry.get(\"models\", {})\n+                        if isinstance(_cp_models, dict):\n+                            _cp_model_cfg = _cp_models.get(self.model, {})\n+                            if isinstance(_cp_model_cfg, dict):\n+                                _cp_ctx = _cp_model_cfg.get(\"context_length\")\n+                                if _cp_ctx is not None:\n+                                    try:\n+                                        _config_context_length = int(_cp_ctx)\n+                                    except (TypeError, ValueError):\n+                                        pass\n+                        break\n         \n         self.context_compressor = ContextCompressor(\n             model=self.model,","@@ -993,6 +1014,7 @@ def __init__(\n             base_url=self.base_url,\n             api_key=[redacted:secret-assignment] \"api_key\", \"\"),\n             config_context_length=_config_context_length,\n+            provider=self.provider,\n         )\n         self.compression_enabled = compression_enabled\n         self._user_turn_count = 0"]},{"file":"tests/agent/test_model_metadata.py","hunks":["@@ -472,35 +472,35 @@ def test_tiers_descending(self):\n         for i in range(len(CONTEXT_PROBE_TIERS) - 1):\n             assert CONTEXT_PROBE_TIERS[i] > CONTEXT_PROBE_TIERS[i + 1]\n \n-    def test_first_tier_is_2m(self):\n-        assert CONTEXT_PROBE_TIERS[0] == 2_000_000\n+    def test_first_tier_is_128k(self):\n+        assert CONTEXT_PROBE_TIERS[0] == 128_000\n \n-    def test_last_tier_is_32k(self):\n-        assert CONTEXT_PROBE_TIERS[-1] == 32_000\n+    def test_last_tier_is_8k(self):\n+        assert CONTEXT_PROBE_TIERS[-1] == 8_000\n \n \n class TestGetNextProbeTier:\n-    def test_from_2m(self):\n-        assert get_next_probe_tier(2_000_000) == 1_000_000\n-\n-    def test_from_1m(self):\n-        assert get_next_probe_tier(1_000_000) == 512_000\n-\n     def test_from_128k(self):\n         assert get_next_probe_tier(128_000) == 64_000\n \n-    def test_from_32k_returns_none(self):\n-        assert get_next_probe_tier(32_000) is None\n+    def test_from_64k(self):\n+        assert get_next_probe_tier(64_000) == 32_000\n+\n+    def test_from_32k(self):\n+        assert get_next_probe_tier(32_000) == 16_000\n+\n+    def test_from_8k_returns_none(self):\n+        assert get_next_probe_tier(8_000) is None\n \n     def test_from_below_min_returns_none(self):\n-        assert get_next_probe_tier(16_000) is None\n+        assert get_next_probe_tier(4_000) is None\n \n     def test_from_arbitrary_value(self):\n-        assert get_next_probe_tier(300_000) == 200_000\n+        assert get_next_probe_tier(100_000) == 64_000\n \n     def test_above_max_tier(self):\n-        \"\"\"Value above 2M should return 2M.\"\"\"\n-        assert get_next_probe_tier(5_000_000) == 2_000_000\n+        \"\"\"Value above 128K should return 128K.\"\"\"\n+        assert get_next_probe_tier(500_000) == 128_000\n \n     def test_zero_returns_none(self):\n         assert get_next_probe_tier(0) is None"]},{"file":"tests/agent/test_models_dev.py","hunks":["@@ -0,0 +1,197 @@\n+\"\"\"Tests for agent.models_dev — models.dev registry integration.\"\"\"\n+import json\n+from unittest.mock import patch, MagicMock\n+\n+import pytest\n+from agent.models_dev import (\n+    PROVIDER_TO_MODELS_DEV,\n+    _extract_context,\n+    fetch_models_dev,\n+    lookup_models_dev_context,\n+)\n+\n+\n+SAMPLE_REGISTRY = {\n+    \"anthropic\": {\n+        \"id\": \"anthropic\",\n+        \"name\": \"Anthropic\",\n+        \"models\": {\n+            \"claude-opus-4-6\": {\n+                \"id\": \"claude-opus-4-6\",\n+                \"limit\": {\"context\": 1000000, \"output\": 128000},\n+            },\n+            \"claude-sonnet-4-6\": {\n+                \"id\": \"claude-sonnet-4-6\",\n+                \"limit\": {\"context\": 1000000, \"output\": 64000},\n+            },\n+            \"claude-sonnet-4-0\": {\n+                \"id\": \"claude-sonnet-4-0\",\n+                \"limit\": {\"context\": 200000, \"output\": 64000},\n+            },\n+        },\n+    },\n+    \"github-copilot\": {\n+        \"id\": \"github-copilot\",\n+        \"name\": \"GitHub Copilot\",\n+        \"models\": {\n+            \"claude-opus-4.6\": {\n+                \"id\": \"claude-opus-4.6\",\n+                \"limit\": {\"context\": 128000, \"output\": 32000},\n+            },\n+        },\n+    },\n+    \"kilo\": {\n+        \"id\": \"kilo\",\n+        \"name\": \"Kilo Gateway\",\n+        \"models\": {\n+            \"anthropic/claude-sonnet-4.6\": {\n+                \"id\": \"anthropic/claude-sonnet-4.6\",\n+                \"limit\": {\"context\": 1000000, \"output\": 128000},\n+            },\n+        },\n+    },\n+    \"deepseek\": {\n+        \"id\": \"deepseek\",\n+        \"name\": \"DeepSeek\",\n+        \"models\": {\n+            \"deepseek-chat\": {\n+                \"id\": \"deepseek-chat\",\n+                \"limit\": {\"context\": 128000, \"output\": 8192},\n+            },\n+        },\n+    },\n+    \"audio-only\": {\n+        \"id\": \"audio-only\",\n+        \"models\": {\n+            \"tts-model\": {\n+                \"id\": \"tts-model\",\n+                \"limit\": {\"context\": 0, \"output\": 0},\n+            },\n+        },\n+    },\n+}\n+\n+\n+class TestProviderMapping:\n+    def test_all_mapped_providers_are_strings(self):\n+        for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():\n+            assert isinstance(hermes_id, str)\n+            assert isinstance(mdev_id, str)\n+\n+    def test_known_providers_mapped(self):\n+        assert PROVIDER_TO_MODELS_DEV[\"anthropic\"] == \"anthropic\"\n+        assert PROVIDER_TO_MODELS_DEV[\"copilot\"] == \"github-copilot\"\n+        assert PROVIDER_TO_MODELS_DEV[\"kilocode\"] == \"kilo\"\n+        assert PROVIDER_TO_MODELS_DEV[\"ai-gateway\"] == \"vercel\"\n+\n+    def test_unmapped_provider_not_in_dict(self):\n+        assert \"nous\" not in PROVIDER_TO_MODELS_DEV\n+        assert \"openai-codex\" not in PROVIDER_TO_MODELS_DEV\n+\n+\n+class TestExtractContext:\n+    def test_valid_entry(self):\n+        assert _extract_context({\"limit\": {\"context\": 128000}}) == 128000\n+\n+    def test_zero_context_returns_none(self):\n+        assert _extract_context({\"limit\": {\"context\": 0}}) is None\n+\n+    def test_missing_limit_returns_none(self):\n+        assert _extract_context({\"id\": \"test\"}) is None\n+\n+    def test_missing_context_returns_none(self):\n+        assert _extract_context({\"limit\": {\"output\": 8192}}) is None\n+\n+    def test_non_dict_returns_none(self):\n+        assert _extract_context(\"not a dict\") is None\n+\n+    def test_float_context_coerced_to_int(self):\n+        assert _extract_context({\"limit\": {\"context\": 131072.0}}) == 131072\n+\n+\n+class TestLookupModelsDevContext:\n+    @patch(\"agent.models_dev.fetch_models_dev\")\n+    def test_exact_match(self, mock_fetch):\n+        mock_fetch.return_value = SAMPLE_REGISTRY\n+        assert lookup_models_dev_context(\"anthropic\", \"claude-opus-4-6\") == 1000000\n+\n+    @patch(\"agent.models_dev.fetch_models_dev\")\n+    def test_case_insensitive_match(self, mock_fetch):\n+        mock_fetch.return_value = SAMPLE_REGISTRY\n+        assert lookup_models_dev_context(\"anthropic\", \"Claude-Opus-4-6\") == 1000000\n+\n+    @patch(\"agent.models_dev.fetch_models_dev\")\n+    def test_provider_not_mapped(self, mock_fetch):\n+        mock_fetch.return_value = SAMPLE_REGISTRY\n+        assert lookup_models_dev_context(\"nous\", \"some-model\") is None\n+\n+    @patch(\"agent.models_dev.fetch_models_dev\")\n+    def test_model_not_found(self, mock_fetch):\n+        mock_fetch.return_value = SAMPLE_REGISTRY\n+        assert lookup_models_dev_context(\"anthropic\", \"nonexistent-model\") is None\n+\n+    @patch(\"agent.models_dev.fetch_models_dev\")\n+    def test_provider_aware_context(self, mock_fetch):\n+        \"\"\"Same model, different context per provider.\"\"\"\n+        mock_fetch.return_value = SAMPLE_REGISTRY\n+        # Anthropic direct: 1M\n+        assert lookup_models_dev_context(\"anthropic\", \"claude-opus-4-6\") == 1000000\n+        # GitHub Copilot: only 128K for same model\n+        assert lookup_models_dev_context(\"copilot\", \"claude-opus-4.6\") == 128000\n+\n+    @patch(\"agent.models_dev.fetch_models_dev\")\n+    def test_zero_context_filtered(self, mock_fetch):\n+        mock_fetch.return_value = SAMPLE_REGISTRY\n+        # audio-only is not a mapped provider, but test the filtering directly\n+        data = SAMPLE_REGISTRY[\"audio-only\"][\"models\"][\"tts-model\"]\n+        assert _extract_context(data) is None\n+\n+    @patch(\"agent.models_dev.fetch_models_dev\")\n+    def test_empty_registry(self, mock_fetch):\n+        mock_fetch.return_value = {}\n+        assert lookup_models_dev_context(\"anthropic\", \"claude-opus-4-6\") is None\n+\n+\n+class TestFetchModelsDev:\n+    @patch(\"agent.models_dev.requests.get\")\n+    def test_fetch_success(self, mock_get):\n+        mock_resp = MagicMock()\n+        mock_resp.status_code = 200\n+        mock_resp.json.return_value = SAMPLE_REGISTRY\n+        mock_resp.raise_for_status = MagicMock()\n+        mock_get.return_value = mock_resp\n+\n+        # Clear caches\n+        import agent.models_dev as md\n+        md._models_dev_cache = {}\n+        md._models_dev_cache_time = 0\n+\n+        with patch.object(md, \"_save_disk_cache\"):\n+            result = fetch_models_dev(force_refresh=True)\n+\n+        assert \"anthropic\" in result\n+        assert len(result) == len(SAMPLE_REGISTRY)\n+\n+    @patch(\"agent.models_dev.requests.get\")\n+    def test_fetch_failure_returns_stale_cache(self, mock_get):\n+        mock_get.side_effect = Exception(\"network error\")\n+\n+        import agent.models_dev as md\n+        md._models_dev_cache = SAMPLE_REGISTRY\n+        md._models_dev_cache_time = 0  # expired\n+\n+        with patch.object(md, \"_load_disk_cache\", return_value=SAMPLE_REGISTRY):\n+            result = fetch_models_dev(force_refresh=True)\n+\n+        assert \"anthropic\" in result\n+\n+    @patch(\"agent.models_dev.requests.get\")\n+    def test_in_memory_cache_used(self, mock_get):\n+        import agent.models_dev as md\n+        import time\n+        md._models_dev_cache = SAMPLE_REGISTRY\n+        md._models_dev_cache_time = time.time()  # fresh\n+\n+        result = fetch_models_dev()\n+        mock_get.assert_not_called()\n+        assert result == SAMPLE_REGISTRY"]},{"file":"tests/hermes_cli/test_setup.py","hunks":["@@ -97,30 +97,32 @@ def fake_prompt_choice(question, choices, default=0):\n \n     monkeypatch.setattr(\"hermes_cli.setup.prompt_choice\", fake_prompt_choice)\n \n-    prompt_values = iter(\n-        [\n-            \"https://custom.example/v1\",\n-            \"custom-api-key\",\n-            \"custom/model\",\n-        ]\n-    )\n-    monkeypatch.setattr(\n-        \"hermes_cli.setup.prompt\",\n-        lambda *args, **kwargs: next(prompt_values),\n-    )\n+    # _model_flow_custom uses builtins.input (URL, key, model, context_length)\n+    input_values = iter([\n+        \"https://custom.example/v1\",\n+        \"custom-api-key\",\n+        \"custom/model\",\n+        \"\",  # context_length (blank = auto-detect)\n+    ])\n+    monkeypatch.setattr(\"builtins.input\", lambda _prompt=\"\": next(input_values))\n     monkeypatch.setattr(\"hermes_cli.setup.prompt_yes_no\", lambda *args, **kwargs: False)\n     monkeypatch.setattr(\"hermes_cli.auth.detect_external_credentials\", lambda: [])\n+    monkeypatch.setattr(\"hermes_cli.main._save_custom_provider\", lambda *args, **kwargs: None)\n+    monkeypatch.setattr(\n+        \"hermes_cli.models.probe_api_models\",\n+        lambda api_key, base_url: {\"models\": [\"m\"], \"probed_url\": base_url + \"/models\"},\n+    )\n \n     setup_model_provider(config)\n-    save_config(config)\n-\n-    reloaded = load_config()\n \n+    # Core assertion: switching to custom endpoint clears OAuth provider\n     assert get_active_provider() is None\n-    assert isinstance(reloaded[\"model\"], dict)\n-    assert reloaded[\"model\"][\"provider\"] == \"custom\"\n-    assert reloaded[\"model\"][\"base_url\"] == \"https://custom.example/v1\"\n-    assert reloaded[\"model\"][\"default\"] == \"custom/model\"\n+\n+    # _model_flow_custom writes config via its own load/save cycle\n+    reloaded = load_config()\n+    if isinstance(reloaded.get(\"model\"), dict):\n+        assert reloaded[\"model\"].get(\"provider\") == \"custom\"\n+        assert reloaded[\"model\"].get(\"default\") == \"custom/model\"\n \n \n def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, monkeypatch):"]},{"file":"tests/hermes_cli/test_setup_model_provider.py","hunks":["@@ -99,21 +99,21 @@ def fake_prompt_choice(question, choices, default=0):\n             return tts_idx\n         raise AssertionError(f\"Unexpected prompt_choice call: {question}\")\n \n-    def fake_prompt(message, current=None, **kwargs):\n-        if \"API base URL\" in message:\n-            return \"http://localhost:8000\"\n-        if \"API key\" in message:\n-            return \"local-key\"\n-        if \"Model name\" in message:\n-            return \"llm\"\n-        return \"\"\n+    # _model_flow_custom uses builtins.input (URL, key, model, context_length)\n+    input_values = iter([\n+        \"http://localhost:8000\",\n+        \"local-key\",\n+        \"llm\",\n+        \"\",  # context_length (blank = auto-detect)\n+    ])\n+    monkeypatch.setattr(\"builtins.input\", lambda _prompt=\"\": next(input_values))\n \n     monkeypatch.setattr(\"hermes_cli.setup.prompt_choice\", fake_prompt_choice)\n-    monkeypatch.setattr(\"hermes_cli.setup.prompt\", fake_prompt)\n     monkeypatch.setattr(\"hermes_cli.setup.prompt_yes_no\", lambda *args, **kwargs: False)\n     monkeypatch.setattr(\"hermes_cli.auth.get_active_provider\", lambda: None)\n     monkeypatch.setattr(\"hermes_cli.auth.detect_external_credentials\", lambda: [])\n     monkeypatch.setattr(\"agent.auxiliary_client.get_available_vision_backends\", lambda: [])\n+    monkeypatch.setattr(\"hermes_cli.main._save_custom_provider\", lambda *args, **kwargs: None)\n     monkeypatch.setattr(\n         \"hermes_cli.models.probe_api_models\",\n         lambda api_key, base_url: {","@@ -126,16 +126,19 @@ def fake_prompt(message, current=None, **kwargs):\n     )\n \n     setup_model_provider(config)\n-    save_config(config)\n \n     env = _read_env(tmp_path)\n-    reloaded = load_config()\n \n+    # _model_flow_custom saves env vars and config to disk\n     assert env.get(\"OPENAI_BASE_URL\") == \"http://localhost:8000/v1\"\n     assert env.get(\"OPENAI_API_KEY\") == \"local-key\"\n-    assert reloaded[\"model\"][\"provider\"] == \"custom\"\n-    assert reloaded[\"model\"][\"base_url\"] == \"http://localhost:8000/v1\"\n-    assert reloaded[\"model\"][\"default\"] == \"llm\"\n+\n+    # The model config is saved as a dict by _model_flow_custom\n+    reloaded = load_config()\n+    model_cfg = reloaded.get(\"model\", {})\n+    if isinstance(model_cfg, dict):\n+        assert model_cfg.get(\"provider\") == \"custom\"\n+        assert model_cfg.get(\"default\") == \"llm\"\n \n \n def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(tmp_path, monkeypatch):"]},{"file":"tests/test_cli_provider_resolution.py","hunks":["@@ -459,7 +459,7 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):\n     )\n     monkeypatch.setattr(\"hermes_cli.config.save_config\", lambda cfg: None)\n \n-    answers = iter([\"http://localhost:8000\", \"local-key\", \"llm\"])\n+    answers = iter([\"http://localhost:8000\", \"local-key\", \"llm\", \"\"])\n     monkeypatch.setattr(\"builtins.input\", lambda _prompt=\"\": next(answers))\n \n     hermes_main._model_flow_custom({})"]},{"file":"website/docs/user-guide/configuration.md","hunks":["@@ -416,7 +416,19 @@ LLM_MODEL=meta-llama/Llama-3.1-70B-Instruct-Turbo\n \n ### Context Length Detection\n \n-Hermes automatically detects your model's context length by querying the endpoint's `/v1/models` response. For most setups this works out of the box. If detection fails (the model name doesn't match, the endpoint doesn't expose `/v1/models`, etc.), Hermes falls back to a high default and probes downward on context-length errors.\n+Hermes uses a multi-source resolution chain to detect the correct context window for your model and provider:\n+\n+1. **Config override** — `model.context_length` in config.yaml (highest priority)\n+2. **Custom provider per-model** — `custom_providers[].models.<id>.context_length`\n+3. **Persistent cache** — previously discovered values (survives restarts)\n+4. **Endpoint `/models`** — queries your server's API (local/custom endpoints)\n+5. **Anthropic `/v1/models`** — queries Anthropic's API for `max_input_tokens` (API-key users only)\n+6. **OpenRouter API** — live model metadata from OpenRouter\n+7. **Nous Portal** — suffix-matches Nous model IDs against OpenRouter metadata\n+8. **[models.dev](https://models.dev)** — community-maintained registry with provider-specific context lengths for 3800+ models across 100+ providers\n+9. **Fallback defaults** — broad model family patterns (128K default)\n+\n+For most setups this works out of the box. The system is provider-aware — the same model can have different context limits depending on who serves it (e.g., `claude-opus-4.6` is 1M on Anthropic direct but 128K on GitHub Copilot).\n \n To set the context length explicitly, add `context_length` to your model config:\n ","@@ -427,10 +439,23 @@ model:\n   context_length: 131072  # tokens\n ```\n \n-This takes highest priority — it overrides auto-detection, cached values, and hardcoded defaults.\n+For custom endpoints, you can also set context length per model:\n+\n+```yaml\n+custom_providers:\n+  - name: \"My Local LLM\"\n+    base_url: \"http://localhost:11434/v1\"\n+    models:\n+      qwen3.5:27b:\n+        context_length: 32768\n+      deepseek-r1:70b:\n+        context_length: 65536\n+```\n+\n+`hermes model` will prompt for context length when configuring a custom endpoint. Leave it blank for auto-detection.\n \n :::tip When to set this manually\n-- Your model shows \"2M context\" in the status bar (detection failed)\n+- You're using Ollama with a custom `num_ctx` that's lower than the model's maximum\n - You want to limit context below the model's maximum (e.g., 8k on a 128k model to save VRAM)\n - You're running behind a proxy that doesn't expose `/v1/models`\n :::\n"]}]},{"kind":"pr","number":63656,"title":"fix(web): probe loopback endpoints directly, never via env proxy","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"bf28c2fe1f8","number":0},{"kind":"pr","value":"#106462","number":106462}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":106462,"title":"fix(desktop): local model pickers stay fresh — same-URL endpoints keep separate catalogs, no-probe opens revalidate, loopback probes skip the proxy (#106184, #63472, salvage #63656)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/models.py","hermes_cli/web_routers/config_env.py","tests/hermes_cli/test_cached_fetch_api_models.py","tests/hermes_cli/test_model_switch_custom_providers.py","tests/hermes_cli/test_web_routers_endpoint_probe.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}