Files
hermes-agent/tests/agent/test_unset_reasoning_effort_default.py
teknium1 53815e24dc fix: send reasoning_effort=medium on custom endpoints when agent.reasoning_effort is unset
An unset agent.reasoning_effort already resolves to medium on the Nous Portal,
OpenRouter, AI Gateway and Copilot routes (each profile fills it in
build_api_kwargs_extras). The custom / OpenAI-compatible profile — every
`providers.<name>` block and `--provider custom` — omitted the field instead,
so the endpoint's own default applied; for moonshotai/kimi-k3 that is `max`:
3x the reasoning tokens and ~3x the latency of medium, measured live.

The default is resolved at request time in _reasoning_config_for_wire via
ProviderProfile.default_reasoning_config (the custom profile answers medium),
so it is recorded as what actually went out and the reasoning-rejection
ladder keeps working: a 400 on the field turns the rest of the session back
to "omit". It never touches an explicit effort (low stays low, none stays
none), stays off non chat-completions transports (the Anthropic adapter's
unset = no thinking kwargs stands), off models the catalog or model_overrides
mark supports_reasoning: false, and off local Ollama models pulled without
the thinking capability. Auxiliary calls are untouched: they hand the profile
reasoning_config=None directly, which still omits the field.

Live wire capture (token-injecting proxy, providers.probe -> kimi-k3):
  before  req_reasoning: {}
  after   req_reasoning: {'reasoning_effort': 'medium'}
  agent.reasoning_effort: low  ->  {'reasoning_effort': 'low'}  (unchanged)
2026-09-20 16:03:38 -07:00

92 lines
4.4 KiB
Python

"""An unset ``agent.reasoning_effort`` resolves to ``medium`` on custom / OpenAI-compatible routes.
The Nous, OpenRouter and AI Gateway profiles already fill medium when no effort is configured; the
custom profile (``providers.<name>`` blocks, ``--provider custom``) omitted the field, so the
endpoint's own default applied — for kimi-k3 that is ``max``: 3x the reasoning tokens and ~3x the
latency of medium. The default is decided at request time in ``_reasoning_config_for_wire`` so the
rejection ladder sees exactly what went out, and it never overrides an explicit effort.
"""
from unittest.mock import patch
import pytest
from agent.chat_completion_helpers import _build_api_kwargs_for_mode
from agent.models_dev import ModelCapabilities
from agent.transports.chat_completions import ChatCompletionsTransport
from providers import get_provider_profile
class _Agent:
"""The attributes ``_build_api_kwargs_for_mode`` reads before handing off to the transport builder."""
def __init__(self, reasoning_config, *, provider="custom:relay", api_mode="chat_completions"):
self.reasoning_config = reasoning_config
self.provider = provider
self.model = "moonshotai/kimi-k3"
self.api_mode = api_mode
self.base_url = "http://relay.example/v1"
self.tools = []
self.request_overrides = None
self.service_tier = None
self._fast_until = 0.0
self._ephemeral_reasoning_off = False
self._reasoning_effort_rejected = False
self._reasoning_disable_rejected = False
self._ollama_num_ctx: int | None = None
def __getattr__(self, name):
return lambda *args, **kwargs: None
def _wire_reasoning_config(agent):
"""The ``reasoning_config`` the main loop hands the transport for this agent's next request."""
def _capture(agent, api_messages, tools_for_api, reasoning_config, request_overrides, cache_scope_id):
return {"reasoning_config": reasoning_config}
with patch("agent.chat_completion_helpers._build_chat_completions_kwargs", _capture):
return _build_api_kwargs_for_mode(agent, [], [])["reasoning_config"]
def _custom_wire_field(reasoning_config):
kwargs = ChatCompletionsTransport().build_kwargs(
"moonshotai/kimi-k3", [{"role": "user", "content": "hi"}], tools=None,
provider_profile=get_provider_profile("custom:relay"), reasoning_config=reasoning_config,
base_url="http://relay.example/v1",
)
return kwargs.get("reasoning_effort")
@pytest.mark.parametrize(
"configured, expected_wire",
[(None, "medium"), ({"enabled": True, "effort": "low"}, "low"), ({"enabled": False}, "none")],
)
def test_unset_effort_goes_out_as_medium_and_explicit_efforts_are_untouched(configured, expected_wire):
agent = _Agent(configured)
with patch("agent.models_dev.get_model_capabilities", return_value=None):
sent = _wire_reasoning_config(agent)
assert _custom_wire_field(sent) == expected_wire
# The rejection ladder classifies the NEXT 400 from what was recorded as sent.
assert agent._wire_reasoning_config == sent
def test_unset_effort_default_keeps_the_field_off_where_it_would_be_wrong():
# A model the catalog / model_overrides mark as non-reasoning.
with patch("agent.models_dev.get_model_capabilities", return_value=ModelCapabilities(supports_reasoning=False)):
assert _wire_reasoning_config(_Agent(None)) is None
with patch("agent.models_dev.get_model_capabilities", return_value=None):
# The route already rejected the reasoning field this session (#112781 ladder → route default).
rejected = _Agent(None)
rejected._reasoning_effort_rejected = True
assert _wire_reasoning_config(rejected) is None
# A local Ollama model pulled without the thinking capability 400s on reasoning_effort.
ollama = _Agent(None)
ollama._ollama_num_ctx = 8192
ollama._ollama_supports_thinking_cached = lambda: False
assert _wire_reasoning_config(ollama) is None
# Profiles that decide inside build_api_kwargs_extras (Nous fills medium itself) get None as before.
assert _wire_reasoning_config(_Agent(None, provider="nous")) is None
# Not a chat-completions wire: the Anthropic adapter's "unset = no thinking kwargs" stands.
from agent.reasoning_params import unset_reasoning_default
assert unset_reasoning_default(_Agent(None, api_mode="anthropic_messages")) is None