Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,69 @@ def _resolve_nous_context_length(model: str) -> Optional[int]:
return None


def read_config_context_length(
model: str,
base_url: str = "",
) -> int | None:
"""Read config_context_length from config.yaml for the given model.

Checks model.context_length first, then custom_providers[].models.<model>.context_length.
Returns None if not configured.
"""
try:
from hermes_cli.config import load_config, get_compatible_custom_providers
except ImportError:
return None

try:
cfg = load_config()
except Exception:
return None

# 1. model.context_length (top-level override)
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
raw = model_cfg.get("context_length")
if raw is not None:
try:
val = int(raw)
if val > 0:
return val
except (TypeError, ValueError):
pass

# 2. custom_providers[].models.<model>.context_length
if base_url:
try:
custom_providers = get_compatible_custom_providers(cfg)
except Exception:
custom_providers = cfg.get("custom_providers", [])
if not isinstance(custom_providers, list):
custom_providers = []

normalized_url = base_url.rstrip("/")
for cp_entry in custom_providers:
if not isinstance(cp_entry, dict):
continue
cp_url = (cp_entry.get("base_url") or "").rstrip("/")
if cp_url and cp_url == normalized_url:
cp_models = cp_entry.get("models", {})
if isinstance(cp_models, dict):
cp_model_cfg = cp_models.get(model, {})
if isinstance(cp_model_cfg, dict):
cp_ctx = cp_model_cfg.get("context_length")
if cp_ctx is not None:
try:
val = int(cp_ctx)
if val > 0:
return val
except (TypeError, ValueError):
pass
break

return None


