Files
hermes-agent/tests/agent/test_output_cap_parsing.py
T
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

194 lines
8.2 KiB
Python

import pytest
from agent.model_metadata import (
is_output_cap_error,
parse_available_output_tokens_from_error,
)
class TestParseOpenRouterOutputCap:
"""OpenRouter/Nous phrase the output-cap error as a context breakdown."""
def test_openrouter_breakdown_format(self):
msg = ("This endpoint's maximum context length is 200000 tokens. "
"However, you requested about 195000 tokens "
"(150000 of text input, 40000 of tool input, 5000 in the output).")
# available output = 200000 - 150000 - 40000 = 10000
assert parse_available_output_tokens_from_error(msg) == 10000
class TestParseCharBasedOutputCap:
"""LM Studio / llama.cpp report context in tokens but prompt in characters.
These servers send a hard 400 even on a trivial prompt when the default
output cap equals the context window (#42741): the request asks for the
whole window as output, leaving zero room for input.
"""
def test_char_based_output_cap_format(self):
msg = ("This model's maximum context length is 65536 tokens. However, "
"you requested 65536 output tokens and your prompt contains "
"77409 characters (more than 0 characters, which is the upper "
"bound for 0 input tokens). Please reduce the length of the "
"input prompt or the number of requested output tokens.")
# est input = ceil(77409 / 3) = 25803; available = 65536 - 25803 = 39733
assert parse_available_output_tokens_from_error(msg) == 39733
def test_char_based_leaves_room_for_input(self):
# The whole point: the retried output cap + the estimated input must
# fit inside the reported context window.
ctx = 65536
chars = 77409
available = parse_available_output_tokens_from_error(
f"maximum context length is {ctx} tokens. However, you requested "
f"{ctx} output tokens and your prompt contains {chars} characters."
)
assert available is not None
assert available + (chars + 2) // 3 <= ctx
class TestParseDashScopeOutputCap:
"""DashScope / Alibaba Cloud (Qwen) reject an over-cap output request with
a bounded range whose upper bound is the real max-output cap (#55546)."""
def test_dashscope_range_format(self):
msg = ("HTTP 400: InternalError.Algo.InvalidParameter: "
"Range of max_tokens should be [1, 65536]")
assert parse_available_output_tokens_from_error(msg) == 65536
def test_dashscope_range_arbitrary_bound(self):
msg = "Range of max_tokens should be [1, 8192]"
assert parse_available_output_tokens_from_error(msg) == 8192
def test_dashscope_range_with_spaces(self):
msg = "range of max_tokens should be [ 1 , 32768 ]"
assert parse_available_output_tokens_from_error(msg) == 32768
class TestParseMaximumOutputTokensCap:
"""Some OpenAI-compatible relays report the model's separate output cap."""
def test_parenthesized_max_output_cap(self):
msg = (
"API call failed after 3 retries: [400]: max_tokens (98304) "
"exceeds model's maximum output tokens (65536)"
)
assert parse_available_output_tokens_from_error(msg) == 65536
def test_parenthesized_max_output_cap_is_output_cap(self):
assert is_output_cap_error(
"max_tokens (98304) exceeds model's maximum output tokens (65536)"
) is True
class TestIsOutputCapError:
"""`is_output_cap_error` is the broader yes/no gate that keeps an
output-cap 400 out of the compression death-loop even when we can't parse
a number from the provider's wording (#55546)."""
def test_dashscope_is_output_cap(self):
assert is_output_cap_error(
"Range of max_tokens should be [1, 65536]"
) is True
def test_anthropic_available_tokens_is_output_cap(self):
assert is_output_cap_error(
"max_tokens: 32768 > context_window: 200000 - "
"input_tokens: 190000 = available_tokens: 10000"
) is True
def test_real_input_overflow_is_not_output_cap(self):
# Mentions max_tokens but the INPUT is the problem -> compression path.
assert is_output_cap_error(
"prompt is too long: 250000 tokens > 200000 max_tokens window"
) is False
def test_gpt5_unsupported_param_is_not_output_cap(self):
# format_error caught earlier; must NOT be treated as an output cap.
assert is_output_cap_error(
"Unsupported parameter: 'max_tokens' is not supported with this "
"model. Use 'max_completion_tokens' instead."
) is False
def test_unrelated_error_is_not_output_cap(self):
assert is_output_cap_error("some unrelated 400 error") is False
class TestParseVllmTokenBasedOutputCap:
"""vLLM reports both the window and the prompt in TOKENS.
Until this format was parsed, the recovery path misclassified it as
prompt-too-long and looped through compression (which frees little) while
retrying with the same oversized max_tokens — terminating in "cannot
compress further" even though simply lowering the output cap would have
succeeded.
"""
# Verbatim vLLM 0.22 / OpenAI-compatible server response (max_tokens set).
_VLLM_MSG = (
"This model's maximum context length is 131072 tokens. However, you "
"requested 65536 output tokens and your prompt contains at least "
"65537 input tokens, for a total of at least 131073 tokens. Please "
"reduce the length of the input prompt or the number of requested "
"output tokens."
)
# Verbatim vLLM response where the input is MEASURED, not back-computed:
# window - input != requested - 1, so the reported figure is real.
_VLLM_MSG_REAL_INPUT = (
"This model's maximum context length is 131072 tokens. However, you "
"requested 65536 output tokens and your prompt contains 100000 "
"input tokens, for a total of 165536 tokens. Please reduce the length "
"of the input prompt or the number of requested output tokens."
)
def test_vllm_token_based_format(self):
# The reported input is a LOWER BOUND that vLLM back-computes from the
# constraint (65537 == 131072 + 1 - 65536), so window - input is just
# requested - 1 and carries no information about the real prompt.
# Halve the requested cap instead so the retry actually converges.
assert parse_available_output_tokens_from_error(self._VLLM_MSG) == 32768
def test_vllm_measured_input_is_trusted(self):
# When the input is measured rather than derived, use it as-is.
# available output = 131072 - 100000 = 31072
assert parse_available_output_tokens_from_error(
self._VLLM_MSG_REAL_INPUT
) == 31072
def test_vllm_retry_fits_inside_window(self):
# The retried cap plus the reported input must fit in the window.
available = parse_available_output_tokens_from_error(self._VLLM_MSG)
assert available is not None
assert available + 65537 <= 131072
def test_vllm_retry_converges(self):
"""The retry sequence must reach a working cap in a few attempts.
Regression test for the 65-tokens-per-retry crawl: with a 102400
window and a real prompt of ~37000 tokens, retrying from a 65536 cap
used to produce 65471 -> 65406 -> 65341 and exhaust the compression
budget without ever fitting.
"""
window, real_input, cap = 102400, 37000, 65536
for _ in range(5):
if real_input + cap <= window:
break
# vLLM's message when max_tokens is the binding constraint.
msg = (
f"This model's maximum context length is {window} tokens. "
f"However, you requested {cap} output tokens and your prompt "
f"contains at least {window + 1 - cap} input tokens, for a "
f"total of at least {window + 1} tokens."
)
available = parse_available_output_tokens_from_error(msg)
assert available is not None
assert available < cap, "each retry must lower the cap"
cap = available
assert real_input + cap <= window, f"did not converge: cap={cap}"