Skip to content

Commit 3be440f

Browse files
committed
fix(llm-agent): surface React provider errors
1 parent 469090f commit 3be440f

3 files changed

Lines changed: 183 additions & 13 deletions

File tree

‎llm-agent/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"
55

66
[project]
77
name = "rusticai-llm-agent"
8-
version = "1.4.0"
8+
version = "1.4.1"
99
description = "A complete set of LLM agents"
1010
authors = [{name = "Dragonscale Industries Inc.", email = "dev@dragonscale.ai"}]
1111
license = "Apache-2.0"

‎llm-agent/src/rustic_ai/llm_agent/react/react_agent.py‎

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import List, Literal, Optional, Union
66
import uuid
77

8+
import openai
89
from pydantic import (
910
BaseModel,
1011
ConfigDict,
@@ -346,6 +347,30 @@ def handle_chat_completion_request(self, ctx: ProcessContext[ChatCompletionReque
346347
# Send the final ChatCompletionResponse
347348
ctx.send(result)
348349

350+
except openai.APIConnectionError as e:
351+
logger.error("Provider connection error in ReAct loop: %s", e, exc_info=True)
352+
error = ChatCompletionError(
353+
status_code=(
354+
ResponseCodes.API_TIMEOUT_ERROR
355+
if isinstance(e, openai.APITimeoutError)
356+
else ResponseCodes.API_CONNECTION_ERROR
357+
),
358+
message=str(e),
359+
model=str(self.config.model) if self.config.model else self.name,
360+
request_messages=list(request.messages),
361+
)
362+
ctx.send_error(error)
363+
except openai.APIStatusError as e:
364+
logger.error("Provider error in ReAct loop: %s", e, exc_info=True)
365+
error = ChatCompletionError(
366+
status_code=ResponseCodes(e.status_code),
367+
message=e.message,
368+
response=e.response.text if e.response else None,
369+
model=str(self.config.model) if self.config.model else self.name,
370+
request_messages=list(request.messages),
371+
body=e.body if hasattr(e, "body") else None,
372+
)
373+
ctx.send_error(error)
349374
except Exception as e:
350375
logger.error(f"Error in ReAct loop: {e}", exc_info=True)
351376
ctx.send_error(
@@ -461,8 +486,6 @@ def _run_react_iterations(
461486
iteration_request = self._build_react_iteration_request(state)
462487
iteration_request = self._preprocess_iteration_if_needed(ctx, llm, iteration_request, iteration)
463488
response = self._call_llm_direct(llm, iteration_request)
464-
if isinstance(response, str):
465-
return response
466489
if response.usage:
467490
state.total_usage = CompletionUsage(
468491
prompt_tokens=state.total_usage.prompt_tokens + response.usage.prompt_tokens,
@@ -1163,7 +1186,7 @@ def _call_llm(
11631186
llm: LLM,
11641187
messages: List[DiscriminatedLLMMessage],
11651188
tools: Optional[list] = None,
1166-
) -> Union[ChatCompletionResponse, str]:
1189+
) -> ChatCompletionResponse:
11671190
"""
11681191
Call the LLM with the given messages and tools.
11691192
@@ -1173,7 +1196,7 @@ def _call_llm(
11731196
tools: Optional tools list (uses toolset if not provided).
11741197
11751198
Returns:
1176-
ChatCompletionResponse on success, error string on failure.
1199+
ChatCompletionResponse on success.
11771200
"""
11781201
if tools is None:
11791202
tools = self.config.toolset.chat_tools if self.config.toolset.tool_count > 0 else None
@@ -1191,7 +1214,7 @@ def _call_llm_direct(
11911214
self,
11921215
llm: LLM,
11931216
request: ChatCompletionRequest,
1194-
) -> Union[ChatCompletionResponse, str]:
1217+
) -> ChatCompletionResponse:
11951218
"""
11961219
Call the LLM with a pre-built request.
11971220
@@ -1200,14 +1223,9 @@ def _call_llm_direct(
12001223
request: The chat completion request.
12011224
12021225
Returns:
1203-
ChatCompletionResponse on success, error string on failure.
1226+
ChatCompletionResponse on success.
12041227
"""
1205-
try:
1206-
response = llm.completion(request, self.config.model)
1207-
return response
1208-
except Exception as e:
1209-
logger.error(f"LLM call failed: {e}", exc_info=True)
1210-
return f"LLM call failed: {e}"
1228+
return llm.completion(request, self.config.model)
12111229

12121230
@staticmethod
12131231
def _structured_tool_error(code: str, message: str) -> str:

‎llm-agent/tests/react/test_react_agent.py‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,23 @@
22
from typing import Any, ClassVar, List, Optional
33
from unittest.mock import patch
44

5+
import httpx
6+
import openai
57
from pydantic import BaseModel, ConfigDict, ValidationError
68
import pytest
79

810
from rustic_ai.core.guild.agent_ext.depends.dependency_resolver import DependencySpec
911
from rustic_ai.core.guild.agent_ext.depends.llm.models import (
1012
AssistantMessage,
13+
ChatCompletionError,
1114
ChatCompletionMessageToolCall,
1215
ChatCompletionRequest,
1316
ChatCompletionResponse,
1417
Choice,
1518
CompletionUsage,
1619
FinishReason,
1720
FunctionCall,
21+
ResponseCodes,
1822
SystemMessage,
1923
ToolType,
2024
UserMessage,
@@ -570,6 +574,154 @@ def test_agent_simple_response(self, generator, build_message_from_payload):
570574
assert "react_trace" in provider_fields
571575
assert len(provider_fields["react_trace"]) == 0 # No tool calls
572576

577+
def test_provider_error_is_emitted_as_typed_error(self, generator, build_message_from_payload):
578+
agent_spec: AgentSpec = (
579+
AgentBuilder(ReActAgent)
580+
.set_id("react_agent")
581+
.set_name("ReAct Agent")
582+
.set_description("A ReAct agent for testing")
583+
.set_properties(
584+
ReActAgentConfig(
585+
model="test-model",
586+
toolset=CalculatorToolset(),
587+
)
588+
)
589+
.build_spec()
590+
)
591+
agent, results = wrap_agent_for_testing(
592+
agent_spec,
593+
dependency_map={
594+
"llm": DependencySpec(
595+
class_name="rustic_ai.litellm.agent_ext.llm.LiteLLMResolver",
596+
properties={"model": "test-model"},
597+
)
598+
},
599+
)
600+
provider_error = openai.RateLimitError(
601+
"You have no credits remaining. PRIVATE_PROVIDER_DETAIL",
602+
response=httpx.Response(
603+
429,
604+
request=httpx.Request("POST", "https://provider.invalid/chat"),
605+
json={"error": {"code": "insufficient_quota"}},
606+
),
607+
body={"error": {"code": "insufficient_quota"}},
608+
)
609+
610+
with patch.object(agent, "_call_llm_direct", side_effect=provider_error):
611+
agent._on_message(
612+
build_message_from_payload(
613+
generator,
614+
ChatCompletionRequest(messages=[UserMessage(content="PRIVATE_USER_PROMPT")]),
615+
)
616+
)
617+
618+
assert len(results) == 1
619+
assert results[0].format.endswith(".ChatCompletionError")
620+
error = ChatCompletionError.model_validate(results[0].payload)
621+
assert error.status_code == ResponseCodes.RATE_LIMIT_ERROR
622+
assert error.body == {"error": {"code": "insufficient_quota"}}
623+
assert error.request_messages[0].content == "PRIVATE_USER_PROMPT"
624+
625+
@pytest.mark.parametrize(
626+
("provider_error", "expected_status"),
627+
[
628+
(
629+
openai.APIConnectionError(
630+
message="PRIVATE_CONNECTION_DETAIL",
631+
request=httpx.Request("POST", "https://provider.invalid/chat"),
632+
),
633+
ResponseCodes.API_CONNECTION_ERROR,
634+
),
635+
(
636+
openai.APITimeoutError(
637+
request=httpx.Request("POST", "https://provider.invalid/chat")
638+
),
639+
ResponseCodes.API_TIMEOUT_ERROR,
640+
),
641+
],
642+
)
643+
def test_provider_transport_error_is_emitted_as_typed_error(
644+
self,
645+
generator,
646+
build_message_from_payload,
647+
provider_error,
648+
expected_status,
649+
):
650+
agent_spec: AgentSpec = (
651+
AgentBuilder(ReActAgent)
652+
.set_id("react_agent")
653+
.set_name("ReAct Agent")
654+
.set_description("A ReAct agent for testing")
655+
.set_properties(
656+
ReActAgentConfig(
657+
model="test-model",
658+
toolset=CalculatorToolset(),
659+
)
660+
)
661+
.build_spec()
662+
)
663+
agent, results = wrap_agent_for_testing(
664+
agent_spec,
665+
dependency_map={
666+
"llm": DependencySpec(
667+
class_name="rustic_ai.litellm.agent_ext.llm.LiteLLMResolver",
668+
properties={"model": "test-model"},
669+
)
670+
},
671+
)
672+
673+
with patch.object(agent, "_call_llm_direct", side_effect=provider_error):
674+
agent._on_message(
675+
build_message_from_payload(
676+
generator,
677+
ChatCompletionRequest(messages=[UserMessage(content="PRIVATE_USER_PROMPT")]),
678+
)
679+
)
680+
681+
assert len(results) == 1
682+
assert results[0].format.endswith(".ChatCompletionError")
683+
error = ChatCompletionError.model_validate(results[0].payload)
684+
assert error.status_code == expected_status
685+
assert error.request_messages[0].content == "PRIVATE_USER_PROMPT"
686+
687+
def test_unexpected_error_is_emitted_once_as_internal_error(self, generator, build_message_from_payload):
688+
agent_spec: AgentSpec = (
689+
AgentBuilder(ReActAgent)
690+
.set_id("react_agent")
691+
.set_name("ReAct Agent")
692+
.set_description("A ReAct agent for testing")
693+
.set_properties(
694+
ReActAgentConfig(
695+
model="test-model",
696+
toolset=CalculatorToolset(),
697+
)
698+
)
699+
.build_spec()
700+
)
701+
agent, results = wrap_agent_for_testing(
702+
agent_spec,
703+
dependency_map={
704+
"llm": DependencySpec(
705+
class_name="rustic_ai.litellm.agent_ext.llm.LiteLLMResolver",
706+
properties={"model": "test-model"},
707+
)
708+
},
709+
)
710+
711+
with patch.object(agent, "_call_llm_direct", side_effect=RuntimeError("PRIVATE_INTERNAL_DETAIL")):
712+
agent._on_message(
713+
build_message_from_payload(
714+
generator,
715+
ChatCompletionRequest(messages=[UserMessage(content="PRIVATE_USER_PROMPT")]),
716+
)
717+
)
718+
719+
assert len(results) == 1
720+
assert results[0].format.endswith(".ChatCompletionError")
721+
error = ChatCompletionError.model_validate(results[0].payload)
722+
assert error.status_code == ResponseCodes.INTERNAL_SERVER_ERROR
723+
assert error.message == "Error in ReAct loop: PRIVATE_INTERNAL_DETAIL"
724+
573725
def test_agent_with_tool_call(self, generator, build_message_from_payload):
574726
"""Test agent with tool calls."""
575727
agent_spec: AgentSpec = (

0 commit comments

Comments
 (0)