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
Binary file added .dev-workflow/code-graph.db
Binary file not shown.
14 changes: 14 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1255,6 +1255,20 @@ def init_agent(
# AFTER the custom_providers branch so per-model overrides aren't lost.
agent._config_context_length = _config_context_length

# Check custom_providers per-model max_tokens (symmetric to context_length above)
if agent.max_tokens is None and _custom_providers:
try:
from hermes_cli.config import get_custom_provider_max_tokens
_cp_max_tokens = get_custom_provider_max_tokens(
model=agent.model,
base_url=agent.base_url,
custom_providers=_custom_providers,
)
if _cp_max_tokens:
agent.max_tokens = int(_cp_max_tokens)
except Exception:
pass

agent._ensure_lmstudio_runtime_loaded(_config_context_length)


Expand Down
91 changes: 66 additions & 25 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3169,30 +3169,23 @@ def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:

return compatible


def get_custom_provider_context_length(
model: str,
base_url: str,
custom_providers: Optional[List[Dict[str, Any]]] = None,
config: Optional[Dict[str, Any]] = None,
) -> Optional[int]:
"""Look up a per-model ``context_length`` override from ``custom_providers``.
def get_custom_provider_model_field(
field: str,
model: str | None = None,
base_url: str | None = None,
*,
custom_providers: list | None = None,
config: dict | None = None,
value_type: type = int,
positive_only: bool = True,
) -> int | None:
"""Look up a per-model field override from ``custom_providers``.

Matches any entry whose ``base_url`` equals ``base_url`` (trailing-slash
insensitive) and returns ``custom_providers[i].models.<model>.context_length``
insensitive) and returns ``custom_providers[i].models.<model>.<field>``
if present and valid. Returns ``None`` when no override applies.

This is the single source of truth for custom-provider context overrides,
used by:
* ``AIAgent.__init__`` (startup resolution)
* ``AIAgent.switch_model`` (mid-session ``/model`` switch)
* ``hermes_cli.model_switch.resolve_display_context_length`` (``/model`` confirmation display)
* ``gateway.run._format_session_info`` (``/info`` display)
* ``agent.model_metadata.get_model_context_length`` (when custom_providers is threaded through)

Before this helper existed, the lookup was duplicated in ``run_agent.py``'s
startup path only; every other path (notably ``/model`` switch) fell back
to the 128K default. See #15779.
Generic helper used for both ``context_length`` and ``max_tokens`` lookups.
"""
if not model or not base_url:
return None
Expand Down Expand Up @@ -3223,18 +3216,66 @@ def get_custom_provider_context_length(
model_cfg = models.get(model)
if not isinstance(model_cfg, dict):
continue
raw_ctx = model_cfg.get("context_length")
if raw_ctx is None:
raw_val = model_cfg.get(field)
if raw_val is None:
continue
try:
ctx = int(raw_ctx)
val = value_type(raw_val)
except (TypeError, ValueError):
continue
if ctx > 0:
return ctx
if positive_only and val <= 0:
continue
return val
return None


def get_custom_provider_context_length(
model: str | None = None,
base_url: str | None = None,
custom_providers: list | None = None,
config: dict | None = None,
) -> int | None:
"""Look up a per-model ``context_length`` override from ``custom_providers``.

Matches any entry whose ``base_url`` equals ``base_url`` (trailing-slash
insensitive) and returns ``custom_providers[i].models.<model>.context_length``
if present and valid. Returns ``None`` when no override applies.

This is the single source of truth for custom-provider context overrides,
used by:
* ``AIAgent.__init__`` (startup resolution)
* ``AIAgent.switch_model`` (mid-session ``/model`` switch)
* ``hermes_cli.model_switch.resolve_display_context_length`` (``/model`` confirmation display)
* ``gateway.run._format_session_info`` (``/info`` display)
* ``agent.model_metadata.get_model_context_length`` (when custom_providers is threaded through)

