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
189 lines
7.4 KiB
Python
189 lines
7.4 KiB
Python
"""
|
|
Regression tests for Codex structured output (issue #2504).
|
|
|
|
Before the fix, ``CodexLLM.call(strict_schema=True)`` was a dead no-op: structured
|
|
output always went through prompt-injected schema + raw ``json.loads`` on the
|
|
model's free-form text. Escape-heavy content (code, serial/CLI commands, Windows
|
|
paths, regexes) makes weaker models emit invalid ``\\escape`` sequences, so every
|
|
parse attempt fails and retain/consolidation burn all retries and fail.
|
|
|
|
The fix:
|
|
- ``strict_schema=True`` routes structured output through a single forced function
|
|
tool (constrained decoding into the response schema).
|
|
- The non-strict fallback now repairs invalid ``\\escape`` sequences before giving up.
|
|
"""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
|
|
from hindsight_api.engine.providers.codex_llm import (
|
|
CodexLLM,
|
|
_repair_invalid_json_escapes,
|
|
)
|
|
from hindsight_api.engine.response_models import LLMToolCall
|
|
from tests.codex_stream_stub import stub_codex_stream
|
|
|
|
|
|
class _Fact(BaseModel):
|
|
fact: str
|
|
|
|
|
|
def build_llm() -> CodexLLM:
|
|
with patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")):
|
|
return CodexLLM(
|
|
provider="openai-codex",
|
|
api_key="ignored",
|
|
base_url="https://chatgpt.com/backend-api",
|
|
model="gpt-5.4-mini",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _repair_invalid_json_escapes — pure unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_repair_fixes_invalid_escape_in_json():
|
|
# `\d` and `\s` are not valid JSON escapes; raw json.loads fails.
|
|
broken = r'{"fact": "regex \d+\s matches digits"}'
|
|
import json
|
|
|
|
with pytest.raises(json.JSONDecodeError):
|
|
json.loads(broken)
|
|
repaired = _repair_invalid_json_escapes(broken)
|
|
assert json.loads(repaired) == {"fact": r"regex \d+\s matches digits"}
|
|
|
|
|
|
def test_repair_preserves_valid_escapes():
|
|
import json
|
|
|
|
valid = r'{"fact": "line1\nline2\ttab \"quoted\" \\backslash é"}'
|
|
# Already valid — repair must not corrupt it.
|
|
assert json.loads(_repair_invalid_json_escapes(valid)) == json.loads(valid)
|
|
|
|
|
|
def test_repair_handles_windows_paths():
|
|
import json
|
|
|
|
# Uses path segments whose first char isn't a valid JSON escape letter
|
|
# (b/f/n/r/t/u), where the repair is unambiguous.
|
|
broken = r'{"path": "C:\Windows\System32\app.exe"}'
|
|
assert json.loads(_repair_invalid_json_escapes(broken)) == {"path": r"C:\Windows\System32\app.exe"}
|
|
|
|
|
|
def test_repair_handles_trailing_backslash():
|
|
# A lone trailing backslash must be escaped, not dropped.
|
|
assert _repair_invalid_json_escapes("abc\\") == "abc\\\\"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# strict_schema forced-tool path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_strict_schema_uses_forced_function_tool():
|
|
llm = build_llm()
|
|
response = MagicMock(status_code=200)
|
|
response.raise_for_status.return_value = None
|
|
tool_call = LLMToolCall(id="call-1", name="structured_response", arguments={"fact": "the sky is blue"})
|
|
|
|
with stub_codex_stream(llm, response) as mock_stream:
|
|
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
|
mock_parse.return_value = (None, [tool_call])
|
|
result = await llm.call(
|
|
messages=[{"role": "user", "content": "The sky is blue"}],
|
|
response_format=_Fact,
|
|
strict_schema=True,
|
|
max_retries=0,
|
|
)
|
|
sent_payload = mock_stream.call_args.kwargs["json"]
|
|
sent_headers = mock_stream.call_args.kwargs["headers"]
|
|
|
|
# Forced tool wired into the request payload.
|
|
assert sent_payload["tool_choice"] == {"type": "function", "name": "structured_response"}
|
|
assert len(sent_payload["tools"]) == 1
|
|
assert sent_payload["tools"][0]["name"] == "structured_response"
|
|
assert sent_payload["parallel_tool_calls"] is False
|
|
assert sent_headers["originator"] == "codex_cli_rs"
|
|
assert sent_headers["User-Agent"] == "codex_cli_rs/0.0.0 (Hindsight)"
|
|
# No prompt-injected schema in the instructions.
|
|
assert "You must respond with valid JSON" not in sent_payload["instructions"]
|
|
|
|
assert isinstance(result, _Fact)
|
|
assert result.fact == "the sky is blue"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_strict_schema_skip_validation_returns_dict():
|
|
llm = build_llm()
|
|
response = MagicMock(status_code=200)
|
|
response.raise_for_status.return_value = None
|
|
tool_call = LLMToolCall(id="c", name="structured_response", arguments={"fact": "x"})
|
|
span_recorder = MagicMock()
|
|
|
|
with patch("hindsight_api.tracing.get_span_recorder", return_value=span_recorder):
|
|
with stub_codex_stream(llm, response) as mock_stream:
|
|
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
|
mock_parse.return_value = (None, [tool_call])
|
|
result = await llm.call(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
response_format=_Fact,
|
|
strict_schema=True,
|
|
skip_validation=True,
|
|
max_retries=0,
|
|
)
|
|
|
|
assert result == {"fact": "x"}
|
|
assert span_recorder.record_llm_call.call_args.kwargs["response_content"] == '{"fact": "x"}'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_strict_schema_retries_when_forced_tool_missing():
|
|
llm = build_llm()
|
|
response = MagicMock(status_code=200)
|
|
response.raise_for_status.return_value = None
|
|
|
|
with stub_codex_stream(llm, response) as mock_stream:
|
|
# Model returns no tool call at all — should raise after retries exhausted.
|
|
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
|
mock_parse.return_value = ("some prose", [])
|
|
with pytest.raises(RuntimeError, match="structured_response"):
|
|
await llm.call(
|
|
messages=[{"role": "user", "content": "hi"}],
|
|
response_format=_Fact,
|
|
strict_schema=True,
|
|
max_retries=0,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Non-strict fallback: escape repair keeps the retry storm from happening
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_strict_repairs_invalid_escapes_without_retrying():
|
|
llm = build_llm()
|
|
response = MagicMock(status_code=200)
|
|
response.raise_for_status.return_value = None
|
|
# Escape-heavy content the model would emit as invalid JSON.
|
|
escape_heavy = r'{"fact": "run rig-control \d serial \s command"}'
|
|
|
|
with stub_codex_stream(llm, response) as mock_stream:
|
|
with patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock) as mock_parse:
|
|
mock_parse.return_value = escape_heavy
|
|
result = await llm.call(
|
|
messages=[{"role": "user", "content": "coding transcript"}],
|
|
response_format=_Fact,
|
|
strict_schema=False,
|
|
max_retries=3,
|
|
)
|
|
|
|
# Parsed on the first attempt (no retry storm): the SSE stream was read once.
|
|
assert mock_stream.call_count == 1
|
|
assert isinstance(result, _Fact)
|
|
assert result.fact == r"run rig-control \d serial \s command"
|