Skip to content
215 changes: 165 additions & 50 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ def _strip_provider_prefix(model: str) -> str:
_endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
_ENDPOINT_MODEL_CACHE_TTL = 300
# Bounded-lifetime cache: after the first successful probe we remember the
# server type so subsequent refreshes skip the full waterfall (no more 404
# spam every 5 minutes on non-matching endpoints like /api/v1/models on vllm).
# Entries expire after _ENDPOINT_PROBE_TTL_SECONDS so a server swap on the
# same port (stop Ollama, start LM Studio) is eventually re-detected instead
# of being pinned to the stale type for the whole process lifetime.
# Values are (server_type, monotonic_timestamp).
_ENDPOINT_PROBE_TTL_SECONDS = 3600.0
_endpoint_probe_path_cache: Dict[str, tuple] = {}


def _get_model_metadata_cache_path() -> Path:
Expand Down Expand Up @@ -632,66 +641,109 @@ def is_local_endpoint(base_url: str) -> bool:
return False


def _localhost_to_ipv4(url: str) -> str:
"""Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL.

On Windows dual-stack machines, httpx resolves ``localhost`` to ``::1``
first and pays a ~2s IPv6 connect timeout before falling back to IPv4
when the local server only listens on IPv4 (LM Studio, Ollama defaults).
Probing the IPv4 loopback directly skips that penalty.

Only the URL's own host component is rewritten (anchored at the scheme),
so a non-localhost URL whose path or query merely embeds the substring
``http://localhost...`` (e.g. ``?upstream=http://localhost:11434``)
passes through untouched.
"""
if not url:
return url
return re.sub(
r"^(https?://)localhost(?=[:/]|$)",
r"\g<1>127.0.0.1",
url,
count=1,
)


def detect_local_server_type(base_url: str, api_key: str = "") -> Optional[str]:
"""Detect which local server is running at base_url by probing known endpoints.

Returns one of: "ollama", "lm-studio", "vllm", "llamacpp", or None.

The result is cached for the lifetime of the process so that repeated
calls (e.g. every 5-minute metadata refresh) never re-run the waterfall
and never spray 404s at endpoints the server does not expose.
"""
import httpx

normalized = _normalize_base_url(base_url)

# Resolve localhost to IPv4 to avoid 2s IPv6 timeout on Windows dual-stack.
# Applied to ``normalized`` before deriving server/LM Studio URLs AND
# before the cache lookup, so localhost and 127.0.0.1 share a cache entry.
normalized = _localhost_to_ipv4(normalized)

server_url = normalized
if server_url.endswith("/v1"):
server_url = server_url[:-3]
lmstudio_url = _lmstudio_server_root(base_url)
lmstudio_url = _lmstudio_server_root(normalized)

cached = _endpoint_probe_path_cache.get(server_url)
if cached is not None and (time.monotonic() - cached[1]) < _ENDPOINT_PROBE_TTL_SECONDS:
return cached[0]

headers = _auth_headers(api_key)

result: Optional[str] = None
try:
with httpx.Client(timeout=2.0, headers=headers) as client:
# LM Studio exposes /api/v1/models — check first (most specific)
try:
r = client.get(f"{lmstudio_url}/api/v1/models")
if r.status_code == 200:
return "lm-studio"
result = "lm-studio"
except Exception:
pass
# Ollama exposes /api/tags and responds with {"models": [...]}
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
# on this path, so we must verify the response contains "models".
try:
r = client.get(f"{server_url}/api/tags")
if r.status_code == 200:
try:
if result is None:
# Ollama exposes /api/tags and responds with {"models": [...]}
# LM Studio returns {"error": "Unexpected endpoint"} with status 200
# on this path, so we must verify the response contains "models".
try:
r = client.get(f"{server_url}/api/tags")
if r.status_code == 200:
try:
data = r.json()
if "models" in data:
result = "ollama"
except Exception:
pass
except Exception:
pass
if result is None:
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
try:
r = client.get(f"{server_url}/v1/props")
if r.status_code != 200:
r = client.get(f"{server_url}/props") # fallback for older builds
if r.status_code == 200 and "default_generation_settings" in r.text:
result = "llamacpp"
except Exception:
pass
if result is None:
# vLLM: /version
try:
r = client.get(f"{server_url}/version")
if r.status_code == 200:
data = r.json()
if "models" in data:
return "ollama"
except Exception:
pass
except Exception:
pass
# llama.cpp exposes /v1/props (older builds used /props without the /v1 prefix)
try:
r = client.get(f"{server_url}/v1/props")
if r.status_code != 200:
r = client.get(f"{server_url}/props") # fallback for older builds
if r.status_code == 200 and "default_generation_settings" in r.text:
return "llamacpp"
except Exception:
pass
# vLLM: /version
try:
r = client.get(f"{server_url}/version")
if r.status_code == 200:
data = r.json()
if "version" in data:
return "vllm"
except Exception:
pass
if "version" in data:
result = "vllm"
except Exception:
pass
except Exception:
pass

