Files
vectorize-io__hindsight/hindsight-api-slim/tests/test_codex_reasoning_effort.py
T
Nicolò Boschi 7729396e12 fix(llm): give every provider a real per-request deadline (#3898) (#3946)
The Codex provider never read the configured LLM timeout. The factory did not
pass one, and CodexLLM extends LLMInterface (not the LLMProvider base that
assigns self.timeout), so the class had no timeout attribute at all -- the three
call sites hardcoded httpx timeout=120.0.

That literal is a per-socket-read timeout, and the body was fetched with a
buffering client.post(), so a backend wedged into runaway generation reset it
forever: one consolidation call was read for ~830 s (~12 MB of SSE deltas for a
~340-character answer) until the backend closed the connection, holding the
reserved consolidation slot for the whole time. Three such stalls cost ~1.7 h on
one bank.

- LLMInterface now takes and stores `timeout`, so no provider can silently drop
  it, and the factory threads the resolved value to the five that were missing
  it: codex, gemini, anthropic, fireworks and llamacpp.
- Codex reads the SSE body with `client.stream()` inside an `asyncio.timeout`
  that covers the request *and* the parse, so the configured timeout is a total
  deadline rather than an idle one, plus a body-size ceiling that abandons a
  fast runaway stream in seconds instead of buffering it. Both surface as
  CodexRunawayStreamError, an httpx.RequestError, so the existing retry/backoff
  path handles them unchanged.
- Gemini's hardcoded 90 s and Anthropic's own 300 s default become the
  unconfigured fallbacks rather than the only values.

Codex tests move onto a shared streaming stub since the provider no longer calls
client.post(). The consolidation wall-clock ceiling the issue also asks for
already landed in #3746 (unreleased); it is an idle ceiling defaulting to 7200 s,
so it would not have ended an 830 s stall on its own.

Claude-Session: https://claude.ai/code/session_018HDqrzHgqZqsGc7EDqoTEu
2026-09-01 09:15:06 +02:00

86 lines
2.9 KiB
Python

"""Regression tests for Codex reasoning-effort request serialization."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from hindsight_api.engine.providers.codex_llm import CodexLLM
from tests.codex_stream_stub import stub_codex_stream
def build_llm(reasoning_effort: str | None = "high") -> CodexLLM:
with (
patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")),
patch.object(CodexLLM, "_load_codex_refresh_token", return_value=None),
):
return CodexLLM(
provider="openai-codex",
api_key="ignored",
base_url="https://chatgpt.com/backend-api",
model="gpt-5.6-luna",
reasoning_effort=reasoning_effort,
)
@pytest.mark.asyncio
async def test_call_sends_reasoning_effort_separately_from_summary() -> None:
llm = build_llm("high")
response = MagicMock(status_code=200)
response.raise_for_status.return_value = None
with (
stub_codex_stream(llm, response) as mock_stream,
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
await llm.call(messages=[{"role": "user", "content": "hello"}], max_retries=0)
assert mock_stream.call_args.kwargs["json"]["reasoning"] == {
"effort": "high",
"summary": "detailed",
}
@pytest.mark.asyncio
async def test_call_with_tools_sends_reasoning_effort_separately_from_summary() -> None:
llm = build_llm("low")
response = MagicMock()
response.status_code = 200
response.raise_for_status.return_value = None
with (
stub_codex_stream(llm, response) as mock_stream,
patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock, return_value=(None, [])),
):
await llm.call_with_tools(
messages=[{"role": "user", "content": "hello"}],
tools=[],
max_retries=0,
)
assert mock_stream.call_args.kwargs["json"]["reasoning"] == {
"effort": "low",
"summary": "concise",
}
@pytest.mark.asyncio
async def test_unconfigured_reasoning_effort_is_omitted_from_the_payload() -> None:
"""Unset means the Codex backend picks the effort, not Hindsight.
The config layer used to resolve unset to "low", so every Codex deployment sent an
effort nobody had configured. The summary is presentation and stays — only the
effort is the operator's to set (issue #3449).
"""
llm = build_llm(None)
response = MagicMock(status_code=200)
response.raise_for_status.return_value = None
with (
stub_codex_stream(llm, response) as mock_stream,
patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock, return_value="ok"),
):
await llm.call(messages=[{"role": "user", "content": "hello"}], max_retries=0)
# "auto" is the neutral summary an unrecognised level already mapped to.
assert mock_stream.call_args.kwargs["json"]["reasoning"] == {"summary": "auto"}