def get_model_context_length(
model: str,
base_url: str = "",
Expand Down
14 changes: 12 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4712,11 +4712,16 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None:
_cprint(f" Capabilities: {mi.format_capabilities()}")
else:
try:
from agent.model_metadata import get_model_context_length
from agent.model_metadata import get_model_context_length, read_config_context_length
_config_ctx = read_config_context_length(
result.new_model,
base_url=result.base_url or self.base_url or "",
)
ctx = get_model_context_length(
result.new_model,
base_url=result.base_url or self.base_url,
api_key=result.api_key or self.api_key,
config_context_length=_config_ctx,
provider=result.target_provider,
)
_cprint(f" Context: {ctx:,} tokens")
Expand Down Expand Up @@ -4939,11 +4944,16 @@ def _handle_model_switch(self, cmd_original: str):
else:
# Fallback to old context length lookup
try:
from agent.model_metadata import get_model_context_length
from agent.model_metadata import get_model_context_length, read_config_context_length
_config_ctx = read_config_context_length(
result.new_model,
base_url=result.base_url or self.base_url or "",
)
ctx = get_model_context_length(
result.new_model,
base_url=result.base_url or self.base_url,
api_key=result.api_key or self.api_key,
config_context_length=_config_ctx,
provider=result.target_provider,
)
_cprint(f" Context: {ctx:,} tokens")
Expand Down
14 changes: 12 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -3645,12 +3645,17 @@ async def _prepare_inbound_message_text(
if "@" in message_text:
try:
from agent.context_references import preprocess_context_references_async
from agent.model_metadata import get_model_context_length
from agent.model_metadata import get_model_context_length, read_config_context_length

_msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~"))
_config_ctx = read_config_context_length(
self._model,
base_url=self._base_url or "",
)
_msg_ctx_len = get_model_context_length(
self._model,
base_url=self._base_url or "",
config_context_length=_config_ctx,
)
_ctx_result = await preprocess_context_references_async(
message_text,
Expand Down Expand Up @@ -5349,11 +5354,16 @@ async def _on_model_selected(
lines.append(f"Capabilities: {mi.format_capabilities()}")
else:
try:
from agent.model_metadata import get_model_context_length
from agent.model_metadata import get_model_context_length, read_config_context_length
_config_ctx = read_config_context_length(
result.new_model,
base_url=result.base_url or current_base_url or "",
)
ctx = get_model_context_length(
result.new_model,
base_url=result.base_url or current_base_url,
api_key=result.api_key or current_api_key,
config_context_length=_config_ctx,
provider=result.target_provider,
)
lines.append(f"Context: {ctx:,} tokens")
Expand Down
106 changes: 106 additions & 0 deletions tests/agent/test_model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
get_model_context_length,
get_next_probe_tier,
get_cached_context_length,
read_config_context_length,
parse_context_limit_from_error,
save_context_length,
fetch_model_metadata,
Expand Down Expand Up @@ -711,3 +712,108 @@ def test_special_chars_in_model_name(self, tmp_path):
with patch("agent.model_metadata._get_context_cache_path", return_value=cache_file):
save_context_length(model, url, 200000)
assert get_cached_context_length(model, url) == 200000


# =========================================================================
# read_config_context_length — config.yaml context_length resolution
# =========================================================================

class TestReadConfigContextLength:
"""Tests for read_config_context_length() helper."""

def test_model_context_length_from_config(self):
"""model.context_length in config is returned."""
mock_cfg = {"model": {"context_length": 500000}}
with patch("hermes_cli.config.load_config", return_value=mock_cfg):
result = read_config_context_length("any-model")
assert result == 500000

def test_custom_providers_context_length(self):
"""custom_providers[].models.<model>.context_length is returned."""
mock_cfg = {
"custom_providers": [{
"name": "my-provider",
"base_url": "https://api.example.com/v1",
"models": {
"gpt-5.4": {"context_length": 1000000},
},
}],
}
with patch("hermes_cli.config.load_config", return_value=mock_cfg), \
patch("hermes_cli.config.get_compatible_custom_providers", return_value=mock_cfg["custom_providers"]):
result = read_config_context_length(
"gpt-5.4",
base_url="https://api.example.com/v1",
)
assert result == 1000000

def test_model_context_length_takes_priority(self):
"""model.context_length overrides custom_providers per-model."""
mock_cfg = {
"model": {"context_length": 256000},
"custom_providers": [{
"name": "my-provider",
"base_url": "https://api.example.com/v1",
"models": {"gpt-5.4": {"context_length": 1000000}},
}],
}
with patch("hermes_cli.config.load_config", return_value=mock_cfg):
result = read_config_context_length(
"gpt-5.4",
base_url="https://api.example.com/v1",
)
assert result == 256000

def test_no_config_returns_none(self):
"""No config data returns None."""
with patch("hermes_cli.config.load_config", return_value={}):
result = read_config_context_length("some-model")
assert result is None

def test_invalid_context_length_ignored(self):
"""Non-integer context_length is ignored."""
mock_cfg = {"model": {"context_length": "not-a-number"}}
with patch("hermes_cli.config.load_config", return_value=mock_cfg):
result = read_config_context_length("some-model")
assert result is None

def test_zero_context_length_ignored(self):
"""context_length=0 is treated as unset."""
mock_cfg = {"model": {"context_length": 0}}
with patch("hermes_cli.config.load_config", return_value=mock_cfg):
result = read_config_context_length("some-model")
assert result is None

def test_base_url_mismatch_skips_custom_providers(self):
"""Non-matching base_url skips custom_providers lookup."""
mock_cfg = {
"custom_providers": [{
"name": "my-provider",
"base_url": "https://other.example.com/v1",
"models": {"gpt-5.4": {"context_length": 1000000}},
}],
}
with patch("hermes_cli.config.load_config", return_value=mock_cfg), \
patch("hermes_cli.config.get_compatible_custom_providers", return_value=mock_cfg["custom_providers"]):
result = read_config_context_length(
"gpt-5.4",
base_url="https://api.example.com/v1",
)
assert result is None

def test_trailing_slash_normalized(self):
"""Trailing slashes in base_url are normalized for matching."""
mock_cfg = {
"custom_providers": [{
"name": "my-provider",
"base_url": "https://api.example.com/v1",
"models": {"gpt-5.4": {"context_length": 1000000}},
}],
}
with patch("hermes_cli.config.load_config", return_value=mock_cfg), \
patch("hermes_cli.config.get_compatible_custom_providers", return_value=mock_cfg["custom_providers"]):
result = read_config_context_length(
"gpt-5.4",
base_url="https://api.example.com/v1/",
)
assert result == 1000000