return None
if result is not None:
_endpoint_probe_path_cache[server_url] = (result, time.monotonic())
return result


def _iter_nested_dicts(value: Any):
Expand Down Expand Up @@ -795,7 +847,10 @@ def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any
return _model_metadata_cache

try:
response = requests.get(OPENROUTER_MODELS_URL, timeout=10, verify=_resolve_requests_verify())
# Tuple (connect, read) — flat timeout=10 means urllib3 can block 10s per
# retry stage through proxies that 403 CONNECT, ballooning to minutes
# (#46620). 5s connect / 10s read fails fast on unreachable hosts.
response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify())
response.raise_for_status()
data = response.json()

Expand Down Expand Up @@ -873,7 +928,7 @@ def fetch_endpoint_model_metadata(
response = requests.get(
server_url.rstrip("/") + "/api/v1/models",
headers=headers,
timeout=10,
timeout=(5, 10),
verify=_resolve_requests_verify(),
)
response.raise_for_status()
Expand Down Expand Up @@ -921,7 +976,7 @@ def fetch_endpoint_model_metadata(
for candidate in candidates:
url = candidate.rstrip("/") + "/models"
try:
response = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
response = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
response.raise_for_status()
payload = response.json()
cache: Dict[str, Dict[str, Any]] = {}
Expand Down Expand Up @@ -1022,13 +1077,23 @@ def _load_context_cache() -> Dict[str, int]:
return {}


def _context_cache_key(model: str, base_url: str) -> str:
"""Canonical ``model@base_url`` key for the persistent context cache.

Trailing slashes are stripped so ``http://host/v1`` and
``http://host/v1/`` share one entry instead of creating duplicates
that can go stale independently.
"""
return f"{model}@{(base_url or '').rstrip('/')}"


def save_context_length(model: str, base_url: str, length: int) -> None:
"""Persist a discovered context length for a model+provider combo.

Cache key is ``model@base_url`` so the same model name served from
different providers can have different limits.
"""
key = f"{model}@{base_url}"
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
if cache.get(key) == length:
return # already stored
Expand All @@ -1045,18 +1110,43 @@ def save_context_length(model: str, base_url: str, length: int) -> None:

def get_cached_context_length(model: str, base_url: str) -> Optional[int]:
"""Look up a previously discovered context length for model+provider."""
key = f"{model}@{base_url}"
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
return cache.get(key)
hit = cache.get(key)
if hit is not None:
return hit
# Legacy rows written before key normalization may carry a trailing
# slash — honor them rather than re-probing. Checked regardless of the
# caller's slash form: the row's shape and the caller's shape can differ
# in either direction (old slashed row + new normalized config, or the
# reverse), so probe the literal form and the slashed canonical form.
for legacy_key in (f"{model}@{base_url}", f"{key}/"):
if legacy_key != key:
hit = cache.get(legacy_key)
if hit is not None:
return hit
return None


def _invalidate_cached_context_length(model: str, base_url: str) -> None:
"""Drop a stale cache entry so it gets re-resolved on the next lookup."""
key = f"{model}@{base_url}"
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
if key not in cache:
# Invalidation must also drop the in-memory TTL probe entries for this
# pair — otherwise the next resolution inside the TTL window reuses the
# very value we just declared stale and re-persists it.
bare = _strip_provider_prefix(model)
stripped = (base_url or "").rstrip("/")
_LOCAL_CTX_PROBE_CACHE.pop((bare, stripped), None)
_LOCAL_CTX_PROBE_CACHE.pop(("ollama_show", bare, stripped), None)
# Clear every key shape for this pair: canonical, the caller's literal
# form, and the slashed legacy form — same set get_cached_context_length
# consults, so a lookup can never resurrect a row invalidation missed.
stale_keys = {key, f"{model}@{base_url}", f"{key}/"}
if not any(k in cache for k in stale_keys):
return
del cache[key]
for k in stale_keys:
cache.pop(k, None)
path = _get_context_cache_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -1343,7 +1433,7 @@ def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Option
import httpx

bare_model = _strip_provider_prefix(model)
server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]

Expand Down Expand Up @@ -1404,7 +1494,7 @@ def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") -
except Exception:
return None

server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]