Before this helper existed, the lookup was duplicated in ``run_agent.py``'s
startup path only; every other path (notably ``/model`` switch) fell back
to the 128K default. See #15779.
"""
return get_custom_provider_model_field(
"context_length", model, base_url,
custom_providers=custom_providers, config=config,
)


def get_custom_provider_max_tokens(
model: str | None = None,
base_url: str | None = None,
custom_providers: list | None = None,
config: dict | None = None,
) -> int | None:
"""Look up a per-model ``max_tokens`` override from ``custom_providers``.

Symmetric to ``get_custom_provider_context_length`` but for the output
token limit. Returns ``None`` when no override applies.
"""
return get_custom_provider_model_field(
"max_tokens", model, base_url,
custom_providers=custom_providers, config=config,
)


def check_config_version() -> Tuple[int, int]:
"""
Check config version.
Expand Down
158 changes: 158 additions & 0 deletions tests/hermes_cli/test_custom_provider_max_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""Regression tests for custom_providers per-model max_tokens resolution.

Covers the fix for #28046 — custom_providers per-model max_tokens was silently
ignored, always defaulting to 4096. The fix adds a symmetric lookup to the
existing context_length mechanism via get_custom_provider_model_field.
"""
from __future__ import annotations

from hermes_cli.config import get_custom_provider_max_tokens


class TestGetCustomProviderMaxTokens:
def test_returns_override_for_matching_entry(self):
custom = [
{
"name": "my-endpoint",
"base_url": "https://example.invalid/v1",
"models": {"gpt-5.5": {"max_tokens": 16384}},
}
]
assert (
get_custom_provider_max_tokens(
"gpt-5.5", "https://example.invalid/v1", custom
)
== 16384
)

def test_trailing_slash_insensitive(self):
custom = [
{
"base_url": "https://example.invalid/v1/",
"models": {"m": {"max_tokens": 8192}},
}
]
assert (
get_custom_provider_max_tokens(
"m", "https://example.invalid/v1", custom
)
== 8192
)

def test_returns_none_when_no_override(self):
custom = [
{
"base_url": "https://other.invalid/v1",
"models": {"m": {"max_tokens": 4096}},
}
]
assert (
get_custom_provider_max_tokens(
"m", "https://example.invalid/v1", custom
)
is None
)

def test_returns_none_for_zero_value(self):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"max_tokens": 0}},
}
]
assert (
get_custom_provider_max_tokens(
"m", "https://example.invalid/v1", custom
)
is None
)

def test_returns_none_for_negative_value(self):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"max_tokens": -100}},
}
]
assert (
get_custom_provider_max_tokens(
"m", "https://example.invalid/v1", custom
)
is None
)

def test_returns_none_for_string_value(self):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"max_tokens": "16384"}},
}
]
# int("16384") succeeds so this should work
assert (
get_custom_provider_max_tokens(
"m", "https://example.invalid/v1", custom
)
== 16384
)

def test_returns_none_for_non_int_string(self):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"max_tokens": "16K"}},
}
]
assert (
get_custom_provider_max_tokens(
"m", "https://example.invalid/v1", custom
)
is None
)

def test_coexists_with_context_length(self):
"""Both fields can be present in the same model config."""
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {
"m": {
"context_length": 256_000,
"max_tokens": 8192,
}
},
}
]
from hermes_cli.config import get_custom_provider_context_length

assert (
get_custom_provider_context_length("m", "https://example.invalid/v1", custom)
== 256_000
)
assert (
get_custom_provider_max_tokens("m", "https://example.invalid/v1", custom)
== 8192
)

def test_first_matching_entry_wins(self):
custom = [
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"max_tokens": 4096}},
},
{
"base_url": "https://example.invalid/v1",
"models": {"m": {"max_tokens": 8192}},
},
]
assert (
get_custom_provider_max_tokens(
"m", "https://example.invalid/v1", custom
)
== 4096
)

def test_none_inputs(self):
assert get_custom_provider_max_tokens(None, "url", []) is None
assert get_custom_provider_max_tokens("m", None, []) is None
assert get_custom_provider_max_tokens(None, None, []) is None
Loading