mirror of
https://github.com/vectorize-io/hindsight.git
synced 2026-09-14 19:31:49 +08:00
7729396e12
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
86 lines
2.9 KiB
Python
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"}
|