Expand Down Expand Up @@ -1443,6 +1533,12 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
hosting behind a reverse proxy, etc. For non-Ollama servers the POST
returns 404/405 quickly; the function handles errors gracefully.

Results are cached in ``_LOCAL_CTX_PROBE_CACHE`` (same 30s TTL,
positive-only — see ``_query_local_context_length``) so back-to-back
resolutions during one startup issue a single POST instead of one per
call site. Failures are never memoized: a server that isn't up yet must
be re-probed once it comes up.

For hosted servers the GGUF ``model_info.*.context_length`` is the
authoritative source: the user can't set their own ``num_ctx``, and the
OpenAI-compat ``/v1/models`` endpoint correctly omits ``context_length``
Expand All @@ -1454,9 +1550,28 @@ def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Opti
The order is flipped vs ``query_ollama_num_ctx()`` because local users
control ``num_ctx`` themselves; hosted users can't.
"""
import time as _time

# Namespaced cache key: shares the TTL store with
# _query_local_context_length but never collides with its (model, url)
# keys — the two probes can return different values for the same pair.
cache_key = ("ollama_show", _strip_provider_prefix(model), base_url.rstrip("/"))
now = _time.monotonic()
cached = _LOCAL_CTX_PROBE_CACHE.get(cache_key)
if cached is not None and (now - cached[1]) < _LOCAL_CTX_PROBE_TTL_SECONDS:
return cached[0]

result = _query_ollama_api_show_uncached(model, base_url, api_key=api_key)
if result: # positive-only — never memoize a failed probe
_LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now)
return result


def _query_ollama_api_show_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Uncached body of ``_query_ollama_api_show`` — one POST to ``/api/show``."""
import httpx

server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]

Expand Down Expand Up @@ -1575,10 +1690,10 @@ def _query_local_context_length_uncached(model: str, base_url: str, api_key: str
model = _strip_provider_prefix(model)

# Strip /v1 suffix to get the server root
server_url = base_url.rstrip("/")
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]
lmstudio_url = _lmstudio_server_root(base_url)
lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url))

headers = _auth_headers(api_key)

Expand Down Expand Up @@ -1688,7 +1803,7 @@ def _query_anthropic_context_length(model: str, base_url: str, api_key: str) ->
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
resp = requests.get(url, headers=headers, timeout=10, verify=_resolve_requests_verify())
resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify())
if resp.status_code != 200:
return None
data = resp.json()
Expand Down Expand Up @@ -1755,7 +1870,7 @@ def _fetch_codex_oauth_context_lengths(access_token: str) -> Dict[str, int]:
resp = requests.get(
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
timeout=(5, 10),
verify=_resolve_requests_verify(),
)
if resp.status_code != 200:
Expand Down
8 changes: 7 additions & 1 deletion model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -583,7 +583,13 @@ def _resolve_active_context_length() -> int:
if not model_id:
return 0
from agent.model_metadata import get_model_context_length
return int(get_model_context_length(model_id) or 0)
# Honor explicit `model.context_length` in config.yaml — short-circuits
# the OpenRouter /models probe at get_model_context_length step 0, so
# non-OpenRouter providers don't pay the ~2-3s OpenRouter fetch at every
# CLI startup. See issue #46620.
raw_ctx = model_cfg.get("context_length")
config_ctx = raw_ctx if isinstance(raw_ctx, int) and raw_ctx > 0 else None
return int(get_model_context_length(model_id, config_context_length=config_ctx) or 0)
except Exception as e:
logger.debug("Could not resolve active context length: %s", e)
return 0
Expand Down
2 changes: 2 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -1933,6 +1933,8 @@
"wafy.081107@gmail.com": "mahdiwafy", # PR #60347 salvage (session messages pagination)
"codeforgenet@icloud.com": "CodeForgeNet", # PR #47437 salvage (compact_rows blob skip)
"i@dex.moe": "dexhunter", # PR #60339 salvage (skills snapshot manifest speedup)
"1torhan@protonmail.com": "uzaylisak", # PR #29988 salvage (detect_local_server_type process-lifetime cache)
"zhchl@hermes-agent.local": "8294", # PR #50572 salvage (honor config context_length on banner)
}


Expand Down
Loading
Loading