Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion reflexio/cli/commands/setup_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
)

_PROVIDERS: dict[str, dict[str, str]] = {
"openai": {"env_var": "OPENAI_API_KEY", "model": "gpt-5.4-mini", "display": "OpenAI"},
"openai": {
"env_var": "OPENAI_API_KEY",
"model": "gpt-5.4-mini",
"display": "OpenAI",
},
"anthropic": {
"env_var": "ANTHROPIC_API_KEY",
"model": "claude-sonnet-4-6",
Expand Down
34 changes: 33 additions & 1 deletion reflexio/server/llm/litellm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,15 @@ class LiteLLMConfig:
)


# Reasoning models that routinely exceed the default 120s provider timeout on
# large extraction contexts. Values are floors, not overrides: the effective
# timeout is max(configured, floor), and an explicit per-call timeout kwarg
# always wins.
_MODEL_TIMEOUT_FLOOR_SECONDS: dict[str, int] = {
"minimax/MiniMax-M3": 240,
}


@dataclass
class ToolCallingChatResponse:
"""Response from a chat call that was routed in tool-calling mode.
Expand Down Expand Up @@ -955,7 +964,9 @@ def _build_completion_params(
params: dict[str, Any] = {
"model": actual_model,
"messages": messages,
"timeout": kwargs.pop("timeout", self.config.timeout),
"timeout": kwargs.pop(
"timeout", self._effective_timeout_for_model(actual_model)
),
}

# Drop any fallback entry that points back at the primary — sending the
Expand Down Expand Up @@ -1154,6 +1165,18 @@ def _completion_with_hard_timeout(self, params: dict[str, Any]) -> Any:
result_queue.close()
result_queue.join_thread()

def _effective_timeout_for_model(self, model: str) -> int:
"""Return the configured timeout, raised to the model's floor if one exists.

Args:
model: Resolved model name (e.g. 'minimax/MiniMax-M3').

Returns:
int: max(config.timeout, per-model floor). Callers that pass an
explicit timeout kwarg bypass this entirely.
"""
return max(self.config.timeout, _MODEL_TIMEOUT_FLOOR_SECONDS.get(model, 0))

def _hard_timeout_grace_seconds(self) -> float:
raw = os.environ.get("REFLEXIO_LLM_HARD_TIMEOUT_GRACE_SECONDS", "5") or "5"
try:
Expand Down Expand Up @@ -1358,6 +1381,15 @@ def _call_and_parse() -> str | BaseModel | ToolCallingChatResponse:
params.get("model"),
)
return _call_and_parse()
except LLMHardTimeoutError:
# The hard timeout kills the litellm subprocess, so litellm's
# num_retries never gets a chance — we owe one explicit retry
# at this level to cover transient provider hangs.
self.logger.warning(
"event=llm_hard_timeout_retry model=%s — request hit hard timeout, retrying once",
params.get("model"),
)
return _call_and_parse()
except Exception as e:
self.logger.error(
"event=llm_request_end model=%s elapsed_seconds=%.3f success=False error_type=%s error=%s",
Expand Down
1 change: 1 addition & 0 deletions reflexio/server/llm/llm_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def positive_int_env(name: str, default: int, logger: logging.Logger) -> int:
return default
return value if value > 0 else default


_STRICT_SCHEMA_UNSUPPORTED_KEYWORDS = frozenset(
{
"exclusiveMaximum",
Expand Down
17 changes: 17 additions & 0 deletions reflexio/server/llm/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,9 @@ def run_tool_loop(
)

# ---- Native tool loop ---------------------------------------------
# Local import keeps litellm_client a type-only dependency of this module.
from reflexio.server.llm.litellm_client import LiteLLMClientError

local_msgs = list(messages)
try:
for _step in range(max_steps):
Expand Down Expand Up @@ -677,6 +680,20 @@ def run_tool_loop(
pending_tool_call_ids=pending_tool_call_ids,
max_steps_remaining=max_steps - _step - 1,
)
except LiteLLMClientError as e:
# LLM failure after the client exhausted its retries and fallbacks —
# a known failure mode (timeouts, provider errors), not a bug. Log at
# warning so it doesn't surface as a Sentry error.
logger.warning("event=tool_loop_llm_error error=%s", e)
trace.finished = False
return ToolLoopResult(
ctx=ctx,
trace=trace,
finished_reason="error",
messages=local_msgs,
pending_tool_call_ids=pending_tool_call_ids,
max_steps_remaining=0,
)
except Exception:
logger.exception("Tool loop raised an unexpected exception")
trace.finished = False
Expand Down
2 changes: 1 addition & 1 deletion reflexio/server/services/extractor_interaction_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def get_extractor_window_params[TExtractorConfig](
Get effective window_size and stride_size for a specific extractor.

Uses extractor's override values if set, otherwise falls back to global values,
then to defaults (window_size=10, stride_size=5).
then to defaults (DEFAULT_WINDOW_SIZE / DEFAULT_STRIDE_SIZE from config_schema).

Args:
extractor_config: Extractor configuration object
Expand Down
6 changes: 3 additions & 3 deletions tests/models/test_retrieval_floor_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ def test_retrieval_floor_defaults():
cfg = RetrievalFloorConfig()
assert cfg.enabled is True
assert cfg.pool_size == 30
assert cfg.profile_floor == -5.0
assert cfg.user_playbook_floor == -5.0
assert cfg.agent_playbook_floor == -5.0
assert cfg.profile_floor == -3.0
assert cfg.user_playbook_floor == -3.0
assert cfg.agent_playbook_floor == -3.0


def test_config_has_retrieval_floor_default():
Expand Down
79 changes: 78 additions & 1 deletion tests/server/llm/test_litellm_client_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
LiteLLMClient,
LiteLLMClientError,
LiteLLMConfig,
LLMHardTimeoutError,
StructuredOutputParseError,
_get_embedding_encoding,
_get_embedding_limit,
Expand Down Expand Up @@ -1513,6 +1514,45 @@ def test_top_p_default_not_included(self, mock_completion):
call_kwargs = mock_completion.call_args.kwargs
assert "top_p" not in call_kwargs

@patch("reflexio.server.llm.litellm_client.litellm.completion")
def test_model_timeout_floor_raises_default(self, mock_completion):
"""MiniMax-M3 has a 240s floor; the default 120s config is raised to it."""
mock_completion.return_value = _make_completion_response("ok")
client = LiteLLMClient(LiteLLMConfig(model="minimax/MiniMax-M3"))

client.generate_response("hi")

assert mock_completion.call_args.kwargs["timeout"] == 240

@patch("reflexio.server.llm.litellm_client.litellm.completion")
def test_model_timeout_floor_does_not_lower_higher_config(self, mock_completion):
"""A configured timeout above the floor is preserved."""
mock_completion.return_value = _make_completion_response("ok")
client = LiteLLMClient(LiteLLMConfig(model="minimax/MiniMax-M3", timeout=600))

client.generate_response("hi")

assert mock_completion.call_args.kwargs["timeout"] == 600

@patch("reflexio.server.llm.litellm_client.litellm.completion")
def test_explicit_timeout_kwarg_beats_model_floor(self, mock_completion):
"""A per-call timeout kwarg bypasses the floor entirely."""
mock_completion.return_value = _make_completion_response("ok")
client = LiteLLMClient(LiteLLMConfig(model="minimax/MiniMax-M3"))

client.generate_chat_response([{"role": "user", "content": "hi"}], timeout=90)

assert mock_completion.call_args.kwargs["timeout"] == 90

@patch("reflexio.server.llm.litellm_client.litellm.completion")
def test_model_without_floor_keeps_config_timeout(self, mock_completion):
mock_completion.return_value = _make_completion_response("ok")
client = LiteLLMClient(LiteLLMConfig(model="gpt-4o"))

client.generate_response("hi")

assert mock_completion.call_args.kwargs["timeout"] == 120

@patch("reflexio.server.llm.litellm_client.litellm.completion")
def test_custom_endpoint_overrides_model(self, mock_completion):
mock_completion.return_value = _make_completion_response("ok")
Expand Down Expand Up @@ -2175,7 +2215,44 @@ def _slow(**_params):
start = time.perf_counter()
with pytest.raises(LiteLLMClientError, match="hard timeout"):
client.generate_chat_response(self._messages())
assert time.perf_counter() - start < 0.5
# Two subprocess spawn/kill cycles (initial attempt + one hard-timeout
# retry) — still far below the 1s the blocked call would take.
assert time.perf_counter() - start < 1.0

def test_hard_timeout_retried_once_then_succeeds(self, monkeypatch):
"""A transient hard timeout is retried exactly once at the client level
(litellm's num_retries dies with the killed subprocess, so it can never
cover this case)."""
client = LiteLLMClient(LiteLLMConfig(model="x"))
attempts: list[int] = []

def _flaky(params):
attempts.append(1)
if len(attempts) == 1:
raise LLMHardTimeoutError("LLM request exceeded hard timeout")
return _make_completion_response("recovered")

monkeypatch.setattr(client, "_completion_with_hard_timeout", _flaky)

result = client.generate_chat_response(self._messages())

assert result == "recovered"
assert len(attempts) == 2

def test_hard_timeout_not_retried_more_than_once(self, monkeypatch):
"""A second consecutive hard timeout propagates as LiteLLMClientError."""
client = LiteLLMClient(LiteLLMConfig(model="x"))
attempts: list[int] = []

def _always_timeout(params):
attempts.append(1)
raise LLMHardTimeoutError("LLM request exceeded hard timeout")

monkeypatch.setattr(client, "_completion_with_hard_timeout", _always_timeout)

with pytest.raises(LiteLLMClientError, match="hard timeout"):
client.generate_chat_response(self._messages())
assert len(attempts) == 2

def test_invalid_hard_timeout_grace_env_falls_back(self, monkeypatch, caplog):
monkeypatch.setenv("REFLEXIO_LLM_HARD_TIMEOUT_GRACE_SECONDS", "not-a-float")
Expand Down
5 changes: 4 additions & 1 deletion tests/server/llm/test_model_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,10 @@ def test_embedding_default_does_not_probe_chromadb(

monkeypatch.setenv("ANTHROPIC_API_KEY", "ant-test")
monkeypatch.setattr(lep.importlib.util, "find_spec", lambda _name: None)
assert resolve_model_name(ModelRole.EMBEDDING) == _PROVIDER_DEFAULTS["local"].embedding
assert (
resolve_model_name(ModelRole.EMBEDDING)
== _PROVIDER_DEFAULTS["local"].embedding
)


# ---------------------------------------------------------------------------
Expand Down
46 changes: 46 additions & 0 deletions tests/server/llm/test_tools.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import json
import logging
from unittest.mock import MagicMock, patch

import pytest
from pydantic import BaseModel

from reflexio.server.llm.litellm_client import (
LiteLLMClient,
LiteLLMClientError,
LiteLLMConfig,
ToolCallingChatResponse,
)
Expand Down Expand Up @@ -374,6 +376,50 @@ def boom(**_kwargs):
assert result.trace.turns == []


def test_run_tool_loop_logs_llm_client_error_as_warning(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""LiteLLMClientError (timeouts, provider errors after retries) is a known
failure mode: finished_reason='error' but logged at WARNING, not ERROR."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
monkeypatch.delenv("CLAUDE_SMART_USE_LOCAL_CLI", raising=False)

ctx = LoopCtx()

def _emit_handler(args: BaseModel, c: LoopCtx) -> dict:
c.emitted.append(args.value) # type: ignore[attr-defined]
return {"ok": True}

reg = ToolRegistry([Tool(name="emit", args_model=EmitArgs, handler=_emit_handler)])

client = LiteLLMClient(LiteLLMConfig(model="claude-sonnet-4-6"))

def boom(**_kwargs):
raise LiteLLMClientError("API call failed: hard timeout")

monkeypatch.setattr(client, "generate_chat_response", boom)

with caplog.at_level(logging.WARNING, logger="reflexio.server.llm.tools"):
result = run_tool_loop(
client=client,
messages=[{"role": "user", "content": "go"}],
registry=reg,
model_role=ModelRole.EXTRACTION_AGENT,
max_steps=5,
ctx=ctx,
finish_tool_name="finish",
)

assert result.finished_reason == "error"
assert result.trace.finished is False
tool_loop_records = [
r for r in caplog.records if r.name == "reflexio.server.llm.tools"
]
assert any("tool_loop_llm_error" in r.getMessage() for r in tool_loop_records)
assert all(r.levelno < logging.ERROR for r in tool_loop_records)


# ---------------- log_label (llm_io.log) integration ---------------- #


Expand Down
16 changes: 4 additions & 12 deletions tests/server/services/playbook/test_playbook_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -656,9 +656,7 @@ def test_rerun_mode_archives_all(self, mock_gen, mock_clust):
mock_gen.return_value = [(_agent_playbook(fid=100), raws)]
agg.storage.save_agent_playbooks.return_value = [_agent_playbook(fid=100)]

req = PlaybookAggregatorRequest(
agent_version="v1", rerun=True
)
req = PlaybookAggregatorRequest(agent_version="v1", rerun=True)
agg.run(req)

agg.storage.archive_agent_playbooks_by_playbook_name.assert_has_calls(
Expand All @@ -679,9 +677,7 @@ def test_rerun_deletes_archived_playbooks_after_success(self, mock_gen, mock_clu
mock_gen.return_value = [(_agent_playbook(fid=100), raws)]
agg.storage.save_agent_playbooks.return_value = [_agent_playbook(fid=100)]

req = PlaybookAggregatorRequest(
agent_version="v1", rerun=True
)
req = PlaybookAggregatorRequest(agent_version="v1", rerun=True)
agg.run(req)

agg.storage.delete_archived_agent_playbooks_by_playbook_name.assert_has_calls(
Expand Down Expand Up @@ -766,9 +762,7 @@ def test_save_exception_restores_full_archive(self, mock_gen, mock_clust):
mock_clust.return_value = {0: [_raw(rid=1)]}
mock_gen.side_effect = RuntimeError("LLM failed")

req = PlaybookAggregatorRequest(
agent_version="v1", rerun=True
)
req = PlaybookAggregatorRequest(agent_version="v1", rerun=True)

with pytest.raises(RuntimeError, match="LLM failed"):
agg.run(req)
Expand Down Expand Up @@ -814,9 +808,7 @@ def test_change_log_exception_is_caught(self, mock_gen, mock_clust):
"DB down"
)

req = PlaybookAggregatorRequest(
agent_version="v1", rerun=True
)
req = PlaybookAggregatorRequest(agent_version="v1", rerun=True)

# Should NOT raise
agg.run(req)
Expand Down
2 changes: 1 addition & 1 deletion tests/server/services/test_base_generation_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ def test_filters_config_when_stride_size_not_met(self, llm_client, request_conte

def test_passes_config_when_stride_size_met(self, llm_client, request_context):
"""Verify config passes through when new interaction count >= stride_size."""
service = self._setup_stride_size_service(llm_client, request_context, 6)
service = self._setup_stride_size_service(llm_client, request_context, 8)
service.service_config = MockServiceConfig(auto_run=True, source="api")

config = MockExtractorConfig(extractor_name="ext1")
Expand Down
4 changes: 2 additions & 2 deletions tests/server/services/test_extractor_interaction_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def test_partial_override(self):
assert stride == 20

def test_defaults_when_nothing_set(self):
"""Test defaults (window=10, stride=5) are returned when no values are set anywhere."""
"""Test defaults (window=10, stride=8) are returned when no values are set anywhere."""
config = MockExtractorConfig(extractor_name="test")

window, stride = get_extractor_window_params(
Expand All @@ -113,7 +113,7 @@ def test_defaults_when_nothing_set(self):
)

assert window == 10
assert stride == 5
assert stride == 8

def test_zero_values_respected(self):
"""Test that zero values ARE respected (0 is a valid override value)."""
Expand Down