Files
hermes-agent/agent/model_metadata.py
Teknium 2273f2bd20 refactor(agent/models): unify model_metadata TTL disk memos
_ttl_memo_get/_ttl_memo_put back both the local-probe and endpoint-metadata
disk caches (file layout, key order and log strings verified identical).
2026-09-02 13:52:52 -07:00

3280 lines
133 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Model metadata, context lengths, and token estimation utilities.
Pure utility functions with no AIAgent dependency. Used by ContextCompressor
and run_agent.py for pre-flight context checks.
"""
import base64
import hashlib
import ipaddress
import json
import logging
import os
import re
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING
from urllib.parse import urlparse
import yaml
if TYPE_CHECKING: # pragma: no cover — runtime import is lazy (see below)
import requests
from utils import atomic_json_write, atomic_yaml_write, base_url_host_matches, base_url_hostname
from hermes_constants import OPENROUTER_MODELS_URL
from agent.message_metadata import PERSISTENCE_ONLY_MESSAGE_FIELDS
logger = logging.getLogger(__name__)
# ``requests`` costs ~27 ms of the `import cli` waterfall, so it is resolved
# lazily: ``_ensure_requests()`` on the runtime path, PEP 562 ``__getattr__``
# for external access (``patch("agent.model_metadata.requests.get")``).
def _ensure_requests():
if "requests" not in globals():
import requests as _requests
globals()["requests"] = _requests
return globals()["requests"]
def __getattr__(name: str):
if name == "requests":
return _ensure_requests()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def _resolve_requests_verify(base_url: str = "") -> bool | str:
"""SSL ``verify`` for ``requests`` probes; mirrors ``agent.ssl_verify.resolve_httpx_verify``.
Priority: per-provider ``ssl_verify: false`` -> per-provider ``ssl_ca_cert``
(otherwise probes log spurious CERTIFICATE_VERIFY_FAILED while the httpx
chat path succeeds) -> HERMES_CA_BUNDLE / REQUESTS_CA_BUNDLE / SSL_CERT_FILE
-> ``True`` (certifi). Callers without a ``base_url`` keep env-only behavior.
"""
if base_url:
try:
from hermes_cli.config import get_custom_provider_tls_settings
tls = get_custom_provider_tls_settings(base_url)
if tls.get("ssl_verify") is False:
return False
ca = tls.get("ssl_ca_cert")
if isinstance(ca, str) and ca and os.path.isfile(ca):
return ca
except Exception:
pass # fall through to env vars — never break a probe on config lookup
for env_var in ("HERMES_CA_BUNDLE", "REQUESTS_CA_BUNDLE", "SSL_CERT_FILE"):
val = os.getenv(env_var)
if val and os.path.isfile(val):
return val
return True
# Compatibility snapshot for callers that inspect this private constant.
# Prefix routing below queries the registry live so later registrations work.
try:
from providers import list_providers as _list_providers
except Exception:
def _list_providers():
return []
_PROVIDER_PREFIXES: frozenset[str] = frozenset(
value.lower()
for profile in _list_providers()
for value in (profile.name, *profile.aliases)
)
_OLLAMA_TAG_PATTERN = re.compile(
r"^(\d+\.?\d*b|latest|stable|q\d|fp?\d|instruct|chat|coder|vision|text)",
re.IGNORECASE,
)
# Tailscale CGNAT (RFC 6598): `ipaddress.is_private` excludes it, yet Ollama
# reached over Tailscale must count as local (timeout auto-bumps).
_TAILSCALE_CGNAT = ipaddress.IPv4Network("100.64.0.0/10")
def _strip_provider_prefix(model: str) -> str:
"""Strip a registry-known provider prefix: ``"local:m"`` -> ``"m"``.
Ollama ``model:tag`` ids are preserved even when the model half is a
provider name (``qwen:0.5b``, ``deepseek:latest``).
"""
if ":" not in model or model.startswith("http"):
return model
prefix, suffix = model.split(":", 1)
prefix_lower = prefix.strip().lower()
try:
from providers import get_provider_profile
is_provider = get_provider_profile(prefix_lower) is not None
except Exception:
is_provider = False
if is_provider:
# Don't strip if suffix looks like an Ollama tag (e.g. "7b", "latest", "q4_0")
if _OLLAMA_TAG_PATTERN.match(suffix.strip()):
return model
return suffix
return model
_model_metadata_cache: Dict[str, Dict[str, Any]] = {}
_model_metadata_cache_time: float = 0
_novita_metadata_cache: Dict[str, Dict[str, Any]] = {}
_novita_metadata_cache_time: float = 0
_MODEL_CACHE_TTL = 3600
_endpoint_model_metadata_cache: Dict[str, Dict[str, Dict[str, Any]]] = {}
_endpoint_model_metadata_cache_time: Dict[str, float] = {}
_ENDPOINT_MODEL_CACHE_TTL = 300
# Server-type verdicts: (server_type, monotonic_ts). Positive verdicts live an
# hour (so a server swap on the same port is eventually re-detected); a None
# verdict gets the short TTL so a transient failure (server starting, key being
# fixed) recovers in minutes while still not re-running the waterfall each turn.
_ENDPOINT_PROBE_TTL_SECONDS = 3600.0
_ENDPOINT_PROBE_FAILURE_TTL_SECONDS = 300.0
_endpoint_probe_path_cache: Dict[str, tuple] = {}
# Routable-but-dead endpoints (corp LAN off-VPN) blackhole TCP: every probe
# waits out its full connect timeout and startup stalls for a minute. Once ANY
# probe observed a connect timeout, later probes short-circuit for a while.
# Pure bookkeeping — no network I/O, fires only after a real timeout was paid.
_ENDPOINT_BLACKHOLE_TTL_SECONDS = 30.0
_endpoint_blackhole_cache: Dict[str, float] = {} # host:port -> monotonic ts
def _endpoint_host_key(base_url: str) -> Optional[str]:
"""``host:port`` key (None without a host) so every probe path for one server shares an entry."""
normalized = _normalize_base_url(base_url)
if not normalized:
return None
url = normalized if "://" in normalized else f"http://{normalized}"
try:
parsed = urlparse(url)
host = parsed.hostname
port = parsed.port or (443 if parsed.scheme == "https" else 80)
except Exception:
return None
return f"{host}:{port}" if host else None
def _note_endpoint_blackholed(base_url: str) -> None:
"""Record that a probe to ``base_url`` timed out during TCP connect."""
key = _endpoint_host_key(base_url)
if key is None:
return
_endpoint_blackhole_cache[key] = time.monotonic()
logger.debug(
"Endpoint %s timed out connecting — skipping further probes for %.0fs",
key, _ENDPOINT_BLACKHOLE_TTL_SECONDS,
)
def _endpoint_blackholed(base_url: str) -> bool:
"""True if a recent probe to ``base_url`` timed out during TCP connect (cache lookup only)."""
if _ENDPOINT_BLACKHOLE_TTL_SECONDS <= 0:
return False
key = _endpoint_host_key(base_url)
if key is None:
return False
seen = _endpoint_blackhole_cache.get(key)
if seen is None:
return False
if (time.monotonic() - seen) >= _ENDPOINT_BLACKHOLE_TTL_SECONDS:
del _endpoint_blackhole_cache[key]
return False
return True
def _is_connect_timeout(exc: BaseException) -> bool:
"""True for connect-phase timeouts raised by httpx or requests.
Read timeouts are deliberately excluded: those mean the server accepted
the connection, which is the opposite of the blackhole this guards.
"""
try:
import httpx
if isinstance(exc, httpx.ConnectTimeout):
return True
except Exception:
pass
try:
from requests.exceptions import ConnectTimeout
if isinstance(exc, ConnectTimeout):
return True
except Exception:
pass
return False
# Disk L2 for local-endpoint probes so back-to-back CLI cold starts skip the
# waterfall. Only SUCCESSFUL probes are persisted (a down server must not pin
# a negative verdict); the TTL is shorter than the 1 h in-process one.
_LOCAL_PROBE_DISK_TTL_SECONDS = 300.0
def _cache_file(name: str) -> Path:
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / name
def _load_json_dict(path: Path) -> Dict[str, Any]:
"""JSON object at ``path``, or {} when missing/invalid."""
try:
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _ttl_memo_get(path: Path, key: str, ttl: float, *, ts_key: str, value_key: str) -> Optional[Any]:
"""Fresh (< ``ttl`` seconds) ``value_key`` of the ``key`` entry in a TTL memo file, else None."""
entry = _load_json_dict(path).get(key)
if not isinstance(entry, dict):
return None
try:
if (time.time() - float(entry[ts_key])) >= ttl:
return None
return entry[value_key]
except Exception:
return None
def _ttl_memo_put(
path: Path, key: str, value: Any, ttl: float, *, ts_key: str, value_key: str, what: str, ts_first: bool = False,
) -> None:
"""Write ``key`` into a TTL memo file, dropping expired siblings. Best-effort."""
try:
now = time.time()
data = {
k: v for k, v in _load_json_dict(path).items()
if isinstance(v, dict) and (now - float(v.get(ts_key, 0))) < ttl
}
data[key] = {ts_key: now, value_key: value} if ts_first else {value_key: value, ts_key: now}
atomic_json_write(path, data, indent=0, separators=(",", ":"))
except Exception as e:
logger.debug("Failed to save %s: %s", what, e)
def _local_probe_disk_cache_path() -> Path:
return _cache_file("local_endpoint_probes.json")
def _local_probe_disk_get(kind: str, key: str) -> Optional[Any]:
"""Return a fresh cached value for ``kind:key``, else None."""
return _ttl_memo_get(
_local_probe_disk_cache_path(), f"{kind}:{key}", _LOCAL_PROBE_DISK_TTL_SECONDS,
ts_key="ts", value_key="value",
)
def _local_probe_disk_put(kind: str, key: str, value: Any) -> None:
"""Persist a successful probe result. Best-effort; prunes stale entries."""
_ttl_memo_put(
_local_probe_disk_cache_path(), f"{kind}:{key}", value, _LOCAL_PROBE_DISK_TTL_SECONDS,
ts_key="ts", value_key="value", what="local probe disk cache",
)
def _get_model_metadata_cache_path() -> Path:
"""Return path to the OpenRouter model metadata disk cache."""
return _cache_file("openrouter_model_metadata.json")
def _model_metadata_disk_cache_age_seconds() -> Optional[float]:
"""Return disk-cache age in seconds, or None if freshness is unknown."""
try:
cache_path = _get_model_metadata_cache_path()
if not cache_path.exists():
return None
age = time.time() - cache_path.stat().st_mtime
if age < 0:
return None
return age
except Exception:
return None
def _load_model_metadata_disk_cache() -> Dict[str, Dict[str, Any]]:
"""Load processed OpenRouter metadata cache from disk."""
try:
cache_path = _get_model_metadata_cache_path()
with cache_path.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return {}
return {
str(key): value
for key, value in data.items()
if isinstance(value, dict)
}
except Exception as e:
logger.debug("Failed to load OpenRouter model metadata disk cache: %s", e)
return {}
def _save_model_metadata_disk_cache(data: Dict[str, Dict[str, Any]]) -> None:
"""Save processed OpenRouter metadata cache to disk atomically."""
try:
atomic_json_write(
_get_model_metadata_cache_path(),
data,
indent=0,
separators=(",", ":"),
)
except Exception as e:
logger.debug("Failed to save OpenRouter model metadata disk cache: %s", e)
def _get_endpoint_metadata_cache_path() -> Path:
"""On-disk memo of remote ``/models`` probes (see ``_endpoint_disk_cache_get``)."""
return _cache_file("endpoint_model_metadata.json")
def _endpoint_disk_cache_get(normalized: str) -> Optional[Dict[str, Dict[str, Any]]]:
"""Fresh (``_ENDPOINT_MODEL_CACHE_TTL``) cross-process memo of a remote ``/models`` probe.
One-shot runs (``hermes -q``, cron, Bot Mode hops) start cold; Nous bypasses
the persistent context cache by design, so without this every launch paid
the live probe. Same TTL as the in-memory cache keeps the portal
authoritative. Local endpoints are never memoized (transient loaded context).
"""
models = _ttl_memo_get(
_get_endpoint_metadata_cache_path(), normalized, _ENDPOINT_MODEL_CACHE_TTL,
ts_key="at", value_key="models",
)
return models if isinstance(models, dict) else None
def _endpoint_disk_cache_put(normalized: str, cache: Dict[str, Dict[str, Any]]) -> None:
"""Memoize a successful remote ``/models`` probe; expired siblings are dropped."""
_ttl_memo_put(
_get_endpoint_metadata_cache_path(), normalized, cache, _ENDPOINT_MODEL_CACHE_TTL,
ts_key="at", value_key="models", what="endpoint model metadata disk cache", ts_first=True,
)
# Descending probe tiers for unknown models; tier[0] is also the default fallback.
CONTEXT_PROBE_TIERS = [
256_000,
128_000,
64_000,
32_000,
16_000,
8_000,
]
# Default context length when no detection method succeeds.
DEFAULT_FALLBACK_CONTEXT = CONTEXT_PROBE_TIERS[0]
# The fallback result is never cached, so dedupe its warning per (model, base_url).
_FALLBACK_WARNED: set = set()
def _warn_context_length_fallback(model: str, base_url: str) -> None:
"""Warn (once per model+endpoint) that context detection failed and the
hard default is being used, so small-context models (8K, 32K) don't
silently get 256K and cause hard-to-debug API failures."""
key = (model, base_url or "")
if key in _FALLBACK_WARNED:
return
_FALLBACK_WARNED.add(key)
logger.warning(
"Could not determine context length for model %r (base_url=%s) "
"— falling back to %s tokens. Set model.context_length in "
"config.yaml to override.",
model, base_url or "default", f"{DEFAULT_FALLBACK_CONTEXT:,}",
)
# Sessions, model switches and cron jobs reject models below this: too little
# working memory for tool-calling workflows.
MINIMUM_CONTEXT_LENGTH = 64_000
# In-process cache for local-server context probes, (model, base_url) ->
# (result, monotonic_ts): one startup resolves the same model several times
# (banner, /model switch, compressor update_model). Never persisted.
_LOCAL_CTX_PROBE_TTL_SECONDS = 30.0
_LOCAL_CTX_PROBE_CACHE: Dict[tuple, tuple] = {}
# Family-pattern fallbacks, used only when provider-aware sources all miss.
# Lookups are longest-key-first substring matches, so dict order is cosmetic
# and a specific key must be STRICTLY longer than its catch-all.
DEFAULT_CONTEXT_LENGTHS = {
# Anthropic — bare ids only (prefixed ids resolve via OpenRouter/models.dev
# and would collide: "anthropic/claude-sonnet-4" ⊂ "anthropic/claude-sonnet-4.6").
"claude-fable-5": 1000000,
"claude-fable": 1000000,
"claude-opus-5": 1000000,
"claude-sonnet-5": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
"claude-opus-4-7": 1000000,
"claude-opus-4.7": 1000000,
"claude-opus-4-6": 1000000,
"claude-sonnet-4-6": 1000000,
"claude-opus-4.6": 1000000,
"claude-sonnet-4.6": 1000000,
# Catch-all for older Claude models (must sort after specific entries)
"claude": 200000,
# OpenAI — direct-API windows (Codex OAuth caps gpt-5.4+/5.5/5.6 at 272K,
# resolved by its own branch). https://developers.openai.com/api/docs/models
"gpt-5.6-luna": 1050000,
"gpt-5.6-terra": 1050000,
"gpt-5.6-sol": 1050000,
"gpt-5.5": 1050000,
"gpt-5.4-nano": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4-mini": 400000, # 400k (not 1.05M like full 5.4)
"gpt-5.4": 1050000, # GPT-5.4, GPT-5.4 Pro (1.05M context)
"gpt-5.3-codex-spark": 128000, # Codex-OAuth-only; keeps "gpt-5" (400k) from winning
"gpt-5.1-chat": 128000, # Chat variant has 128k context
"gpt-5": 400000, # GPT-5.x base, mini, codex variants (400k)
"gpt-4.1": 1047576,
"gpt-4": 128000,
# Google
"gemini": 1048576,
# Gemma (open models served via AI Studio)
"gemma-4": 256000, # Gemma 4 family
"gemma4": 256000, # Ollama-style naming (e.g. gemma4:31b-cloud)
"gemma-4-31b": 256000,
"gemma-3": 131072,
"gemma": 8192, # fallback for older gemma models
# DeepSeek — V4 family is 1M; deepseek-chat/-reasoner alias v4-flash modes.
# https://api-docs.deepseek.com/zh-cn/quick_start/pricing
"deepseek-v4-pro": 1_000_000,
"deepseek-v4-flash": 1_000_000,
"deepseek-chat": 1_000_000,
"deepseek-reasoner": 1_000_000,
"deepseek": 128000,
# Meta
"llama": 131072,
# Thinking Machines — covers inkling-small and :free/:batch variants (the
# :batch SKU's smaller live window comes from provider metadata).
"inkling": 1_048_576,
# Qwen — specific model families before the catch-all.
# Official docs: https://help.aliyun.com/zh/model-studio/developer-reference/
"qwen3.8-max": 1_000_000, # 1M context (OpenRouter & Nous portal, verified 2026-08-03)
"qwen3.8-flash": 1_000_000, # 1M context (OpenRouter & Nous portal, verified 2026-08-28)
"qwen3.6-plus": 1048576, # 1M context (DashScope/Alibaba & OpenRouter)
"qwen3.7-plus": 1048576, # 1M context (DashScope/Alibaba)
"qwen3-coder-plus": 1000000, # 1M context
"qwen3-coder": 262144, # 256K context
"qwen3-max": 262144, # 256K context (qwen3-max-2026-01-23 snapshot, Coding Plan)
"qwen": 131072,
# MiniMax — M3 is 1M; M2.x is 204,800. https://platform.minimax.io/docs/api-reference/text-chat-openai
"minimax-m3": 1000000,
"minimax": 204800,
# GLM — 5.2/5.3 are 1M (5.2 verified empirically at 789K on api.z.ai);
# older GLM (5, 5.1, 5-turbo) ~202K.
"glm-5.2": 1_048_576,
"glm-5.2:free": 256_000, # OpenRouter free variant is capped; longer key wins
"glm-5.3": 1_048_576,
"glm": 202752,
# xAI — /v1/models returns no context_length, so these prevent probe-down
# on api.x.ai custom providers. grok-composer is OAuth-only: 200k usable
# (the /v1/responses ~262144 input+output budget is a separate limit).
"grok-composer": 200000, # grok-composer-2.5-fast (Grok Build CLI)
"grok-build-latest": 500000, # alias of grok-4.5 (early access)
"grok-build": 256000, # grok-build-0.1
"grok-code-fast": 256000, # grok-code-fast-1
"grok-2-vision": 8192, # grok-2-vision, -1212, -latest
"grok-4-fast": 2000000, # grok-4-fast-(non-)reasoning, also matches -reasoning
"grok-4.20": 2000000, # grok-4.20-0309-(non-)reasoning, -multi-agent-0309
"grok-4.6": 500000, # grok-4.6 — 500K context (OpenRouter / docs.x.ai)
"grok-4.5": 500000, # grok-4.5, grok-4.5-latest — 500K context per docs.x.ai
"grok-4.3": 1000000, # grok-4.3, grok-4.3-latest — 1M context per docs.x.ai
"grok-4": 256000, # grok-4, grok-4-0709
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
"grok-2": 131072, # grok-2, grok-2-1212, grok-2-latest
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
# Kimi — K3 is 1 Mi (matches the endpoint-scoped override); older Kimi 256K.
"kimi-k3": 1_048_576,
"kimi": 262144,
# Upstage Solar — /v1/models returns no context_length; dated variants
# (solar-pro3-250127) resolve via the family prefix.
"solar-open2": 262144, # 256K
"solar-pro3": 131072,
"solar-pro2": 65536,
"solar-mini": 32768,
# Tencent Hunyuan (262144 = 256 × 1024, aligned with OpenRouter live metadata)
"hy4-preview": 1_048_576,
"hy3-preview": 262144,
"hy3": 262144,
# "Ox Alpha" stealth model — OpenCode Zen slug and OpenRouter slug
"x-preview-f": 1_048_576,
"ox-alpha": 1_048_576,
# NVIDIA Nemotron — 128K across sizes except 3.5 Lightning (1M)
"nemotron-3.5-lightning": 1_000_000,
"nemotron": 131072,
# Poolside Laguna 2.1 (covers :free and OpenCode Zen -free slugs)
"laguna-s-2.1": 262144,
"laguna-xs-2.1": 262144,
# Arcee
"trinity": 262144,
# OpenRouter
"elephant": 262144,
# Hugging Face Inference Providers — model IDs use org/name format
"Qwen/Qwen3.5-397B-A17B": 131072,
"Qwen/Qwen3.5-35B-A3B": 131072,
"deepseek-ai/DeepSeek-V3.2": 65536,
"moonshotai/Kimi-K2.5": 262144,
"moonshotai/Kimi-K2.6": 262144,
"moonshotai/Kimi-K2-Thinking": 262144,
"MiniMaxAI/MiniMax-M2.5": 204800,
"XiaomiMiMo/MiMo-V2-Flash": 262144,
"mimo-v2-pro": 1048576,
"mimo-v2.5-pro": 1048576,
"mimo-v2.5": 1048576,
"mimo-v2-omni": 262144,
"mimo-v2-flash": 262144,
"zai-org/GLM-5": 202752,
}
# xAI Grok models that ACCEPT `reasoning.effort` (verified live against
# /v1/responses). Unlisted Grok models still reason natively but 400 on the
# parameter ("Model X does not support parameter reasoningEffort"), so callers
# must send no `reasoning` key rather than a default `medium`.
_GROK_EFFORT_CAPABLE_PREFIXES = (
"grok-3-mini",
"grok-4.20-multi-agent",
"grok-4.3",
"grok-4.5", # accepts low/medium/high (default high) but REJECTS "none", unlike grok-4.3
"grok-4.6", # same effort dial as grok-4.5
)
def grok_supports_reasoning_effort(model: str) -> bool:
"""Allowlist check (aggregator prefixes like ``x-ai/`` stripped); unknown Grok models get no effort dial."""
name = (model or "").strip().lower()
if not name:
return False
if "/" in name:
name = name.rsplit("/", 1)[-1]
return any(name.startswith(prefix) for prefix in _GROK_EFFORT_CAPABLE_PREFIXES)
def is_grok_46_family(model: str) -> bool:
"""Return whether *model* is a Grok 4.6 family identifier."""
name = (model or "").strip().lower().replace("_", "-")
if "/" in name:
name = name.rsplit("/", 1)[-1]
return name == "grok-4.6" or name.startswith("grok-4.6-")
_CONTEXT_LENGTH_KEYS = (
"context_length",
"context_window",
"context_size",
"max_context_length",
"max_position_embeddings",
"max_model_len",
"max_input_tokens",
"max_sequence_length",
"max_seq_len",
"n_ctx_train",
"n_ctx",
"ctx_size",
)
_MAX_COMPLETION_KEYS = (
"max_completion_tokens",
"max_output_tokens",
"max_tokens",
)
# Local server hostnames / address patterns
_LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1", "0.0.0.0")
# Docker / Podman / Lima DNS names that resolve to the host machine
_CONTAINER_LOCAL_SUFFIXES = (
".docker.internal",
".containers.internal",
".lima.internal",
)
def _normalize_base_url(base_url: str) -> str:
return (base_url or "").strip().rstrip("/")
def _auth_headers(api_key: str = "") -> Dict[str, str]:
token = str(api_key or "").strip()
if not token:
return {}
return {"Authorization": f"Bearer {token}"}
def _is_openrouter_base_url(base_url: str) -> bool:
return base_url_host_matches(base_url, "openrouter.ai")
def _is_custom_endpoint(base_url: str) -> bool:
normalized = _normalize_base_url(base_url)
return bool(normalized) and not _is_openrouter_base_url(normalized)
_URL_TO_PROVIDER: Dict[str, str] = {
"api.openai.com": "openai",
"chatgpt.com": "openai",
"api.anthropic.com": "anthropic",
"api.z.ai": "zai",
"open.bigmodel.cn": "zai",
"api.moonshot.ai": "kimi-coding",
"api.moonshot.cn": "kimi-coding-cn",
"api.kimi.com": "kimi-coding",
"api.stepfun.ai": "stepfun",
"api.stepfun.com": "stepfun",
"api.arcee.ai": "arcee",
"api.minimax": "minimax",
"dashscope.aliyuncs.com": "alibaba",
"dashscope-intl.aliyuncs.com": "alibaba",
"portal.qwen.ai": "qwen-oauth",
"openrouter.ai": "openrouter",
"generativelanguage.googleapis.com": "gemini",
"inference-api.nousresearch.com": "nous",
"api.deepseek.com": "deepseek",
"api.githubcopilot.com": "copilot",
".githubcopilot.com": "copilot", # api.enterprise./api.business. Copilot hosts
"models.github.ai": "copilot",
# GitHub Models free tier: ~8K per-request cap makes it unusable, but
# mapping it lets us emit a targeted hint instead of the custom-endpoint path.
"models.inference.ai.azure.com": "copilot",
"api.fireworks.ai": "fireworks",
"opencode.ai": "opencode-go",
"api.x.ai": "xai",
"integrate.api.nvidia.com": "nvidia",
"api.xiaomimimo.com": "xiaomi",
"xiaomimimo.com": "xiaomi",
"api.gmi-serving.com": "gmi",
"api.novita.ai": "novita",
"tokenhub.tencentmaas.com": "tencent-tokenhub",
"api.lkeap.cloud.tencent.com": "tencent-tokenplan",
"ollama.com": "ollama-cloud",
}
# Auto-extend with provider-profile hostnames not already mapped.
try:
for _pp in _list_providers():
_host = _pp.get_hostname()
if _host and _host not in _URL_TO_PROVIDER:
_URL_TO_PROVIDER[_host] = _pp.name
except Exception:
pass
def _infer_provider_from_url(base_url: str) -> Optional[str]:
"""models.dev provider name for a base URL (custom endpoints need no explicit provider)."""
normalized = _normalize_base_url(base_url)
if not normalized:
return None
parsed = urlparse(normalized if "://" in normalized else f"https://{normalized}")
host = parsed.netloc.lower() or parsed.path.lower()
for url_part, provider in _URL_TO_PROVIDER.items():
if url_part in host:
return provider
return None
def _lmstudio_server_root(base_url: str) -> str:
"""Return the LM Studio server root for native ``/api/v1`` endpoints."""
root = _normalize_base_url(base_url).rstrip("/")
for suffix in ("/api/v1", "/api", "/v1"):
if root.endswith(suffix):
root = root[: -len(suffix)].rstrip("/")
break
return root
def _is_known_provider_base_url(base_url: str) -> bool:
return _infer_provider_from_url(base_url) is not None
def _server_root(base_url: str) -> str:
"""Probe root for a local server: IPv4-resolved, ``/v1`` suffix stripped."""
server_url = _localhost_to_ipv4(base_url.rstrip("/"))
if server_url.endswith("/v1"):
server_url = server_url[:-3]
return server_url
def _longest_key_match(table: Dict[str, int], model_lower: str) -> Optional[Tuple[str, int]]:
"""First ``(key, value)`` whose key is a substring of ``model_lower``, longest key first.
Longest-first makes specific entries (``gpt-5.4-mini``) win over their
family catch-all (``gpt-5``); ties keep table order (stable sort).
"""
for key, value in sorted(table.items(), key=lambda x: len(x[0]), reverse=True):
if key in model_lower:
return key, value
return None
def _ollama_show_context(data: Dict[str, Any], *, gguf_first: bool, minimum: Optional[int] = None) -> Optional[int]:
"""Context length from an Ollama ``/api/show`` payload.
``parameters`` -> ``num_ctx`` is the Modelfile override (the RUNTIME window
Ollama allocates KV cache for); ``model_info.*.context_length`` is the GGUF
training max, which can exceed num_ctx. Local users control num_ctx, so
local probes prefer it; hosted Ollama operators may cap num_ctx
arbitrarily, so hosted probes prefer the GGUF value (``gguf_first``).
"""
def _num_ctx() -> Optional[int]:
for line in data.get("parameters", "").split("\n"):
if "num_ctx" in line:
parts = line.strip().split()
if len(parts) >= 2:
try:
ctx = int(parts[-1])
except ValueError:
continue
if minimum is None or ctx >= minimum:
return ctx
return None
def _gguf() -> Optional[int]:
for key, value in data.get("model_info", {}).items():
if "context_length" in key and isinstance(value, (int, float)):
ctx = int(value)
if minimum is None or ctx >= minimum:
return ctx
return None
for reader in ((_gguf, _num_ctx) if gguf_first else (_num_ctx, _gguf)):
ctx = reader()
if ctx is not None:
return ctx
return None
# (host, canonical paths, model ids, context) — see _endpoint_scoped_context_length.
_ENDPOINT_SCOPED_CONTEXT = (
("api.kimi.com", {"/coding", "/coding/v1"}, {"k3", "kimi-k3", "kimi-k3-cot"}, 1_048_576),
("integrate.api.nvidia.com", {"/v1"}, {"deepseek-ai/deepseek-v4-pro"}, 262_144),
)
def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
"""Context confirmed for one provider endpoint only (see _ENDPOINT_SCOPED_CONTEXT).
Kimi Coding serves K3 (aliases kimi-k3, kimi-k3-cot) at 1 Mi only on the
canonical ``https://api.kimi.com/coding`` host — legacy Moonshot keys do
not. NVIDIA NIM serves deepseek-v4-pro at 262,144 while DeepSeek's native
endpoint is 1M; the lower limit stays scoped to NVIDIA.
"""
normalized = _normalize_base_url(base_url)
try:
parsed = urlparse(normalized)
port = parsed.port
except ValueError:
return None
# Only canonical https://host[:443]/path with no credentials/query/fragment.
if (
parsed.scheme.lower() != "https"
or port not in (None, 443)
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
return None
host = (parsed.hostname or "").lower()
path = parsed.path.rstrip("/")
model_key = model.strip().lower()
for scoped_host, paths, models, ctx in _ENDPOINT_SCOPED_CONTEXT:
if host == scoped_host and path in paths and model_key in models:
return ctx
return None
def _skip_persistent_context_cache(base_url: str, provider: str) -> bool:
"""Providers whose on-disk context cache must not short-circuit probing.
LM Studio: loaded context is transient (the user can reload with another
context_length). Codex OAuth: the window is account/entitlement-specific,
and a fallback persisted after a transient failure would suppress revalidation.
"""
return (provider or "").strip().lower() in {"lmstudio", "openai-codex"}
def _maybe_cache_local_context_length(
model: str,
base_url: str,
length: int,
) -> None:
"""Persist a probed local window only at/above MINIMUM_CONTEXT_LENGTH.
Sub-minimum windows are still returned so agent_init can reject them, but
must not be blessed into the on-disk cache as valid operating limits.
"""
if length >= MINIMUM_CONTEXT_LENGTH:
save_context_length(model, base_url, length)
def _probe_local_context_length(model: str, base_url: str, api_key: str, provider: str) -> Optional[int]:
"""Live local probe; persists a positive result unless the provider opts out of the disk cache."""
local_ctx = _query_local_context_length(model, base_url, api_key=api_key)
if local_ctx and local_ctx > 0:
if not _skip_persistent_context_cache(base_url, provider):
_maybe_cache_local_context_length(model, base_url, local_ctx)
return local_ctx
return None
def _reconcile_local_cached_context_length(
model: str,
base_url: str,
cached: int,
api_key: str = "",
) -> int:
"""Return *cached* unless a live local probe reports a different limit.
Operators restart vLLM/Ollama with a new --max-model-len / num_ctx under
the same model id; a reachable server wins over the disk entry, a failed
probe keeps it. Sub-minimum live windows invalidate but are not persisted.
"""
live_ctx = _query_local_context_length(model, base_url, api_key=api_key)
if live_ctx and live_ctx > 0 and live_ctx != cached:
if live_ctx < MINIMUM_CONTEXT_LENGTH:
logger.info(
"Live local probe for %s@%s reports %s (< minimum %s); "
"invalidating stale cache — agent init should reject",
model, base_url, f"{live_ctx:,}", f"{MINIMUM_CONTEXT_LENGTH:,}",
)
_invalidate_cached_context_length(model, base_url)
return live_ctx
logger.info(
"Reconciling stale local cache entry %s@%s: %s -> %s (live probe)",
model, base_url, f"{cached:,}", f"{live_ctx:,}",
)
_invalidate_cached_context_length(model, base_url)
_maybe_cache_local_context_length(model, base_url, live_ctx)
return live_ctx
return cached
def is_local_endpoint(base_url: str) -> bool:
"""True for loopback, container-internal DNS, unqualified hosts, RFC-1918,
link-local and Tailscale CGNAT (so a trusted Ollama box over Tailscale gets
the same timeout auto-bumps as localhost)."""
normalized = _normalize_base_url(base_url)
if not normalized:
return False
url = normalized if "://" in normalized else f"http://{normalized}"
try:
parsed = urlparse(url)
host = parsed.hostname or ""
except Exception:
return False
if host in _LOCAL_HOSTS:
return True
# Docker / Podman / Lima internal DNS names (e.g. host.docker.internal)
if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES):
return True
# Unqualified hostnames (no dots) are local by definition — Docker
# Compose service names, /etc/hosts entries, or mDNS names.
if host and "." not in host:
return True
# RFC-1918 private ranges, link-local, and Tailscale CGNAT
try:
addr = ipaddress.ip_address(host)
if addr.is_private or addr.is_loopback or addr.is_link_local:
return True
if isinstance(addr, ipaddress.IPv4Address) and addr in _TAILSCALE_CGNAT:
return True
except ValueError:
pass
# Bare IP that looks like a private range (e.g. 172.26.x.x for WSL)
# or Tailscale CGNAT (100.64.x.x–100.127.x.x).
parts = host.split(".")
if len(parts) == 4:
try:
first, second = int(parts[0]), int(parts[1])
except ValueError:
return False
return (
first == 10
or (first == 172 and 16 <= second <= 31)
or (first == 192 and second == 168)
or (first == 100 and 64 <= second <= 127)
)
return False
def _localhost_to_ipv4(url: str) -> str:
"""Rewrite a ``localhost`` HOST to ``127.0.0.1`` in a probe URL.
Windows dual-stack resolves localhost to ::1 first and pays a ~2s IPv6
connect timeout when the server only listens on IPv4. Anchored at the
scheme so an embedded ``?upstream=http://localhost`` is untouched.
"""
if not url or not isinstance(url, str):
return url # non-string values (test doubles, lazy config) pass through
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]:
"""Probe known endpoints: "ollama", "lm-studio", "vllm", "llamacpp", or None (TTL-cached)."""
import httpx
# IPv4-resolve BEFORE deriving server/LM Studio URLs and the cache lookup,
# so localhost and 127.0.0.1 share a cache entry.
normalized = _localhost_to_ipv4(_normalize_base_url(base_url))
server_url = _server_root(normalized)
lmstudio_url = _lmstudio_server_root(normalized)
cached = _endpoint_probe_path_cache.get(server_url)
if cached is not None:
ttl = (
_ENDPOINT_PROBE_TTL_SECONDS
if cached[0] is not None
else _ENDPOINT_PROBE_FAILURE_TTL_SECONDS
)
if (time.monotonic() - cached[1]) < ttl:
return cached[0]
# Blackholed host: skip the waterfall. Deliberately NOT written to the
# hour-long verdict cache, which would pin "undetected" after it comes back.
if _endpoint_blackholed(server_url):
return None
disk_hit = _local_probe_disk_get("server_type", server_url)
if isinstance(disk_hit, str):
_endpoint_probe_path_cache[server_url] = (disk_hit, time.monotonic())
return disk_hit
headers = _auth_headers(api_key)
def _probe_failed(exc: Exception) -> None:
"""Swallow a probe error; on a connect timeout re-raise so the remaining legs are skipped."""
if _is_connect_timeout(exc):
_note_endpoint_blackholed(server_url)
raise exc
def _lm_studio(client) -> bool:
return client.get(f"{lmstudio_url}/api/v1/models").status_code == 200
def _ollama(client) -> bool:
# LM Studio answers /api/tags with {"error": ...} and status 200, so
# the body must actually carry "models".
r = client.get(f"{server_url}/api/tags")
return r.status_code == 200 and "models" in r.json()
def _llamacpp(client) -> bool:
r = client.get(f"{server_url}/v1/props")
if r.status_code != 200:
r = client.get(f"{server_url}/props") # older builds: no /v1 prefix
return r.status_code == 200 and "default_generation_settings" in r.text
def _vllm(client) -> bool:
r = client.get(f"{server_url}/version")
return r.status_code == 200 and "version" in r.json()
# Most specific first: LM Studio's native API, then Ollama, llama.cpp, vLLM.
waterfall = (("lm-studio", _lm_studio), ("ollama", _ollama), ("llamacpp", _llamacpp), ("vllm", _vllm))
result: Optional[str] = None
try:
with httpx.Client(timeout=2.0, headers=headers) as client:
for name, probe in waterfall:
try:
if probe(client):
result = name
break
except Exception as exc:
_probe_failed(exc)
except Exception:
pass
if result is not None:
_endpoint_probe_path_cache[server_url] = (result, time.monotonic())
_local_probe_disk_put("server_type", server_url, result)
else:
# Negative verdict in memory only (never on disk — failures are often transient).
_endpoint_probe_path_cache[server_url] = (None, time.monotonic())
return result
def _iter_nested_dicts(value: Any):
if isinstance(value, dict):
yield value
for nested in value.values():
yield from _iter_nested_dicts(nested)
elif isinstance(value, list):
for item in value:
yield from _iter_nested_dicts(item)
def _coerce_reasonable_int(value: Any, minimum: int = 1024, maximum: int = 10_000_000) -> Optional[int]:
try:
if isinstance(value, bool):
return None
if isinstance(value, str):
value = value.strip().replace(",", "")
result = int(value)
except (TypeError, ValueError):
return None
if minimum <= result <= maximum:
return result
return None
def _extract_first_int(payload: Dict[str, Any], keys: tuple[str, ...]) -> Optional[int]:
keyset = {key.lower() for key in keys}
for mapping in _iter_nested_dicts(payload):
for key, value in mapping.items():
if str(key).lower() not in keyset:
continue
coerced = _coerce_reasonable_int(value)
if coerced is not None:
return coerced
return None
def _extract_flat_context_length(payload: Dict[str, Any]) -> Optional[int]:
"""Top-level-only context WINDOW read (no nested walk, so an unrelated nested
section can't leak a same-named key). ``max_tokens`` is deliberately NOT a
window key: on OpenAI-compatible passthroughs it is the max OUTPUT."""
for key in _CONTEXT_LENGTH_KEYS:
coerced = _coerce_reasonable_int(payload.get(key))
if coerced is not None:
return coerced
return None
def _extract_context_length(payload: Dict[str, Any]) -> Optional[int]:
return _extract_first_int(payload, _CONTEXT_LENGTH_KEYS)
def _extract_max_completion_tokens(payload: Dict[str, Any]) -> Optional[int]:
return _extract_first_int(payload, _MAX_COMPLETION_KEYS)
def _context_length_from_model_payload(payload: Dict[str, Any]) -> Optional[int]:
"""Context window from a ``/v1/models`` object: window keys first, ``max_tokens`` last.
Anthropic-shaped payloads carry both ``max_input_tokens`` (1M window) and
``max_tokens`` (128k OUTPUT cap); reading max_tokens first would persist a
stale window and fire the compressor at 75% of 128k instead of 1M.
"""
if not isinstance(payload, dict):
return None
ctx = _extract_flat_context_length(payload)
if ctx is not None:
return ctx
raw = payload.get("max_tokens")
if isinstance(raw, (int, float)):
ivalue = int(raw)
if ivalue > 0:
return ivalue
return None
def _extract_pricing(payload: Dict[str, Any]) -> Dict[str, Any]:
def _per_token(source: Dict[str, Any], fields: Dict[str, str], scale) -> Dict[str, Any]:
# Provider $/MTok (or Novita's 1/10_000-$ per M) -> per-token strings so
# usage_pricing consumes them through the same path as OpenRouter.
return {
target: str(scale(float(source[key])))
for target, key in fields.items()
if source.get(key) is not None
}
novita_fields = {"prompt": "input_token_price_per_m", "completion": "output_token_price_per_m"}
if any(payload.get(k) is not None for k in novita_fields.values()):
return _per_token(payload, novita_fields, lambda v: v / 10_000 / 1_000_000)
# DeepInfra ships pricing under ``metadata.pricing`` in $/MTok.
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else None
deepinfra_pricing = metadata.get("pricing") if metadata else None
deepinfra_fields = {"prompt": "input_tokens", "completion": "output_tokens", "cache_read": "cache_read_tokens"}
if isinstance(deepinfra_pricing, dict) and any(k in deepinfra_pricing for k in deepinfra_fields.values()):
return _per_token(deepinfra_pricing, deepinfra_fields, lambda v: v / 1_000_000)
alias_map = {
"prompt": ("prompt", "input", "input_cost_per_token", "prompt_token_cost"),
"completion": ("completion", "output", "output_cost_per_token", "completion_token_cost"),
"request": ("request", "request_cost"),
"cache_read": ("cache_read", "cached_prompt", "input_cache_read", "cache_read_cost_per_token"),
"cache_write": ("cache_write", "cache_creation", "input_cache_write", "cache_write_cost_per_token"),
}
for mapping in _iter_nested_dicts(payload):
normalized = {str(key).lower(): value for key, value in mapping.items()}
if not any(any(alias in normalized for alias in aliases) for aliases in alias_map.values()):
continue
pricing: Dict[str, Any] = {}
for target, aliases in alias_map.items():
for alias in aliases:
if alias in normalized and normalized[alias] not in {None, ""}:
pricing[target] = normalized[alias]
break
if pricing:
return pricing
return {}
def _add_model_aliases(cache: Dict[str, Dict[str, Any]], model_id: str, entry: Dict[str, Any]) -> None:
cache[model_id] = entry
if "/" in model_id:
bare_model = model_id.split("/", 1)[1]
cache.setdefault(bare_model, entry)
def fetch_model_metadata(force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
"""Fetch model metadata from OpenRouter (cached for 1 hour)."""
global _model_metadata_cache, _model_metadata_cache_time
if not force_refresh and _model_metadata_cache and (time.time() - _model_metadata_cache_time) < _MODEL_CACHE_TTL:
return _model_metadata_cache
if not force_refresh:
disk_age = _model_metadata_disk_cache_age_seconds()
if disk_age is not None and disk_age < _MODEL_CACHE_TTL:
disk_cache = _load_model_metadata_disk_cache()
if disk_cache:
_model_metadata_cache = disk_cache
_model_metadata_cache_time = time.time() - disk_age
return _model_metadata_cache
try:
_ensure_requests()
# (connect, read) tuple: a flat timeout lets urllib3 block per retry
# stage through proxies that 403 CONNECT, ballooning to minutes.
response = requests.get(OPENROUTER_MODELS_URL, timeout=(5, 10), verify=_resolve_requests_verify())
response.raise_for_status()
data = response.json()
cache = {}
for model in data.get("data", []):
model_id = model.get("id", "")
entry = {
"context_length": model.get("context_length", 128000),
"max_completion_tokens": model.get("top_provider", {}).get("max_completion_tokens", 4096),
"name": model.get("name", model_id),
"pricing": model.get("pricing", {}),
}
_add_model_aliases(cache, model_id, entry)
canonical = model.get("canonical_slug", "")
if canonical and canonical != model_id:
_add_model_aliases(cache, canonical, entry)
_model_metadata_cache = cache
_model_metadata_cache_time = time.time()
_save_model_metadata_disk_cache(cache)
logger.debug("Fetched metadata for %s models from OpenRouter", len(cache))
return cache
except Exception as e:
logger.warning("Failed to fetch model metadata from OpenRouter: %s", e)
if _model_metadata_cache:
return _model_metadata_cache
disk_cache = _load_model_metadata_disk_cache()
if disk_cache:
_model_metadata_cache = disk_cache
disk_age = _model_metadata_disk_cache_age_seconds()
if disk_age is not None:
_model_metadata_cache_time = time.time() - min(disk_age, _MODEL_CACHE_TTL)
else:
_model_metadata_cache_time = time.time() - _MODEL_CACHE_TTL + 1
return _model_metadata_cache
return {}
def _endpoint_model_entry(model: Dict[str, Any], model_id: str, context_length: Optional[int]) -> Dict[str, Any]:
"""Cache entry for one ``/models`` item; optional keys are set only when known."""
entry: Dict[str, Any] = {"name": model.get("name", model_id)}
if context_length is not None:
entry["context_length"] = context_length
max_completion_tokens = _extract_max_completion_tokens(model)
if max_completion_tokens is not None:
entry["max_completion_tokens"] = max_completion_tokens
pricing = _extract_pricing(model)
if pricing:
entry["pricing"] = pricing
return entry
def _lmstudio_native_models(normalized: str, headers: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
"""LM Studio ``/api/v1/models`` → cache; context comes from the first loaded instance."""
response = requests.get(
_lmstudio_server_root(normalized).rstrip("/") + "/api/v1/models",
headers=headers,
timeout=(5, 10),
verify=_resolve_requests_verify(normalized),
)
response.raise_for_status()
cache: Dict[str, Dict[str, Any]] = {}
for model in response.json().get("models", []):
if not isinstance(model, dict):
continue
model_id = model.get("key") or model.get("id")
if not model_id:
continue
context_length = None
for inst in model.get("loaded_instances", []) or []:
if not isinstance(inst, dict):
continue
cfg = inst.get("config", {})
ctx = cfg.get("context_length") if isinstance(cfg, dict) else None
if isinstance(ctx, int) and ctx > 0:
context_length = ctx
break
entry = _endpoint_model_entry(model, model_id, context_length)
_add_model_aliases(cache, model_id, entry)
alt_id = model.get("id")
if isinstance(alt_id, str) and alt_id and alt_id != model_id:
_add_model_aliases(cache, alt_id, entry)
return cache
def _apply_llamacpp_props(cache: Dict[str, Dict[str, Any]], request_candidate: str, headers: Dict[str, str], verify) -> None:
"""Overwrite ``context_length`` with llama.cpp's actually allocated ``n_ctx`` from /props.
``/v1/props`` first (current builds), ``/props`` for older ones. In router
mode the bare endpoint 400s, so each LOADED child's granted window is read
via ``/props?model=``; unloaded children are skipped — probing could autoload them.
"""
base = request_candidate.rstrip("/").replace("/v1", "")
def _props(params=None):
resp = requests.get(base + "/v1/props", params=params, headers=headers, timeout=5, verify=verify)
if not resp.ok:
resp = requests.get(base + "/props", params=params, headers=headers, timeout=5, verify=verify)
return resp
props_resp = _props()
if props_resp.ok:
props = props_resp.json()
n_ctx = props.get("default_generation_settings", {}).get("n_ctx")
model_alias = props.get("model_alias", "")
if n_ctx and model_alias and model_alias in cache:
cache[model_alias]["context_length"] = n_ctx
return
native = requests.get(base + "/models", headers=headers, timeout=5, verify=verify)
if not native.ok:
return
for child in (native.json() or {}).get("data", [])[:16]:
if not isinstance(child, dict):
continue
child_id = child.get("id")
status = (child.get("status") or {}).get("value")
if not child_id or child_id not in cache or status not in ("loaded", "ready"):
continue
pr = _props({"model": child_id})
if pr.ok:
child_ctx = (pr.json().get("default_generation_settings") or {}).get("n_ctx")
if child_ctx:
cache[child_id]["context_length"] = child_ctx
def fetch_endpoint_model_metadata(
base_url: str,
api_key: str = "",
force_refresh: bool = False,
) -> Dict[str, Dict[str, Any]]:
"""Model metadata from an OpenAI-compatible ``/models`` endpoint (cached per base URL)."""
normalized = _normalize_base_url(base_url)
if not normalized or _is_openrouter_base_url(normalized):
return {}
_ensure_requests()
if not force_refresh:
cached = _endpoint_model_metadata_cache.get(normalized)
cached_at = _endpoint_model_metadata_cache_time.get(normalized, 0)
if cached is not None and (time.time() - cached_at) < _ENDPOINT_MODEL_CACHE_TTL:
return cached
if not is_local_endpoint(normalized):
memo = _endpoint_disk_cache_get(normalized)
if memo is not None:
_endpoint_model_metadata_cache[normalized] = memo
_endpoint_model_metadata_cache_time[normalized] = time.time()
return memo
# Blackholed: return empty WITHOUT caching so it is retried once the entry expires.
if _endpoint_blackholed(normalized):
return {}
candidates = [normalized]
if normalized.endswith("/v1"):
alternate = normalized[:-3].rstrip("/")
else:
alternate = normalized + "/v1"
if alternate and alternate not in candidates:
candidates.append(alternate)
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
last_error: Optional[Exception] = None
if is_local_endpoint(normalized):
try:
if detect_local_server_type(normalized, api_key=api_key) == "lm-studio":
cache = _lmstudio_native_models(normalized, headers)
_endpoint_model_metadata_cache[normalized] = cache
_endpoint_model_metadata_cache_time[normalized] = time.time()
return cache
except Exception as exc:
last_error = exc
if _is_connect_timeout(exc):
_note_endpoint_blackholed(normalized)
for candidate in candidates:
# A connect timeout condemns the host, not the path.
if _endpoint_blackholed(normalized):
break
# Cache keys stay unrewritten; only the outbound target is IPv4-resolved.
request_candidate = _localhost_to_ipv4(candidate)
url = request_candidate.rstrip("/") + "/models"
response = None
try:
response = requests.get(
url,
headers=headers,
timeout=(5, 10),
verify=_resolve_requests_verify(normalized),
stream=True,
)
if response.status_code in (401, 403):
logger.debug(
"Model metadata probe received HTTP %s from %s; stopping candidate probing",
response.status_code,
url,
)
break
response.raise_for_status()
payload = response.json()
cache: Dict[str, Dict[str, Any]] = {}
for model in payload.get("data", []):
if not isinstance(model, dict):
continue
model_id = model.get("id")
if not model_id:
continue
_add_model_aliases(cache, model_id, _endpoint_model_entry(model, model_id, _extract_context_length(model)))
if any(m.get("owned_by") == "llamacpp" for m in payload.get("data", []) if isinstance(m, dict)):
try:
_apply_llamacpp_props(cache, request_candidate, headers, _resolve_requests_verify(normalized))
except Exception:
pass
_endpoint_model_metadata_cache[normalized] = cache
_endpoint_model_metadata_cache_time[normalized] = time.time()
if cache and not is_local_endpoint(normalized):
_endpoint_disk_cache_put(normalized, cache)
return cache
except Exception as exc:
last_error = exc
if _is_connect_timeout(exc):
_note_endpoint_blackholed(normalized)
finally:
if response is not None:
response.close()
if last_error:
logger.debug("Failed to fetch model metadata from %s/models: %s", normalized, last_error)
_endpoint_model_metadata_cache[normalized] = {}
_endpoint_model_metadata_cache_time[normalized] = time.time()
return {}
def _resolve_endpoint_context_length(
model: str,
base_url: str,
api_key: str = "",
) -> Optional[int]:
"""Resolve context length from an endpoint's live ``/models`` metadata."""
endpoint_metadata = fetch_endpoint_model_metadata(base_url, api_key=api_key)
matched = endpoint_metadata.get(model)
if not matched:
if len(endpoint_metadata) == 1:
matched = next(iter(endpoint_metadata.values()))
elif model:
# Substring match; "" would match EVERY key and poison the window.
for key, entry in endpoint_metadata.items():
if model in key or key in model:
matched = entry
break
if matched:
context_length = matched.get("context_length")
if isinstance(context_length, int):
return context_length
return None
def _get_context_cache_path() -> Path:
"""Return path to the persistent context length cache file."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "context_length_cache.yaml"
def _load_context_cache() -> Dict[str, int]:
"""Load the model+provider -> context_length cache from disk."""
path = _get_context_cache_path()
if not path.exists():
return {}
try:
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
return data.get("context_lengths") or {}
except Exception as e:
logger.debug("Failed to load context length cache: %s", e)
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.
"""
# Never persist non-positive values — a 0 or negative context length
# is always a bug and would poison the cache, causing downstream
# `get_model_context_length()` to return 0 (since `0 is not None`).
if length <= 0:
logger.warning(
"Refusing to cache non-positive context length %s -> %s tokens",
f"{model}@{base_url}", length,
)
return
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
if cache.get(key) == length:
return # already stored
cache[key] = length
path = _get_context_cache_path()
try:
# Atomic write (temp file + fsync + os.replace): a plain truncating
# ``open(path, "w")`` leaves the file empty/partial if the process is
# killed mid-dump, and the next _load_context_cache() swallows the
# resulting YAML error and returns {} — silently wiping EVERY cached
# context length. It also exposes torn reads to a concurrent process
# reading between truncate and dump-complete.
atomic_yaml_write(path, {"context_lengths": cache})
logger.info("Cached context length %s -> %s tokens", key, f"{length:,}")
except Exception as e:
logger.debug("Failed to save context length cache: %s", e)
def get_cached_context_length(model: str, base_url: str) -> Optional[int]:
"""Look up a previously discovered context length for model+provider."""
key = _context_cache_key(model, base_url)
cache = _load_context_cache()
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 = _context_cache_key(model, base_url)
cache = _load_context_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
for k in stale_keys:
cache.pop(k, None)
path = _get_context_cache_path()
try:
# Atomic write — see save_context_length() for why a plain truncating
# open() here risks wiping the entire cache on an interrupted dump.
atomic_yaml_write(path, {"context_lengths": cache})
except Exception as e:
logger.debug("Failed to invalidate context length cache entry %s: %s", key, e)
def get_next_probe_tier(current_length: int) -> Optional[int]:
"""Return the next lower probe tier, or None if already at minimum."""
for tier in CONTEXT_PROBE_TIERS:
if tier < current_length:
return tier
return None
def parse_context_limit_from_error(error_msg: str) -> Optional[int]:
"""Context limit quoted in a provider error ("maximum context length is 32768 tokens"), if any."""
error_lower = error_msg.lower()
patterns = [
r'max_model_len\s*(?:is\s*)?[:=(]?\s*(\d{4,})', # vLLM: "max_model_len 32768", "=32768", ": 32768", "(32768)", "is 32768"
r'maximum model length\s*(?:is\s*)?[:=(]?\s*(\d{4,})', # vLLM alt: "maximum model length 131072", "... is 131072"
r'(?:max(?:imum)?|limit)\s*(?:context\s*)?(?:length|size|window)?\s*(?:is|of|:)?\s*(\d{4,})',
r'context\s*(?:length|size|window)\s*(?:is|of|:)?\s*(\d{4,})',
r'(\d{4,})\s*(?:token)?\s*(?:context|limit)',
r'>\s*(\d{4,})\s*(?:max|limit|token)', # "250000 tokens > 200000 maximum"
r'(\d{4,})\s*(?:max(?:imum)?)\b', # "200000 maximum"
# Gemini: "input token count is 32825 but model only supports up to
# 32768" — anchor on the phrase so the input count isn't captured.
r'supports?\s+(?:only\s+)?up\s+to\s+(\d{4,})',
]
for pattern in patterns:
match = re.search(pattern, error_lower)
if match:
limit = int(match.group(1))
# Sanity check: must be a reasonable context length
if 1024 <= limit <= 10_000_000:
return limit
return None
def get_context_length_from_provider_error(
error_msg: str,
current_context_length: int,
) -> Optional[int]:
"""Provider-reported limit LOWER than the current window, else None.
Overflow recovery must not invent a window: when the provider only says
the input is too long, callers keep the configured length and compress
rather than stepping down guessed probe tiers.
"""
parsed_limit = parse_context_limit_from_error(error_msg)
if parsed_limit is not None and parsed_limit < current_context_length:
return parsed_limit
return None
def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
"""Available OUTPUT tokens from a "max_tokens too large" error, or None.
Distinct from "prompt too long" (input exceeds the window -> compress):
here input + requested_output > window, so the fix is a smaller max_tokens
for this call and context_length must NOT be touched. E.g. Anthropic:
"max_tokens: 32768 > context_window: 200000 - input_tokens: 190000 = available_tokens: 10000" -> 10000.
"""
error_lower = error_msg.lower()
if not _any_phrase_group(error_lower, _PARSEABLE_OUTPUT_CAP_SIGNALS):
return None
# Direct cap figures, most specific first:
# "max_tokens (98304) exceeds model's maximum output tokens (65536)"
# "Range of max_tokens should be [1, 65536]" (upper bound is the cap)
# "... = available_tokens: 10000" (Anthropic)
# "200000 - 190000 = 10000" (last number after "=")
for pattern in (
r'exceeds model(?:\'s)? maximum output tokens\s*\(?\s*(\d+)\s*\)?',
r'range of max_tokens should be\s*\[\s*\d+\s*,\s*(\d+)\s*\]',
r'available_tokens[:\s]+(\d+)',
r'available\s+tokens[:\s]+(\d+)',
r'=\s*(\d+)\s*$',
):
match = re.search(pattern, error_lower)
if match and int(match.group(1)) >= 1:
return int(match.group(1))
# OpenRouter/Nous format: "maximum context length is N … (A of text input,
# B of tool input, C in the output)". Available output = ctx - text - tool.
_m_ctx = re.search(r'maximum context length is (\d+)', error_lower)
_m_parts = re.search(
r'\((\d+)\s+of text input,\s*(\d+)\s+of tool input,\s*(\d+)\s+in the output\)',
error_lower,
)
if _m_ctx and _m_parts:
_available = int(_m_ctx.group(1)) - int(_m_parts.group(1)) - int(_m_parts.group(2))
if _available >= 1:
return _available
# LM Studio / llama.cpp: window in tokens, prompt in CHARACTERS. ~3
# chars/token over-reserves the input so the retried cap stays inside the window.
_m_ctx_tok = re.search(r'maximum context length is (\d+)\s*token', error_lower)
_m_chars = re.search(r'prompt contains (\d+)\s*character', error_lower)
if _m_ctx_tok and _m_chars:
_ctx = int(_m_ctx_tok.group(1))
_est_input = (int(_m_chars.group(1)) + 2) // 3
_available = _ctx - _est_input
if _available >= 1:
return _available
# vLLM: window and prompt both in TOKENS; available = window - input (None
# when the input alone overflows, so the caller compresses instead).
# When max_tokens is the BINDING constraint vLLM reports "at least N input
# tokens" with N == window + 1 - requested_output, so window - N is always
# requested_output - 1 and each retry walks the cap down by the safety
# margin without ever fitting. Detect that and halve the cap instead —
# still strictly below what was rejected, converges in one or two retries.
_m_vllm_input = re.search(
r'prompt contains (?:at least )?(\d+)\s*input tokens', error_lower
)
if _m_ctx_tok and _m_vllm_input:
_available = int(_m_ctx_tok.group(1)) - int(_m_vllm_input.group(1))
_m_requested_out = re.search(r'requested (\d+)\s*output tokens', error_lower)
if 'at least' in error_lower and _m_requested_out:
_requested_out = int(_m_requested_out.group(1))
if _available >= _requested_out - 1:
# The budget is derived from the constraint, not measured.
return max(1, _requested_out // 2)
if _available >= 1:
return _available
return None
# Each entry is a phrase group; the group matches when ALL phrases are present.
_OUTPUT_CAP_SIGNALS = (
("range of max_tokens should be",), # DashScope / Alibaba
("available_tokens",), # Anthropic
("available tokens",),
("in the output", "maximum context length"), # OpenRouter / Nous
("requested", "output tokens"), # LM Studio / llama.cpp
("should be",), # generic "max_tokens should be <= N"
("less than or equal",),
("must be",),
("exceeds model", "maximum output tokens"), # OpenAI-compatible relays
)
_INPUT_OVERFLOW_SIGNALS = (
"prompt is too long", "prompt too long", "input is too long", "input token",
"prompt length", "prompt contains", "reduce the length",
)
# Narrower than _OUTPUT_CAP_SIGNALS: only phrasings we can extract a number from.
_PARSEABLE_OUTPUT_CAP_SIGNALS = (
("max_tokens", "available_tokens"), # Anthropic
("max_tokens", "available tokens"),
("in the output", "maximum context length"), # OpenRouter / Nous
# "requested N output tokens" means the OUTPUT cap is the problem (the
# input itself fits) — reduce max_tokens, don't compress.
("maximum context length", "requested", "output tokens"), # LM Studio / llama.cpp
# DashScope rejects an over-cap output request with a bounded range whose
# upper bound IS the real max-output cap: "Range of max_tokens should be [1, 65536]".
("range of max_tokens should be",),
("exceeds model", "maximum output tokens"), # "max_tokens (98304) exceeds model's maximum output tokens (65536)"
)
def _any_phrase_group(text: str, groups: tuple) -> bool:
return any(all(p in text for p in group) for group in groups)
def is_output_cap_error(error_msg: str) -> bool:
"""Yes/no sibling of :func:`parse_available_output_tokens_from_error` for wordings we can't parse a number from.
An output-cap 400 is deterministic: misclassified as a context overflow it
death-loops the compressor (same max_tokens, same rejection) until "cannot
compress further". Signal: talks about max_tokens as a cap/range/limit and
NOT about the input being too long; when both appear, defer to overflow.
"""
error_lower = error_msg.lower()
if not any(p in error_lower for p in ("max_tokens", "max_output_tokens", "max_completion_tokens")):
return False
if not _any_phrase_group(error_lower, _OUTPUT_CAP_SIGNALS):
return False
# An error that ALSO describes an oversized INPUT is a genuine context
# overflow that happens to mention max_tokens — compression can fix it.
return not any(p in error_lower for p in _INPUT_OVERFLOW_SIGNALS)
def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:
"""Exact match, or ``publisher/slug`` (LM Studio native ids) whose slug equals the configured name."""
return candidate_id == lookup_model or (
"/" in candidate_id and candidate_id.rsplit("/", 1)[1] == lookup_model
)
def query_ollama_num_ctx(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Ollama ``/api/show`` context (Modelfile num_ctx, else GGUF max); the value to send as ``num_ctx``."""
import httpx
bare_model = _strip_provider_prefix(model)
server_url = _server_root(base_url)
try:
server_type = detect_local_server_type(base_url, api_key=api_key)
except Exception:
return None
if server_type != "ollama":
return None
_disk_key = f"{server_url}|{bare_model}"
disk_hit = _local_probe_disk_get("ollama_num_ctx", _disk_key)
if isinstance(disk_hit, int) and disk_hit > 0:
return disk_hit
headers = _auth_headers(api_key)
try:
with httpx.Client(timeout=3.0, headers=headers) as client:
resp = client.post(f"{server_url}/api/show", json={"name": bare_model})
if resp.status_code != 200:
return None
ctx = _ollama_show_context(resp.json(), gguf_first=False)
if ctx is not None:
_local_probe_disk_put("ollama_num_ctx", _disk_key, ctx)
return ctx
except Exception:
pass
return None
def query_ollama_supports_vision(model: str, base_url: str, api_key: str = "") -> Optional[bool]:
"""Return True/False when Ollama ``/api/show`` reports vision support.
Uses the ``capabilities`` field on Ollama 0.6.0+ and falls back to
``model_info.*.vision.block_count`` on older servers. Returns None when
the server is unreachable, not Ollama, or the model is unknown.
"""
import httpx
bare_model = _strip_provider_prefix(model)
if not bare_model or not base_url:
return None
try:
if detect_local_server_type(base_url, api_key=api_key) != "ollama":
return None
except Exception:
return None
server_url = _server_root(base_url)
headers = _auth_headers(api_key)
try:
with httpx.Client(timeout=3.0, headers=headers) as client:
resp = client.post(f"{server_url}/api/show", json={"name": bare_model})
if resp.status_code != 200:
return None
data = resp.json()
except Exception:
return None
caps = data.get("capabilities")
if isinstance(caps, list):
if any(str(cap).lower() == "vision" for cap in caps):
return True
if caps:
return False
model_info = data.get("model_info")
if isinstance(model_info, dict):
for key in model_info:
if "vision.block_count" in str(key).lower():
return True
return None
def _query_ollama_api_show(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Provider-agnostic Ollama ``/api/show`` context probe (any hostname; non-Ollama servers 404 fast).
GGUF-first (hosted users can't set num_ctx) — the reverse of
query_ollama_num_ctx(). Positive results share _LOCAL_CTX_PROBE_CACHE under
a namespaced key (the two probes can differ for the same (model, url)).
"""
import time as _time
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 = _server_root(base_url)
if _endpoint_blackholed(server_url):
return None
headers = _auth_headers(api_key)
try:
with httpx.Client(timeout=5.0, headers=headers) as client:
resp = client.post(f"{server_url}/api/show", json={"name": model})
if resp.status_code != 200:
return None
# Hosted Ollama: the GGUF max is authoritative (the operator may
# have capped num_ctx arbitrarily).
ctx = _ollama_show_context(resp.json(), gguf_first=True, minimum=1024)
if ctx is not None:
return ctx
except Exception as exc:
if _is_connect_timeout(exc):
_note_endpoint_blackholed(server_url)
return None
def _model_name_suggests_kimi(model: str) -> bool:
"""Kimi family (``kimi-*``, ``moonshotai/*``) — guard against stale 32K underreports."""
lower = model.lower()
return lower.startswith("kimi") or "moonshot" in lower
def _model_name_suggests_minimax_m3(model: str) -> bool:
"""MiniMax M3 on any surface — models.dev underreport guard and agent_runtime_helpers cache-control gating."""
return "minimax-m3" in model.lower()
# Catalog keys added AFTER the model was reachable via a shorter catch-all (or
# the 256K fallback): older builds persisted that smaller value and the step-1
# cache hit would pin it forever. A cached value at or below what the old path
# could produce is dropped and re-resolved. Only list keys whose catalog value
# is STRICTLY ABOVE every shorter matching key and the 256K fallback — the
# threshold is inferred from those shorter keys.
_PRE_CATALOG_STALE_KEYS = frozenset({
"minimax-m3", # 1M; "minimax" catch-all persisted 204,800
"grok-4.3", # 1M; "grok-4" catch-all persisted 256,000
"grok-4.6", # 500K; "grok-4" catch-all persisted 256,000
"grok-4-fast", # 2M; fell through to the 256K fallback
"grok-4.20", # 2M; fell through to the 256K fallback
"qwen3.6-plus", # 1M; "qwen" catch-all persisted 131,072
})
def _stale_pre_catalog_cache_entry(model: str, cached: int) -> bool:
"""True when a persisted window is a pre-catalog leftover (see _PRE_CATALOG_STALE_KEYS).
The model must resolve (longest-key-first, as step 8) to a listed key and
the cached value must be <= the largest shorter matching catch-all (or the
256K fallback). Values above that — genuine probe results — are kept.
"""
model_lower = model.lower()
matches = [
(key, value)
for key, value in DEFAULT_CONTEXT_LENGTHS.items()
if key in model_lower
]
if not matches:
return False
specific_key, specific_value = max(matches, key=lambda kv: len(kv[0]))
if specific_key not in _PRE_CATALOG_STALE_KEYS:
return False
if cached >= specific_value:
return False
shorter_values = [v for k, v in matches if len(k) < len(specific_key)]
threshold = max(shorter_values, default=DEFAULT_FALLBACK_CONTEXT)
return cached <= threshold
def _model_name_suggests_minimax(model: str) -> bool:
"""MiniMax family (``minimax*``, ``minimaxai/*``) — guard against stale 32K underreports (real: 204.8K)."""
lower = model.lower()
return lower.startswith("minimax") or "minimaxai/" in lower
def _model_name_suggests_stale_32k_underreport(model: str) -> bool:
"""Return True for model families known to be wrongly underreported as 32K."""
return _model_name_suggests_kimi(model) or _model_name_suggests_minimax(model)
def _query_local_context_length(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Local-server context probe, short-TTL cached (see _LOCAL_CTX_PROBE_CACHE)."""
import time as _time
cache_key = (_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_local_context_length_uncached(model, base_url, api_key=api_key)
# Positive-only: a failure during a startup race must not suppress the
# retry seconds later once the server is up.
if result:
_LOCAL_CTX_PROBE_CACHE[cache_key] = (result, now)
return result
def _query_local_context_length_uncached(model: str, base_url: str, api_key: str = "") -> Optional[int]:
"""Query a local server for the model's context length."""
import httpx
model = _strip_provider_prefix(model)
server_url = _server_root(base_url)
lmstudio_url = _localhost_to_ipv4(_lmstudio_server_root(base_url))
if _endpoint_blackholed(server_url):
return None
headers = _auth_headers(api_key)
try:
server_type = detect_local_server_type(base_url, api_key=api_key)
except Exception:
server_type = None
try:
with httpx.Client(timeout=3.0, headers=headers) as client:
# Ollama: num_ctx (runtime window) before the GGUF training max —
# using the max would let conversations grow past what Ollama
# allocated and it would silently truncate. Matches query_ollama_num_ctx().
if server_type == "ollama":
resp = client.post(f"{server_url}/api/show", json={"name": model})
if resp.status_code == 200:
ctx = _ollama_show_context(resp.json(), gguf_first=False)
if ctx is not None:
return ctx
# LM Studio native /api/v1/models (the OpenAI-compat list omits
# context); loaded-instance config is the runtime value.
if server_type == "lm-studio":
resp = client.get(f"{lmstudio_url}/api/v1/models")
if resp.status_code == 200:
data = resp.json()
for m in data.get("models", []):
if _model_id_matches(m.get("key", ""), model) or _model_id_matches(m.get("id", ""), model):
# Prefer loaded instance context (actual runtime value)
for inst in m.get("loaded_instances", []):
cfg = inst.get("config", {})
ctx = cfg.get("context_length")
if ctx and isinstance(ctx, (int, float)):
return int(ctx)
break
# llama.cpp /props: the RUNTIME n_ctx, answered by the router even
# for a not-yet-loaded model (while /v1/models has meta=null), so a
# lazily-loaded model doesn't fall to a family catch-all.
if server_type == "llamacpp":
for props_path in (f"/props?model={model}", "/props"):
try:
resp = client.get(f"{server_url}{props_path}")
except httpx.HTTPError:
break
if resp.status_code != 200:
continue
n_ctx = (resp.json().get("default_generation_settings")
or {}).get("n_ctx")
if isinstance(n_ctx, (int, float)) and n_ctx:
return int(n_ctx)
# LM Studio / vLLM / llama.cpp / Anthropic-compat proxies:
# try /v1/models/{model}
resp = client.get(f"{server_url}/v1/models/{model}")
if resp.status_code == 200:
data = resp.json()
if isinstance(data, dict):
ctx = _context_length_from_model_payload(data)
if ctx is not None:
return ctx
# Try /v1/models and find the model in the list.
# Use _model_id_matches to handle "publisher/slug" vs bare "slug".
resp = client.get(f"{server_url}/v1/models")
if resp.status_code == 200:
data = resp.json()
models_list = data.get("data", [])
# Match by id; on single-model servers (e.g. llama.cpp) the
# configured name rarely equals the reported id (a GGUF path),
# so fall back to the sole model when nothing matches.
matched = None
for m in models_list:
if not isinstance(m, dict):
continue
if _model_id_matches(m.get("id", ""), model):
matched = m
break
if matched is None and len(models_list) == 1:
matched = models_list[0]
if matched is not None:
# Runtime n_ctx (llama.cpp nests it under meta) beats
# n_ctx_train, which can exceed what the server allocates.
sources = [
s
for s in (matched, matched.get("meta") or {})
if isinstance(s, dict)
]
for source in sources:
val = source.get("n_ctx")
if isinstance(val, (int, float)) and val:
return int(val)
for source in sources:
ctx = _context_length_from_model_payload(source)
if ctx is not None:
return ctx
except Exception as exc:
if _is_connect_timeout(exc):
_note_endpoint_blackholed(server_url)
return None
def _normalize_model_version(model: str) -> str:
"""Dots -> dashes so Nous ids (claude-opus-4-6) compare with OpenRouter's (claude-opus-4.6)."""
return model.replace(".", "-")
def _query_anthropic_context_length(model: str, base_url: str, api_key: str) -> Optional[int]:
"""Anthropic /v1/models max_input_tokens; OAuth tokens (sk-ant-oat*) 401 and are skipped."""
if not api_key or api_key.startswith("sk-ant-oat"):
return None
try:
base = base_url.rstrip("/")
if base.endswith("/v1"):
base = base[:-3]
url = f"{base}/v1/models?limit=1000"
headers = {
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
}
_ensure_requests()
resp = requests.get(url, headers=headers, timeout=(5, 10), verify=_resolve_requests_verify(base_url))
if resp.status_code != 200:
return None
data = resp.json()
for m in data.get("data", []):
if m.get("id") == model:
ctx = m.get("max_input_tokens")
if isinstance(ctx, int) and ctx > 0:
return ctx
except Exception as e:
logger.debug("Anthropic /v1/models query failed: %s", e)
return None
# Codex OAuth `context_window` values (what Codex enforces — lower than the
# direct API for the same slugs). Fallback when the live probe fails;
# longest-key-first substring match.
_CODEX_OAUTH_CONTEXT_FALLBACK: Dict[str, int] = {
"gpt-5.1-codex-max": 272_000,
"gpt-5.1-codex-mini": 272_000,
"gpt-5.3-codex": 272_000,
"gpt-5.3-codex-spark": 128_000, # smaller window; listed so "gpt-5.3-codex" doesn't win
"gpt-5.2-codex": 272_000,
"gpt-5.4-mini": 272_000,
"gpt-5.6-sol": 272_000,
"gpt-5.6-terra": 272_000,
"gpt-5.6-luna": 272_000,
"gpt-daybreak-blue-latest": 272_000,
"gpt-5.5": 272_000,
"gpt-5.4": 272_000,
"gpt-5.2": 272_000,
"gpt-5": 272_000,
}
# Codex OAuth advertises 272K for these families but ACCEPTS ~900K+ (verified
# live: 911,276 input tokens OK on gpt-5.6-sol; terra/luna/gpt-5.4 completed
# 900,026; gpt-5.5 and gpt-5.4-mini genuinely reject >272K and are NOT
# listed). 900K keeps ≥11K margin under the observed ceiling.
#
# OPT-IN ONLY: the large window is exposed via explicit ``-900k`` picker
# variants; base slugs keep 272K so the cheaper limit is the default (a 900K
# default burned subscription usage for people who never asked). The suffix is
# a Hermes-side alias stripped before the wire (strip_codex_context_variant_suffix).
#
# The bump fires ONLY when the resolved value is exactly the stale 272,000
# advertisement; any other advertised number is trusted and the table is
# inert. ``gpt-5.6`` is a FAMILY PREFIX (``-pro`` slugs aren't routable on
# Codex, so over-matching is moot); ``gpt-5.4`` is EXACT because gpt-5.4-mini
# enforces 272K.
_CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES: Dict[str, int] = {
"gpt-5.6": 900_000, # sol / terra / luna
}
_CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT: Dict[str, int] = {
"gpt-5.4": 900_000,
"gpt-daybreak-blue-latest": 900_000, # Daybreak/Sol alias
}
_CODEX_OAUTH_STALE_ADVERTISED_CTX = 272_000 # the only advertised value the bump may override
# Picker suffix opting a Codex slug into the verified large window; never sent on the wire.
CODEX_CONTEXT_VARIANT_SUFFIX = "-900k"
# The ONLY bases eligible for ``-900k``: routable, live-verified. No family
# prefixing here — it would synthesize dead ``-pro`` variants and accept
# unprobed descendants. Dated snapshots of the 5.6 bases are allowed.
_CODEX_900K_ELIGIBLE_BASES = frozenset({
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.4", # exact; gpt-5.4-mini enforces 272K
"gpt-daybreak-blue-latest", # verified Sol alias
})
_CODEX_900K_SNAPSHOT_BASES = ("gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna")
_CODEX_900K_SNAPSHOT_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def _bare_codex_slug(model: Optional[str]) -> str:
"""Lowercased slug without ``vendor/`` (display/auxiliary callers pass ``openai/gpt-5.6-sol-900k``)."""
return (model or "").strip().lower().rsplit("/", 1)[-1]
def is_codex_900k_base(model: Optional[str]) -> bool:
"""Single source of truth for ``-900k`` eligibility (picker, resolution, /model validation, wire stripping)."""
slug = _bare_codex_slug(model)
if not slug or slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX):
return False
if slug in _CODEX_900K_ELIGIBLE_BASES:
return True
# Dated snapshots of the routable 5.6 bases (gpt-5.6-sol-2026-07-09).
for base in _CODEX_900K_SNAPSHOT_BASES:
if slug.startswith(base + "-") and _CODEX_900K_SNAPSHOT_RE.match(
slug[len(base) + 1:]
):
return True
return False
def is_codex_context_variant(model: Optional[str]) -> bool:
"""Suffix AND eligible base — ``gpt-5.5-900k`` is an invalid alias, not a variant."""
slug = _bare_codex_slug(model)
if not slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX):
return False
return is_codex_900k_base(slug[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)])
def strip_codex_context_variant_suffix(model: Optional[str]) -> str:
"""Wire-safe slug with a VALID ``-900k`` suffix removed (vendor prefix kept).
An ineligible alias (``gpt-5.5-900k``) is returned unchanged so it fails
honestly at the API instead of silently running as a different model.
"""
raw = (model or "").strip()
if not raw.lower().endswith(CODEX_CONTEXT_VARIANT_SUFFIX):
return raw
base = raw[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)]
if is_codex_900k_base(base):
return base
return raw
def has_codex_context_variant(model_bare: str) -> bool:
"""Picker-side alias of :func:`is_codex_900k_base`."""
return is_codex_900k_base(model_bare)
def _verified_codex_ctx_for_slug(model_bare: str) -> Optional[int]:
"""Live-verified cap for a VALID ``-900k`` variant only; base slugs and ineligible aliases -> None."""
slug = _bare_codex_slug(model_bare)
if not slug.endswith(CODEX_CONTEXT_VARIANT_SUFFIX):
return None
base = slug[: -len(CODEX_CONTEXT_VARIANT_SUFFIX)]
if not is_codex_900k_base(base):
return None
exact = _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_EXACT.get(base)
if exact is not None:
return exact
for key, ctx in _CODEX_OAUTH_VERIFIED_ABOVE_ADVERTISED_PREFIXES.items():
if base == key or base.startswith(key + "-") or base.startswith(key + "."):
return ctx
return None
_codex_oauth_context_cache: Dict[str, Tuple[Dict[str, int], float]] = {}
_CODEX_OAUTH_CONTEXT_CACHE_TTL = 3600 # 1 hour
def _codex_oauth_token_fingerprint(access_token: str) -> str:
"""Return a non-secret cache key for a Codex OAuth access token."""
return hashlib.sha256(access_token.encode("utf-8")).hexdigest()[:16]
def _extract_chatgpt_account_id(access_token: str) -> Optional[str]:
"""``chatgpt_account_id`` from the Codex OAuth JWT, or None on any parse error.
Without the ``ChatGPT-Account-Id`` header /backend-api/codex/models returns
``{"models":[]}`` (HTTP 200) and the probe silently falls back. Mirrors auxiliary_client.py.
"""
try:
parts = access_token.split(".")
if len(parts) < 2:
return None
payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload_b64))
if not isinstance(claims, dict):
return None
acct_id = claims.get("https://api.openai.com/auth", {}).get("chatgpt_account_id")
return acct_id if isinstance(acct_id, str) and acct_id else None
except Exception:
return None
def _fetch_codex_oauth_context_lengths_with_source(
access_token: str,
) -> Tuple[Dict[str, int], bool]:
"""Codex catalogue ``{slug: context_window}`` plus whether it came from HTTP.
Cached per token fingerprint (windows vary by entitlement; the raw token is
never a key). An in-process hit reports False: it is not a fresh provider
confirmation and must not drive persistent writes.
"""
global _codex_oauth_context_cache
now = time.time()
cache_key = _codex_oauth_token_fingerprint(access_token)
cached = _codex_oauth_context_cache.get(cache_key)
if cached is not None:
cached_models, cached_at = cached
if now - cached_at < _CODEX_OAUTH_CONTEXT_CACHE_TTL:
return cached_models, False
headers = {"Authorization": f"Bearer {access_token}"}
acct_id = _extract_chatgpt_account_id(access_token)
if acct_id:
headers["ChatGPT-Account-Id"] = acct_id
try:
_ensure_requests()
resp = requests.get(
"https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
headers=headers,
timeout=(5, 10),
verify=_resolve_requests_verify(),
)
if resp.status_code != 200:
logger.debug(
"Codex /models probe returned HTTP %s; falling back to hardcoded defaults",
resp.status_code,
)
return {}, False
data = resp.json()
except Exception as exc:
logger.debug("Codex /models probe failed: %s", exc)
return {}, False
entries = data.get("models", []) if isinstance(data, dict) else []
result: Dict[str, int] = {}
for item in entries:
if not isinstance(item, dict):
continue
slug = item.get("slug")
ctx = item.get("context_window")
if isinstance(slug, str) and isinstance(ctx, int) and ctx > 0:
result[slug.strip()] = ctx
if result:
_codex_oauth_context_cache[cache_key] = (result, now)
return result, True
def _resolve_codex_oauth_context_length_with_source(
model: str, access_token: str = ""
) -> Tuple[Optional[int], str]:
"""``(context_length, source)`` for a Codex OAuth slug.
source: "live" (fresh authenticated probe — the only one eligible for
persistent writes), "memory" (same-token in-process hit), "fallback"
(static table), or "" when unresolved.
"""
model_bare = _strip_provider_prefix(model).strip()
if not model_bare:
return None, ""
def _apply_verified_bump(ctx: int, source: str) -> Tuple[int, str]:
"""Lift an EXACT stale 272K advertisement to the verified cap for opted-in ``-900k`` variants only."""
bumped = _verified_codex_ctx_for_slug(model_bare)
if bumped is not None and ctx == _CODEX_OAUTH_STALE_ADVERTISED_CTX:
logger.debug(
"Codex OAuth context for %s: advertised %d raised to "
"live-verified %d", model_bare, ctx, bumped,
)
return bumped, source
return ctx, source
# The Codex catalog only knows the base slug (no -900k, no vendor/).
lookup_bare = _bare_codex_slug(strip_codex_context_variant_suffix(model_bare))
if access_token:
live, fresh_probe = _fetch_codex_oauth_context_lengths_with_source(access_token)
live_source = "live" if fresh_probe else "memory"
if lookup_bare in live:
return _apply_verified_bump(live[lookup_bare], live_source)
# Case-insensitive match in case casing drifts
model_lower = lookup_bare.lower()
for slug, ctx in live.items():
if slug.lower() == model_lower:
return _apply_verified_bump(ctx, live_source)
hit = _longest_key_match(_CODEX_OAUTH_CONTEXT_FALLBACK, lookup_bare.lower())
if hit:
return _apply_verified_bump(hit[1], "fallback")
return None, ""
def _resolve_nous_context_length(
model: str,
base_url: str = "",
api_key: str = "",
) -> Tuple[Optional[int], str]:
"""``(context_length, source)`` for a Nous Portal model.
Portal /v1/models is authoritative ("portal") and may differ from OR (OR
says 1M for qwen3.6-plus; the portal 262144). Fallback matches OR's
prefixed ids against the bare Nous id with dot/dash normalisation
("openrouter" — callers must NOT persist it, or a portal blip freezes the
wrong value forever). "" when unresolved.
"""
if base_url:
portal_ctx = _resolve_endpoint_context_length(model, base_url, api_key=api_key)
if portal_ctx is not None:
return portal_ctx, "portal"
metadata = fetch_model_metadata()
def _safe_ctx(or_id: str, entry: dict) -> Optional[int]:
"""Context length minus the known stale 32K underreports (same guard as step 6)."""
ctx = entry.get("context_length")
if ctx is None:
return None
if ctx <= 32768 and _model_name_suggests_stale_32k_underreport(or_id):
logger.info(
"Rejecting OpenRouter metadata context=%s for %r "
"(known 32K underreport, Nous path); falling through to hardcoded defaults",
ctx, or_id,
)
return None
return ctx
if model in metadata:
ctx = _safe_ctx(model, metadata[model])
if ctx is not None:
return ctx, "openrouter"
normalized = _normalize_model_version(model).lower()
for or_id, entry in metadata.items():
bare = or_id.split("/", 1)[1] if "/" in or_id else or_id
if bare.lower() == model.lower() or _normalize_model_version(bare).lower() == normalized:
ctx = _safe_ctx(or_id, entry)
if ctx is not None:
return ctx, "openrouter"
model_lower = model.lower()
for or_id, entry in metadata.items():
bare = or_id.split("/", 1)[1] if "/" in or_id else or_id
for candidate, query in [(bare.lower(), model_lower), (_normalize_model_version(bare).lower(), normalized)]:
if candidate.startswith(query) and (
len(candidate) == len(query) or candidate[len(query)] in "-:."
):
ctx = _safe_ctx(or_id, entry)
if ctx is not None:
return ctx, "openrouter"
return None, ""
def _validate_cached_context_length(
model: str, base_url: str, cached: int, is_bedrock_context: bool, *, api_key: str = "",
) -> Optional[int]:
"""Step 1 of get_model_context_length: accept, repair, or drop a persisted entry.
Returns the value to use, or None to fall through to live resolution
(the stale entry is invalidated first where noted). Order matters: a
value must be rejected as bogus before any provider-specific handling.
"""
# 0/negative is always a bug (corrupt cache, failed probe, manual edit);
# `0 is not None` would short-circuit the chain and hand the compressor a
# zero window, breaking every status-bar and /usage display downstream.
if cached <= 0:
logger.warning(
"Dropping non-positive cache entry %s@%s -> %s; re-resolving",
model, base_url, cached,
)
_invalidate_cached_context_length(model, base_url)
return None
# Families stale third-party metadata underreports as 32K (Kimi, MiniMax).
if cached <= 32768 and _model_name_suggests_stale_32k_underreport(model):
logger.info(
"Dropping stale cached context entry %s@%s -> %s (known 32K underreport); "
"re-resolving via hardcoded defaults",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
return None
# Pre-catalog leftovers: a shorter catch-all (or the 256K fallback) was
# persisted before the specific entry existed (see _PRE_CATALOG_STALE_KEYS).
if _stale_pre_catalog_cache_entry(model, cached):
logger.info(
"Dropping stale pre-catalog cache entry %s@%s -> %s; "
"re-resolving via hardcoded defaults",
model, base_url, f"{cached:,}",
)
_invalidate_cached_context_length(model, base_url)
return None
# Nous Portal: /v1/models is authoritative. Bypass (don't drop) the cache so
# step 5b reconciles pre-fix OR-seeded entries without touching the on-disk
# file when the portal is unreachable; the 300s in-memory endpoint cache
# makes the per-call cost ~0 within a process.
if _infer_provider_from_url(base_url) == "nous":
logger.debug(
"Bypassing persistent cache for %s@%s (Nous portal authoritative)",
model, base_url,
)
return None
# Bedrock: the static table is a FLOOR, not an override — probe-derived
# entries may legitimately exceed it (real window read from Bedrock's
# length-validation error), so only under-reporting entries are dropped.
if is_bedrock_context:
try:
from agent.bedrock_adapter import get_bedrock_context_length
bedrock_ctx = get_bedrock_context_length(model)
if cached < bedrock_ctx:
logger.info(
"Dropping stale Bedrock cache entry %s@%s -> %s; "
"using static Bedrock table value %s",
model,
base_url,
f"{cached:,}",
f"{bedrock_ctx:,}",
)
_invalidate_cached_context_length(model, base_url)
return bedrock_ctx
except ImportError:
pass
return cached
if is_local_endpoint(base_url):
return _reconcile_local_cached_context_length(model, base_url, cached, api_key=api_key)
return cached
def _resolve_bedrock_context_length(model: str, base_url: str) -> Optional[int]:
"""Step 1b: Bedrock static table + one cached live probe; None when boto3 is absent.
Bedrock exposes no context window via metadata APIs, so
get_bedrock_context_length() probes the live endpoint (one fast
pre-inference length rejection). The result is cached per model — keyed by
base_url, else a synthetic bedrock:// key so display/offline paths share it.
"""
try:
from agent.bedrock_adapter import (
get_bedrock_context_length,
resolve_bedrock_region,
)
except ImportError:
return None # boto3 not installed — fall through to generic resolution
cache_key_url = base_url or "bedrock://"
cached = get_cached_context_length(model, cache_key_url)
if cached is not None:
return cached
# Region from the base_url host first, then the standard AWS chain. An
# empty region disables probing (table only).
region = ""
if base_url:
_m = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url)
if _m:
region = _m.group(1)
if not region:
try:
region = resolve_bedrock_region()
except Exception:
region = ""
ctx = get_bedrock_context_length(model, region=region, probe=bool(region))
# Only persist probe-derived values (region present); a pure table fallback
# must not poison the cache against a later successful probe.
if ctx and region:
save_context_length(model, cache_key_url, ctx)
return ctx
def _resolve_custom_endpoint_context_length(model: str, base_url: str, api_key: str, provider: str) -> int:
"""Steps 2-3 for a truly custom endpoint: /models, local probes, Ollama /api/show, catalog, default."""
context_length = _resolve_endpoint_context_length(model, base_url, api_key=api_key)
if context_length is not None:
return context_length
# Local endpoints: the Modelfile-aware probe first. _query_local_context_length
# prefers num_ctx, while _query_ollama_api_show returns the GGUF training max
# first, which can be larger and would create a false-safe compression window.
if is_local_endpoint(base_url):
local_ctx = _probe_local_context_length(model, base_url, api_key, provider)
if local_ctx:
return local_ctx
# 2b. Ollama native /api/show (GGUF-first for non-local). Non-Ollama servers 404/405 quickly.
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
if ctx is not None:
if not _skip_persistent_context_cache(base_url, provider):
save_context_length(model, base_url, ctx)
return ctx
# 3. Probe-down fallback after endpoint-specific detection failed
logger.info(
"Could not detect context length for model %r at %s — "
"defaulting to %s tokens (probe-down). Set model.context_length "
"in config.yaml to override.",
model, base_url, f"{DEFAULT_FALLBACK_CONTEXT:,}",
)
# 3b. Hardcoded catalog as a last resort: a proxied Anthropic gateway fails
# the probes above but its model name still matches DEFAULT_CONTEXT_LENGTHS
# (e.g. "claude-opus-4-8" -> 1M); without this the early return would
# silently cap context at 256K.
hit = _longest_key_match(DEFAULT_CONTEXT_LENGTHS, model.lower())
if hit:
logger.info(
"Using hardcoded context length %s for model %r "
"(custom endpoint, catalog match on %r)",
f"{hit[1]:,}", model, hit[0],
)
return hit[1]
# Same silent-256K bug class as the step-9 fallback — warn here too.
_warn_context_length_fallback(model, base_url)
return DEFAULT_FALLBACK_CONTEXT
def get_model_context_length(
model: str,
base_url: str = "",
api_key: str = "",
config_context_length: int | None = None,
provider: str = "",
custom_providers: list | None = None,
) -> int:
"""Get the context length for a model.
Resolution order:
0. Explicit config override (model.context_length or custom_providers per-model)
0b. model_overrides config (per-provider+model context_window override)
0c. Endpoint-scoped metadata for models validated on one multiplexed endpoint
1. Persistent cache (previously discovered via probing). Nous URLs,
LM Studio, and Codex OAuth bypass the cache here so their provider
metadata can be reconciled against the authoritative live source.
1b. AWS Bedrock static table (must precede custom-endpoint probe)
2. Active endpoint metadata (/models for explicit custom endpoints)
3. Local server query (for local endpoints)
4. Anthropic /v1/models API (API-key users only, not OAuth)
5. Provider-aware lookups (before generic OpenRouter cache):
a. Copilot live /models API
b. Nous: live /v1/models probe first (authoritative), then OR
cache fallback with suffix/version normalisation. Only
portal-derived values are persisted to disk.
c. Codex OAuth /models probe
d. GMI /models endpoint
e. Ollama native /api/show probe (any base_url, provider-agnostic)
f. models.dev registry lookup (with :cloud/-cloud suffix fallback)
6. OpenRouter live API metadata (Kimi-family 32k guard)
7. Local server query (before hardcoded defaults for local endpoints)
8. Hardcoded defaults (broad family patterns, longest-key-first)
9. Default fallback (256K)"""
# 0. Explicit config override — user knows best
if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:
return config_context_length
# 0a. MoA virtual provider: ``model`` is a preset name and ``base_url`` the
# local virtual endpoint, so every probe would miss. The aggregator is the
# acting model — resolve its real provider+model (references are advisory
# and never bound the acting context). Falls through on failure.
if (provider or "").strip().lower() == "moa":
try:
from hermes_cli.config import (
get_compatible_custom_providers,
load_config,
)
from hermes_cli.moa_config import resolve_moa_preset
from hermes_cli.runtime_provider import resolve_runtime_provider
config = load_config()
effective_custom_providers = custom_providers
if effective_custom_providers is None:
effective_custom_providers = get_compatible_custom_providers(config)
preset = resolve_moa_preset(config.get("moa") or {}, model)
agg = preset.get("aggregator") or {}
agg_provider = str(agg.get("provider") or "").strip()
agg_model = str(agg.get("model") or "").strip()
if agg_model and agg_provider and agg_provider.lower() != "moa":
rt = resolve_runtime_provider(requested=agg_provider, target_model=agg_model)
return get_model_context_length(
agg_model,
base_url=rt.get("base_url", "") or "",
api_key=rt.get("api_key", "") or "",
provider=rt.get("provider") or agg_provider,
custom_providers=effective_custom_providers,
)
except Exception:
logger.debug("MoA aggregator context-length resolution failed", exc_info=True)
# 0b. model_overrides: EXPLICIT per-provider+model context_window only.
# Fill-gap _default entries apply later inside lookup_models_dev_context
# (step 5f) once the catalog has missed, so a _default can never preempt
# custom_providers or live probes. Config-read only; never touches the network.
if provider and model:
try:
from agent.models_dev import _override_context_window
mo_ctx = _override_context_window(provider, model)
if mo_ctx is not None and mo_ctx > 0:
return mo_ctx
except Exception:
pass # fall through to other resolution paths
# 0c. custom_providers per-model override — before any probe, so /model
# switch and display paths honour a per-model context_length.
if custom_providers and base_url and model:
try:
from hermes_cli.config import get_custom_provider_context_length
cp_ctx = get_custom_provider_context_length(
model=model,
base_url=base_url,
custom_providers=custom_providers,
)
if cp_ctx:
return cp_ctx
except Exception:
pass # fall through to probing
# Malformed URLs (e.g. unmatched IPv6 bracket) make urllib.parse raise;
# treat them as an unknown endpoint so the inference layer reports the
# configuration error itself.
if base_url:
try:
parsed_base_url = urlparse(_normalize_base_url(base_url))
_ = parsed_base_url.port
except ValueError:
base_url = ""
# A blank model id would fuzzy-match an arbitrary catalog entry (`"" in key`
# is vacuously true) and persist it under a junk "@<base_url>" cache key.
if not str(model or "").strip():
logger.info(
"No model id provided for context length resolution — defaulting to %s tokens.",
f"{DEFAULT_FALLBACK_CONTEXT:,}",
)
return DEFAULT_FALLBACK_CONTEXT
# Bare id for cache lookups and server queries ("local:x" -> "x"; Ollama
# "model:tag" colons preserved).
model = _strip_provider_prefix(model)
# Endpoint-scoped metadata goes AHEAD of the persistent cache so a value
# learned on a multiplexed provider's other endpoint cannot override the
# endpoint where the model was actually validated.
endpoint_context = _endpoint_scoped_context_length(model, base_url)
if endpoint_context is not None:
return endpoint_context
is_bedrock_context = provider == "bedrock" or (
base_url
and base_url_hostname(base_url).startswith("bedrock-runtime.")
and base_url_host_matches(base_url, "amazonaws.com")
)
# 1. Persistent cache (LM Studio / Codex OAuth excluded — see
# _skip_persistent_context_cache).
if base_url and not _skip_persistent_context_cache(base_url, provider):
cached = get_cached_context_length(model, base_url)
if cached is not None:
validated = _validate_cached_context_length(
model, base_url, cached, is_bedrock_context, api_key=api_key,
)
if validated is not None:
return validated
# 1b. AWS Bedrock static table + probe. Must run BEFORE the custom-endpoint
# step: bedrock-runtime.<region>.amazonaws.com is not in _URL_TO_PROVIDER,
# so it would be treated as a custom endpoint, fail the /models probe and
# fall back to the default.
if is_bedrock_context:
ctx = _resolve_bedrock_context_length(model, base_url)
if ctx is not None:
return ctx
if provider == "novita" or (base_url and base_url_host_matches(base_url, "api.novita.ai")):
ctx = _resolve_endpoint_context_length(model, base_url or "https://api.novita.ai/openai/v1", api_key=api_key)
if ctx is not None:
if base_url:
save_context_length(model, base_url, ctx)
return ctx
# 2. Live /models for truly custom endpoints. Known providers skip this:
# their /models may report a provider-imposed limit (Copilot: 128k) rather
# than the model's window (400k); models.dev is consulted at step 5+.
if _is_custom_endpoint(base_url) and not _is_known_provider_base_url(base_url):
return _resolve_custom_endpoint_context_length(model, base_url, api_key, provider)
# 4. Anthropic /v1/models API (only for regular API keys, not OAuth)
if provider == "anthropic" or (
base_url and base_url_hostname(base_url) == "api.anthropic.com"
):
ctx = _query_anthropic_context_length(model, base_url or "https://api.anthropic.com", api_key)
if ctx:
return ctx
# 5. Provider-aware lookups — before the generic OR cache, since the same
# model has different limits per provider (claude-opus-4.6: 1M on
# Anthropic, 128K on Copilot). Generic providers are inferred from the URL.
effective_provider = provider
if base_url and (not effective_provider or effective_provider in {"openrouter", "custom"}):
effective_provider = _infer_provider_from_url(base_url) or effective_provider
# 5a. Copilot live /models — account-specific models (claude-opus-4.6-1m)
# absent from models.dev, and the provider-enforced limit for the rest.
if effective_provider in {"copilot", "copilot-acp", "github-copilot"}:
try:
from hermes_cli.models import get_copilot_model_context
ctx = get_copilot_model_context(model, api_key=api_key)
if ctx:
return ctx
except Exception:
pass # Fall through to models.dev
if effective_provider == "nous":
ctx, source = _resolve_nous_context_length(
model, base_url=base_url or "", api_key=api_key or ""
)
if ctx:
# Persist ONLY portal-derived values: an OR-fallback value cached
# on a portal blip would be frozen in by step 1 forever.
if base_url and source == "portal":
save_context_length(model, base_url, ctx)
return ctx
if effective_provider == "openai-codex":
# Codex OAuth enforces lower limits than the direct API for the same
# slug (gpt-5.5: 1.05M vs 272K); its own /models is authoritative.
codex_ctx, codex_source = _resolve_codex_oauth_context_length_with_source(
model, access_token=api_key or "",
)
if codex_ctx:
# Only a fresh authenticated catalogue response may be persisted;
# the static fallback must not poison future probes.
if base_url and codex_source == "live":
save_context_length(model, base_url, codex_ctx)
return codex_ctx
if effective_provider == "gmi" and base_url:
# GMI exposes authoritative context_length via /models, but it is not
# in models.dev yet. Preserve that higher-fidelity endpoint lookup.
ctx = _resolve_endpoint_context_length(model, base_url, api_key=api_key)
if ctx is not None:
return ctx
# 5e. Ollama native /api/show for any base_url that is not a known
# non-Ollama provider (OpenAI-compat /v1/models omits context_length; the
# GGUF model_info is authoritative). Known hosted providers are skipped:
# the POST always 404s and cost ~300ms on the first-turn critical path.
if base_url:
_inferred_for_probe = _infer_provider_from_url(base_url)
_skip_ollama_probe = (
_inferred_for_probe is not None
and "ollama" not in _inferred_for_probe
)
if not _skip_ollama_probe:
ctx = _query_ollama_api_show(model, base_url, api_key=api_key)
if ctx is not None:
if not _skip_persistent_context_cache(base_url, provider):
save_context_length(model, base_url, ctx)
return ctx
# 5f. OpenRouter live /models — authoritative for OR-routed models and
# refreshed as new slugs ship, so it must win over models.dev (5g) and the
# family catch-all (8): otherwise a brand-new slug (claude-fable-5, 1M)
# falls through to the generic "claude": 200K entry.
if effective_provider == "openrouter":
metadata = fetch_model_metadata()
entry = metadata.get(model)
if entry:
or_ctx = entry.get("context_length")
# Guard against the known OpenRouter Kimi-family 32k underreport
# (same class the hardcoded overrides exist to mitigate).
if isinstance(or_ctx, int) and or_ctx > 0 and not (
or_ctx == 32768 and _model_name_suggests_kimi(model)
):
return or_ctx
if effective_provider:
from agent.models_dev import lookup_models_dev_context
ctx = lookup_models_dev_context(effective_provider, model)
if ctx:
# MiniMax M3: models.dev reports 512K but actual context is 1M.
# Prefer hardcoded catalog over stale probe value.
if _model_name_suggests_minimax_m3(model):
catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3")
if catalog and ctx < catalog:
logger.info(
"Rejecting models.dev context=%s for %r "
"(MiniMax-M3 underreport); using hardcoded default %s",
ctx, model, f"{catalog:,}",
)
ctx = catalog
return ctx
# 6. OpenRouter metadata, provider-unaware fallback — only when the
# provider is unknown (OR data is community-maintained).
if not effective_provider:
metadata = fetch_model_metadata()
if model in metadata:
or_ctx = metadata[model].get("context_length", DEFAULT_FALLBACK_CONTEXT)
# Guard against stale OpenRouter metadata for model families
# known to be underreported as 32K.
if or_ctx == 32768 and _model_name_suggests_stale_32k_underreport(model):
logger.info(
"Rejecting OpenRouter metadata context=%s for %r "
"(known 32K underreport); falling through to hardcoded defaults",
or_ctx, model,
)
else:
return or_ctx
# 7. Query local server before hardcoded defaults — model names like
# ``Hermes-3-Llama-3.1-70B`` substring-match ``llama`` (131072) even when
# vLLM is running at a lower ``--max-model-len`` (e.g. 32768 on limited VRAM).
if base_url and is_local_endpoint(base_url):
local_ctx = _probe_local_context_length(model, base_url, api_key, provider)
if local_ctx:
return local_ctx
# 8. Hardcoded defaults: `key in model` only — the reverse would let
# "claude-sonnet-4" match "claude-sonnet-4-6" and return 1M.
hit = _longest_key_match(DEFAULT_CONTEXT_LENGTHS, model.lower())
if hit:
return hit[1]
# 9. Default fallback — warn (deduped per model+endpoint) so small-context
# models don't silently get 256K.
_warn_context_length_fallback(model, base_url)
return DEFAULT_FALLBACK_CONTEXT
async def get_model_context_length_async(
model: str,
base_url: str = "",
api_key: str = "",
config_context_length: int | None = None,
provider: str = "",
custom_providers: list | None = None,
) -> int:
"""get_model_context_length on a worker thread (its blocking HTTP would stall the event loop)."""
import asyncio
return await asyncio.to_thread(
get_model_context_length,
model,
base_url=base_url,
api_key=api_key,
config_context_length=config_context_length,
provider=provider,
custom_providers=custom_providers,
)
def _is_cjk_token_dense_char(ch: str) -> bool:
code = ord(ch)
return (
0x1100 <= code <= 0x11FF # Hangul Jamo
or 0x2E80 <= code <= 0x9FFF # CJK radicals/ideographs
or 0xA960 <= code <= 0xA97F # Hangul Jamo Extended-A
or 0xAC00 <= code <= 0xD7AF # Hangul Syllables
or 0xF900 <= code <= 0xFAFF # CJK compatibility ideographs
or 0xFF00 <= code <= 0xFFEF # Fullwidth forms / halfwidth kana
)
# Same ranges as _is_cjk_token_dense_char (MUST stay in sync) so dense-char
# counting runs in C rather than a per-char Python loop.
_CJK_DENSE_RE = re.compile(
"[\u1100-\u11ff" # Hangul Jamo
"\u2e80-\u9fff" # CJK radicals/ideographs
"\ua960-\ua97f" # Hangul Jamo Extended-A
"\uac00-\ud7af" # Hangul Syllables
"\uf900-\ufaff" # CJK compatibility ideographs
"\uff00-\uffef]" # Fullwidth forms / halfwidth kana
)
def estimate_tokens_rough(text: str) -> int:
"""Rough token estimate: ceil(chars/4), CJK/Hangul/Kana codepoints ~1 token each.
Ceiling division keeps short texts from estimating 0 (systematic
undercount with many short tool results). Runs on every message of every
preflight walk, so the all-ASCII case must stay O(1): ``str.isascii()`` is
a flag check on CPython and the CJK count is a single C-level regex pass.
"""
if not text:
return 0
text = str(text)
if text.isascii():
return (len(text) + 3) // 4
dense = len(text) - len(_CJK_DENSE_RE.sub("", text))
if not dense: # non-ASCII but no CJK (accents, Cyrillic, emoji)
return (len(text) + 3) // 4
sparse = len(text) - dense
return dense + ((sparse + 3) // 4)
def estimate_messages_tokens_rough(
messages: List[Dict[str, Any]], *, charge_stale_thinking: bool = True,
) -> int:
"""Rough token estimate for a message list (pre-flight only).
Images cost a flat ~1500 tokens each (Anthropic's model) rather than their
base64 length, which would put a 1MB screenshot at ~250K.
``charge_stale_thinking=False`` mirrors the tail-budget walk
(``context_compressor._estimate_msg_budget_tokens``): on routes that don't
echo stale reasoning, ``reasoning``/``reasoning_content`` ride the wire only
for the NEWEST assistant turn, so excluding them elsewhere keeps the
compaction TRIGGER in the same size class as the walk — otherwise
reasoning-heavy sessions fire preflight forever while the walk finds
nothing to compact. Default True is the conservative full charge.
Per-message results are memoized on an identity fingerprint (see
``_estimate_message_tokens_cached``); equal fingerprints imply identical
leaves and structure, hence identical estimates.
"""
_IMAGE_TOKEN_COST = 1500
if not charge_stale_thinking:
messages = _strip_stale_thinking_for_estimate(messages)
total = 0
for msg in messages:
total += _estimate_message_tokens_cached(msg, _IMAGE_TOKEN_COST)
return total
# Generic thinking-text keys replayed for at most the newest assistant turn
# on non-echo routes — must stay in lockstep with
# ``context_compressor._NEWEST_TURN_ONLY_BUDGET_KEYS``.
_STALE_THINKING_ESTIMATE_KEYS = ("reasoning", "reasoning_content")
def _strip_stale_thinking_for_estimate(
messages: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""Copy of ``messages`` with stale thinking keys removed (newest kept).
Shallow stripped copies share the original value objects, so the
per-message memo still hits for the stripped shape on subsequent walks.
"""
newest = -1
for i in range(len(messages) - 1, -1, -1):
m = messages[i]
if isinstance(m, dict) and m.get("role") == "assistant":
newest = i
break
out: List[Dict[str, Any]] = []
for i, m in enumerate(messages):
if (
i != newest
and isinstance(m, dict)
and m.get("role") == "assistant"
and any(m.get(k) for k in _STALE_THINKING_ESTIMATE_KEYS)
):
m = {
k: v for k, v in m.items()
if k not in _STALE_THINKING_ESTIMATE_KEYS
}
out.append(m)
return out
# Per-message token-estimate memo. The estimate is a pure function of the
# message value, so a fingerprint that uniquely determines the value is exact:
# * strings by ``id()`` AND pinned (strong ref in the entry) — while the
# entry lives the id can't be reused, and strings are immutable, so
# id-equality implies value-equality;
# * ints/floats/bools/None by value; dicts/lists structurally, preserving
# key order (``str(shadow)`` depends on it); any other type aborts the memo.
# api_messages shallow-copies history dicts each turn but shares the content
# strings, so unchanged messages still hit.
_MSG_TOKENS_CACHE: Dict[Any, Tuple[list, int]] = {}
_MSG_TOKENS_CACHE_MAX = 4096
def _msg_fingerprint(value: Any, pins: list) -> Any:
if value is None or value is True or value is False:
return value
t = type(value)
if t is str:
pins.append(value)
return ("s", id(value))
if t is int or t is float:
return ("n", t.__name__, value)
if t is dict:
return ("d", tuple(
(_msg_fingerprint(k, pins), _msg_fingerprint(v, pins))
for k, v in value.items()
))
if t is list:
return ("l", tuple(_msg_fingerprint(v, pins) for v in value))
if t is tuple:
return ("t", tuple(_msg_fingerprint(v, pins) for v in value))
raise ValueError("unfingerprintable message value")
def _estimate_message_tokens_cached(msg: Any, image_cost: int) -> int:
try:
pins: list = []
key = _msg_fingerprint(msg, pins)
hash(key)
except Exception:
return (
_estimate_message_tokens_without_images(msg)
+ _count_image_tokens(msg, image_cost)
)
cached = _MSG_TOKENS_CACHE.get(key)
if cached is not None:
return cached[1]
tokens = (
_estimate_message_tokens_without_images(msg)
+ _count_image_tokens(msg, image_cost)
)
_MSG_TOKENS_CACHE[key] = (pins, tokens)
while len(_MSG_TOKENS_CACHE) > _MSG_TOKENS_CACHE_MAX:
try:
_MSG_TOKENS_CACHE.pop(next(iter(_MSG_TOKENS_CACHE)))
except (StopIteration, KeyError, RuntimeError):
break
return tokens
def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int:
"""Count image-like content parts in a message; return their token cost."""
count = 0
content = msg.get("content") if isinstance(msg, dict) else None
if isinstance(content, list):
for part in content:
if not isinstance(part, dict):
continue
ptype = part.get("type")
if ptype in {"image", "image_url", "input_image"}:
count += 1
stashed = msg.get("_anthropic_content_blocks") if isinstance(msg, dict) else None
if isinstance(stashed, list):
for part in stashed:
if isinstance(part, dict) and part.get("type") == "image":
count += 1
# Multimodal tool results that haven't been converted yet.
if isinstance(content, dict) and content.get("_multimodal"):
inner = content.get("content")
if isinstance(inner, list):
for part in inner:
if isinstance(part, dict) and part.get("type") in {"image", "image_url"}:
count += 1
return count * cost_per_image
def _wire_message_shadow(msg: Dict[str, Any]) -> Dict[str, Any]:
"""Shadow of a message holding only what the provider actually receives.
* ``api_content`` SUBSTITUTES ``content`` (``turn_context.substitute_api_content``
pops it and overwrites content at every API-bound build), so exactly one
is counted. The guard is mirrored exactly: only a non-empty STRING sidecar
on a user/assistant row displaces content; any other shape is discarded on
the wire, and substituting it would UNDERcount — the dangerous direction
(compaction fires too late, the turn dies on a hard context error).
* Base64 images become a placeholder; ``_count_image_tokens`` charges them flat.
"""
sidecar = msg.get("api_content")
sidecar_wins = (
isinstance(sidecar, str)
and bool(sidecar)
and msg.get("role") in ("user", "assistant")
)
# ``reasoning`` never ships as-is: request builds pop it after optionally
# promoting it into ``reasoning_content``. When both exist (reasoning-echo
# providers pin reasoning_content; reasoning holds the same text for the
# trajectory) counting both inflated estimates up to +53%; keep
# ``reasoning`` only as the promotion proxy when nothing displaces it.
_rc = msg.get("reasoning_content")
drop_reasoning_dup = isinstance(_rc, str) and bool(_rc.strip())
shadow: Dict[str, Any] = {}
for k, v in msg.items():
if k in ("_anthropic_content_blocks", "reasoning_details") or k in PERSISTENCE_ONLY_MESSAGE_FIELDS:
continue
if k == "reasoning" and drop_reasoning_dup:
continue
if k == "api_content":
if sidecar_wins:
shadow["content"] = v
continue
if k == "content":
if sidecar_wins:
continue
if isinstance(v, list):
cleaned = []
for part in v:
if isinstance(part, dict):
if part.get("type") in {"image", "image_url", "input_image"}:
cleaned.append({"type": part.get("type"), "image": "[stripped]"})
else:
cleaned.append(part)
else:
cleaned.append(part)
shadow[k] = cleaned
elif isinstance(v, dict) and v.get("_multimodal"):
shadow[k] = v.get("text_summary", "")
else:
shadow[k] = v
else:
shadow[k] = v
return shadow
def _estimate_message_tokens_without_images(msg: Dict[str, Any]) -> int:
"""Token estimate for a message shadow with image payloads stripped."""
if not isinstance(msg, dict):
return estimate_tokens_rough(str(msg))
return estimate_tokens_rough(str(_wire_message_shadow(msg)))
def estimate_request_tokens_rough(
messages: List[Dict[str, Any]],
*,
system_prompt: str = "",
tools: Optional[List[Dict[str, Any]]] = None,
charge_stale_thinking: bool = True,
) -> int:
"""Rough token estimate for a full request: system prompt + messages + tool
schemas (50+ tools add 20-30K on their own). ``charge_stale_thinking``
is forwarded — pass False when the route provably strips stale thinking
(``message_sanitization.stale_thinking_reaches_wire``)."""
total = 0
if system_prompt:
total += estimate_tokens_rough(system_prompt)
if messages:
if charge_stale_thinking:
# Positional call: test seams and plugin engines monkeypatch
# estimate_messages_tokens_rough with (messages)-only signatures.
total += estimate_messages_tokens_rough(messages)
else:
total += estimate_messages_tokens_rough(
messages, charge_stale_thinking=False
)
if tools:
total += _estimate_tools_tokens_rough(tools)
return total
# Usage-anchored context accounting: ``usage.prompt_tokens`` is EXACT ground
# truth for everything sent on that request, so anchoring on the last real
# usage shrinks chars/4 estimation to the messages appended since and the
# error self-corrects at every response. Anchor dict fields:
# prompt_tokens / completion_tokens — provider usage at capture.
# base_count — len(messages) at capture; the reply for that response is not
# yet appended and its cost is covered by completion_tokens, so the delta
# walk skips it when it appears at index base_count.
# base_last_id / base_last_role — identity of the last message at capture;
# compaction/splices/rewrites replace it and fall back to full estimation.
def capture_usage_anchor(
prompt_tokens: Any,
completion_tokens: Any,
messages: List[Dict[str, Any]],
) -> Optional[Dict[str, Any]]:
"""Build a usage anchor from provider-reported usage, or None."""
try:
pt = int(prompt_tokens or 0)
ct = int(completion_tokens or 0)
except (TypeError, ValueError):
return None
if pt <= 0 or not isinstance(messages, list):
return None # no usable usage (some endpoints omit it) — caller keeps its anchor
base_count = len(messages)
last = messages[-1] if base_count else None
return {
"prompt_tokens": pt,
"completion_tokens": max(0, ct),
"base_count": base_count,
"base_last_id": id(last) if last is not None else None,
"base_last_role": last.get("role") if isinstance(last, dict) else None,
}
def anchored_context_tokens(
messages: List[Dict[str, Any]],
anchor: Optional[Dict[str, Any]],
*,
charge_stale_thinking: bool = True,
) -> Optional[int]:
"""Anchored prompt+completion tokens plus a rough estimate of ONLY the
messages appended since; None when the anchor is missing or stale. The
anchored response's own reply is skipped (already in completion_tokens).
``charge_stale_thinking`` is forwarded to the delta estimate."""
if not isinstance(anchor, dict) or not isinstance(messages, list):
return None
base_count = anchor.get("base_count") or 0
if base_count <= 0 or len(messages) < base_count:
return None
base_msg = messages[base_count - 1]
if id(base_msg) != anchor.get("base_last_id"):
return None
base_role = base_msg.get("role") if isinstance(base_msg, dict) else None
if base_role != anchor.get("base_last_role"):
return None
total = int(anchor["prompt_tokens"]) + int(anchor.get("completion_tokens") or 0)
delta = messages[base_count:]
if delta:
first = delta[0]
if isinstance(first, dict) and first.get("role") == "assistant":
delta = delta[1:]
if delta:
total += estimate_messages_tokens_rough(
delta, charge_stale_thinking=charge_stale_thinking
)
return total
# Keyed by ``id(tools)``; bounded, evicts oldest-first. Avoids repeated
# ``str(tools)`` on large schemas, which stalls GUI event loops under GIL pressure.
_TOOLS_TOKENS_CACHE: dict[int, Tuple[int, str, str, int]] = {}
_TOOLS_TOKENS_CACHE_MAX = 256
def _tool_name_for_cache(tool: Any) -> str:
if not isinstance(tool, dict):
return ""
fn = tool.get("function")
if isinstance(fn, dict):
name = fn.get("name")
if isinstance(name, str):
return name
name = tool.get("name")
return name if isinstance(name, str) else ""
def _estimate_tools_tokens_rough(tools: List[Dict[str, Any]]) -> int:
if not tools:
return 0
key = id(tools)
n = len(tools)
first = _tool_name_for_cache(tools[0]) if n else ""
last = _tool_name_for_cache(tools[-1]) if n else ""
cached = _TOOLS_TOKENS_CACHE.get(key)
if cached is not None:
cached_n, cached_first, cached_last, cached_tokens = cached
if cached_n == n and cached_first == first and cached_last == last:
return cached_tokens
# Sum the major schema fields (descriptions + parameters dominate).
total_chars = 0
for tool in tools:
if not isinstance(tool, dict):
continue
fn = tool.get("function")
if isinstance(fn, dict):
name = fn.get("name") or ""
desc = fn.get("description") or ""
params = fn.get("parameters") or {}
else:
name = tool.get("name") or ""
desc = tool.get("description") or ""
params = tool.get("parameters") or {}
if isinstance(name, str):
total_chars += len(name)
if isinstance(desc, str):
total_chars += len(desc)
try: # JSON is closer to wire size than repr()
total_chars += len(json.dumps(params, ensure_ascii=False, separators=(",", ":")))
except Exception:
total_chars += len(str(params))
tokens = (total_chars + 3) // 4
if len(_TOOLS_TOKENS_CACHE) >= _TOOLS_TOKENS_CACHE_MAX:
_TOOLS_TOKENS_CACHE.pop(next(iter(_TOOLS_TOKENS_CACHE)), None)
_TOOLS_TOKENS_CACHE[key] = (n, first, last, tokens)
return tokens