Files
hermes-agent/tests/agent/test_auxiliary_structured_output.py
teknium1 5e17f210c1 fix(aux): scope the response_format rejection memo to the model and to capability errors
The process-level memo of routes that rejected a response_format type was
keyed by (endpoint host, type) only. On an aggregator host (openrouter.ai,
the Nous Portal, api.openai.com) one chat-only model's json_schema 400 then
stripped the field from every other model on that host for the rest of the
process, silently degrading schema enforcement for models that support it.
The memo was also fed by any error _is_structured_output_rejection matched,
including the by-shape rule (response_format + json_schema in the body),
which also matches schema-VALIDATION 400s from json_schema-capable providers
("Invalid schema for response_format 'json_schema': additionalProperties
must be false") - those say nothing about the next schema.

- key the memo by (endpoint, model, type); _build_call_kwargs passes the
  model to the lookup and every remember_* caller (both fallback candidate
  paths and the primary ladder rung) now hands over the rejecting error
- memoise only capability rejections (unavailable / not supported / unknown
  or unsupported parameter / extra inputs not permitted); the by-shape match
  still drives the one-shot retry but never feeds the memo
- a custom route whose base_url host is api.deepseek.com consults the
  DeepSeek profile (via the existing base_url->provider inference), so the
  profile gate also fires when the provider name was overridden to custom
- test module docstring: only title_generator and plugin_llm attach
  response_format on aux calls
2026-09-17 09:11:38 -07:00

140 lines
7.5 KiB
Python

"""Structured-output (``response_format``) handling on auxiliary routes that reject it.
Covers NousResearch/hermes-agent#83390 / #105191 / #113064 for the aux callers that attach
``response_format`` (``agent/title_generator.py`` and ``agent/plugin_llm.py``): a fallback candidate
that rejects ``json_schema`` gets the same retry-without-the-field rung as the primary path instead of
aborting the task, and a route+model known to reject a ``response_format`` type (provider profile or a
capability rejection already seen in this process) never pays the guaranteed-fail first request.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from agent import auxiliary_structured_output as structured_output
from agent.auxiliary_client import (
_build_call_kwargs,
_call_fallback_candidate_async,
_call_fallback_candidate_sync,
_is_structured_output_rejection,
)
_JSON_SCHEMA = {"type": "json_schema", "json_schema": {"name": "t", "strict": True, "schema": {"type": "object"}}}
_DEEPSEEK_400 = ("Error code: 400 - {'error': {'message': 'This response_format type is unavailable now', "
"'type': 'invalid_request_error', 'param': None, 'code': 'invalid_request_error'}}")
class _Rejects400(Exception):
status_code = 400
def _ok_response():
return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content='{"title": "x"}'))])
def _rejecting_client(base_url, *, async_mode=False):
"""Fake OpenAI client: 400 while ``extra_body.response_format`` is present, 200 once it is gone."""
client = MagicMock(base_url=base_url)
sent = []
def create(**kwargs):
sent.append(kwargs)
if "response_format" in (kwargs.get("extra_body") or {}):
raise _Rejects400(_DEEPSEEK_400)
return _ok_response()
async def acreate(**kwargs):
return create(**kwargs)
client.chat.completions.create.side_effect = acreate if async_mode else create
return client, sent
@pytest.mark.parametrize("async_mode", [False, True])
def test_fallback_candidate_retries_without_response_format_on_rejection(async_mode):
"""The fallback path used to re-raise the 400; now it degrades to prompt compliance like the primary path."""
client, sent = _rejecting_client("http://relay.example:8080/v1", async_mode=async_mode)
call = _call_fallback_candidate_async if async_mode else _call_fallback_candidate_sync
common = dict(
task="title_generation", messages=[{"role": "user", "content": "Reply with JSON only"}],
temperature=None, max_tokens=64, tools=None, effective_timeout=30.0,
effective_extra_body={"response_format": dict(_JSON_SCHEMA)}, reasoning_config=None,
)
result = call(client, "relay-model", "fallback_chain[0](custom)", **common)
if async_mode:
result = asyncio.run(result)
assert result.choices[0].message.content == '{"title": "x"}'
assert [("response_format" in (k.get("extra_body") or {})) for k in sent] == [True, False]
# The rejection is remembered: the next request to this route skips the field up front.
retry_kwargs = _build_call_kwargs(
"custom", "relay-model", [{"role": "user", "content": "hi"}],
extra_body={"response_format": dict(_JSON_SCHEMA)}, base_url="http://relay.example:8080/v1")
assert "response_format" not in retry_kwargs.get("extra_body", {})
def test_known_unsupported_route_skips_response_format_before_first_request():
"""DeepSeek's profile declares json_schema unsupported; json_object and other providers are untouched."""
messages = [{"role": "user", "content": "hi"}]
deepseek = _build_call_kwargs(
"deepseek", "deepseek-flash", messages, extra_body={"response_format": dict(_JSON_SCHEMA)},
base_url="https://api.deepseek.com/v1", task="title_generation")
assert "response_format" not in deepseek.get("extra_body", {})
json_object = _build_call_kwargs(
"deepseek", "deepseek-flash", messages, extra_body={"response_format": {"type": "json_object"}},
base_url="https://api.deepseek.com/v1")
assert json_object["extra_body"]["response_format"] == {"type": "json_object"}
openai_kwargs = _build_call_kwargs(
"openai", "gpt-5-mini", messages, extra_body={"response_format": dict(_JSON_SCHEMA)},
base_url="https://api.openai.com/v1")
assert openai_kwargs["extra_body"]["response_format"] == _JSON_SCHEMA
# A remembered rejection is scoped to the endpoint (host:port), not to every local server.
structured_output.remember_structured_output_rejection(
"custom", "http://127.0.0.1:1234/v1",
{"model": "m", "extra_body": {"response_format": dict(_JSON_SCHEMA)}}, _Rejects400(_DEEPSEEK_400))
same = _build_call_kwargs("custom", "m", messages, extra_body={"response_format": dict(_JSON_SCHEMA)},
base_url="http://127.0.0.1:1234/v1")
other = _build_call_kwargs("custom", "m", messages, extra_body={"response_format": dict(_JSON_SCHEMA)},
base_url="http://127.0.0.1:11434/v1")
assert "response_format" not in same.get("extra_body", {})
assert other["extra_body"]["response_format"] == _JSON_SCHEMA
# A custom route pointed at DeepSeek's own host gets DeepSeek's profile.
custom_deepseek = _build_call_kwargs(
"custom", "deepseek-chat", messages, extra_body={"response_format": dict(_JSON_SCHEMA)},
base_url="https://api.deepseek.com/v1")
assert "response_format" not in custom_deepseek.get("extra_body", {})
def test_rejection_memo_is_per_model_and_ignores_schema_validation_errors():
"""One model's ``json_schema`` rejection on an aggregator host must not strip the field for every other
model on that host, and a schema-VALIDATION 400 from a json_schema-capable provider is retried but
never memoised (it says nothing about the next schema)."""
messages = [{"role": "user", "content": "hi"}]
openrouter = "https://openrouter.ai/api/v1"
structured_output.remember_structured_output_rejection(
"openrouter", openrouter,
{"model": "some/chat-only-model", "extra_body": {"response_format": dict(_JSON_SCHEMA)}},
_Rejects400("Error code: 400 - {'error': {'message': 'response_format is not supported by this model'}}"))
rejected = _build_call_kwargs("openrouter", "some/chat-only-model", messages,
extra_body={"response_format": dict(_JSON_SCHEMA)}, base_url=openrouter)
sibling = _build_call_kwargs("openrouter", "openai/gpt-5-mini", messages,
extra_body={"response_format": dict(_JSON_SCHEMA)}, base_url=openrouter)
assert "response_format" not in rejected.get("extra_body", {})
assert sibling["extra_body"]["response_format"] == _JSON_SCHEMA
validation_400 = _Rejects400(
"Error code: 400 - Invalid schema for response_format 'json_schema': In context=(), "
"'additionalProperties' is required to be supplied and to be false.")
assert _is_structured_output_rejection(validation_400) # still drives the one-shot retry ...
structured_output.remember_structured_output_rejection(
"openai", "https://api.openai.com/v1",
{"model": "gpt-5-mini", "extra_body": {"response_format": dict(_JSON_SCHEMA)}}, validation_400)
after = _build_call_kwargs("openai", "gpt-5-mini", messages,
extra_body={"response_format": dict(_JSON_SCHEMA)}, base_url="https://api.openai.com/v1")
assert after["extra_body"]["response_format"] == _JSON_SCHEMA # ... but never feeds the memo