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
13 changes: 13 additions & 0 deletions tests/agents/core/test_llm_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from trpc_agent_sdk.models import LLMModel, LlmRequest, LlmResponse, ModelRegistry
from trpc_agent_sdk.sessions import InMemorySessionService
from trpc_agent_sdk.types import Content, Part
from trpc_agent_sdk.types import FinishReason as GenaiFinishReason


class _StubAgent(BaseAgent):
Expand Down Expand Up @@ -113,6 +114,18 @@ def test_preserves_error_fields(self, model, invocation_context):
assert event.error_code == "validation_error"
assert event.error_message == "Bad request"

def test_preserves_finish_reason(self, model, invocation_context):
proc = LlmProcessor(model)
response = LlmResponse(
content=None,
partial=False,
finish_reason=GenaiFinishReason.STOP,
finish_reason_raw="end_turn",
)
event = proc._create_event_from_response(invocation_context, "evt-3", response)
assert event.finish_reason == GenaiFinishReason.STOP
assert event.finish_reason_raw == "end_turn"


# ---------------------------------------------------------------------------
# _create_error_event
Expand Down
4 changes: 4 additions & 0 deletions tests/models/test_anthropic_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from trpc_agent_sdk.models import AnthropicModel
from trpc_agent_sdk.models import LlmRequest
from trpc_agent_sdk.types import Content
from trpc_agent_sdk.types import FinishReason as GenaiFinishReason
from trpc_agent_sdk.types import FunctionDeclaration
from trpc_agent_sdk.types import GenerateContentConfig
from trpc_agent_sdk.types import Part
Expand Down Expand Up @@ -437,6 +438,7 @@ async def mock_aiter(self):
cache_creation_input_tokens=None,
)
mock_final_message.content = [anthropic_types.TextBlock(text="Hello world!", type="text")]
mock_final_message.stop_reason = "end_turn"
mock_stream.get_final_message = AsyncMock(return_value=mock_final_message)

mock_client = AsyncMock()
Expand All @@ -463,6 +465,8 @@ async def mock_aiter(self):
assert final_response.usage_metadata is not None
assert final_response.usage_metadata.prompt_token_count == 5
assert final_response.usage_metadata.candidates_token_count == 3
assert final_response.finish_reason_raw == "end_turn"
assert final_response.finish_reason == GenaiFinishReason.STOP


class TestAnthropicInjectCacheControl:
Expand Down
100 changes: 93 additions & 7 deletions tests/models/test_anthropic_model_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from trpc_agent_sdk.models._anthropic_model import _FinishReason
from trpc_agent_sdk.types import (
Content,
FinishReason as GenaiFinishReason,
FunctionDeclaration,
GenerateContentConfig,
Part,
Expand All @@ -25,11 +26,11 @@
Type,
)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _model(**kwargs):
defaults = dict(model_name="claude-3-5-sonnet-20241022", api_key="test-key")
defaults.update(kwargs)
Expand All @@ -42,6 +43,7 @@ def _model(**kwargs):


class TestToClaudeRole:

def test_model_role_maps_to_assistant(self):
"""'model' role is converted to 'assistant'."""
model = _model()
Expand Down Expand Up @@ -74,6 +76,7 @@ def test_unknown_role_defaults_to_user(self):


class TestIsImagePart:

def test_image_part_detected(self):
"""Image inline data is recognised as image."""
model = _model()
Expand All @@ -99,6 +102,7 @@ def test_text_part_not_image(self):


class TestPartToMessageBlock:

def test_text_part(self):
"""Text part converts to TextBlockParam."""
model = _model()
Expand All @@ -122,7 +126,13 @@ def test_function_response_with_content_array(self):
model = _model()
part = Part.from_function_response(
name="fn",
response={"content": [{"type": "text", "text": "ok"}, {"type": "other", "data": 1}]},
response={"content": [{
"type": "text",
"text": "ok"
}, {
"type": "other",
"data": 1
}]},
)
part.function_response.id = "id1"
block = model._part_to_message_block(part)
Expand Down Expand Up @@ -174,6 +184,7 @@ def test_unsupported_part_raises(self):


class TestParseFinishReason:

def test_end_turn_maps_to_stop(self):
"""'end_turn' maps to STOP."""
model = _model()
Expand Down Expand Up @@ -205,12 +216,59 @@ def test_none_maps_to_error(self):
assert model._parse_finish_reason(None) == _FinishReason.ERROR


class TestLlmResponseFinishReason:
"""finish_reason / finish_reason_raw on Anthropic responses."""

@staticmethod
def _message(stop_reason):
return anthropic_types.Message(
id="msg_1",
type="message",
role="assistant",
content=[anthropic_types.TextBlock(text="hi", type="text")],
model="claude-3-5-sonnet-20241022",
stop_reason=stop_reason,
stop_sequence=None,
usage=anthropic_types.Usage(input_tokens=1, output_tokens=2),
)

def test_max_tokens_is_carried(self):
model = _model()
result = model._message_to_llm_response(self._message("max_tokens"))
assert result.finish_reason_raw == "max_tokens"
assert result.finish_reason == GenaiFinishReason.MAX_TOKENS

def test_end_turn_raw_is_preserved(self):
model = _model()
result = model._message_to_llm_response(self._message("end_turn"))
assert result.finish_reason_raw == "end_turn"
assert result.finish_reason == GenaiFinishReason.STOP

def test_tool_use_raw_is_preserved(self):
model = _model()
result = model._message_to_llm_response(self._message("tool_use"))
assert result.finish_reason_raw == "tool_use"
assert result.finish_reason == GenaiFinishReason.STOP

def test_unknown_stop_reason_maps_to_unspecified(self):
model = _model()
message = MagicMock()
message.content = [anthropic_types.TextBlock(text="hi", type="text")]
message.usage = anthropic_types.Usage(input_tokens=1, output_tokens=2)
message.stop_reason = "something_else"
message.model_dump_json = MagicMock(return_value="{}")
result = model._message_to_llm_response(message)
assert result.finish_reason_raw == "something_else"
assert result.finish_reason == GenaiFinishReason.FINISH_REASON_UNSPECIFIED


# ---------------------------------------------------------------------------
# _update_type_string
# ---------------------------------------------------------------------------


class TestUpdateTypeString:

def test_lowercases_type_field(self):
"""Type field is lowered."""
model = _model()
Expand All @@ -233,7 +291,9 @@ def test_recursive_properties_in_items(self):
"items": {
"type": "OBJECT",
"properties": {
"name": {"type": "STRING"},
"name": {
"type": "STRING"
},
},
},
}
Expand All @@ -247,6 +307,7 @@ def test_recursive_properties_in_items(self):


class TestFunctionDeclarationToToolParam:

def test_with_parameters_json_schema(self):
"""Uses parameters_json_schema when available."""
model = _model()
Expand Down Expand Up @@ -290,6 +351,7 @@ def test_no_parameters(self):


class TestContentBlockToPart:

def test_text_block(self):
"""TextBlock converts to text Part."""
model = _model()
Expand Down Expand Up @@ -336,6 +398,7 @@ def test_unsupported_block_raises(self):


class TestFormatMessagesEdgeCases:

def test_image_in_assistant_turn_skipped(self):
"""Image data in assistant turn is skipped with warning."""
model = _model()
Expand All @@ -359,6 +422,7 @@ def test_empty_message_blocks_filtered(self):


class TestCreateStreamingToolCallResponse:

def test_returns_none_when_no_tool_uses(self):
"""Returns None with empty accumulated_tool_uses."""
model = _model()
Expand All @@ -368,7 +432,11 @@ def test_returns_none_when_tool_name_empty(self):
"""Returns None when current tool has no name."""
model = _model()
result = model._create_streaming_tool_call_response(
[{"id": "id1", "name": "", "accumulated_input": ""}],
[{
"id": "id1",
"name": "",
"accumulated_input": ""
}],
'{"x":1}',
)
assert result is None
Expand All @@ -377,7 +445,11 @@ def test_returns_none_when_tool_not_in_streaming_set(self):
"""Returns None when tool name is not in streaming_tool_names."""
model = _model()
result = model._create_streaming_tool_call_response(
[{"id": "id1", "name": "my_tool", "accumulated_input": "{}"}],
[{
"id": "id1",
"name": "my_tool",
"accumulated_input": "{}"
}],
'{"x":1}',
streaming_tool_names={"other_tool"},
)
Expand All @@ -387,7 +459,11 @@ def test_returns_response_when_tool_in_streaming_set(self):
"""Returns LlmResponse when tool name is in streaming_tool_names."""
model = _model()
result = model._create_streaming_tool_call_response(
[{"id": "id1", "name": "my_tool", "accumulated_input": '{"x":'}],
[{
"id": "id1",
"name": "my_tool",
"accumulated_input": '{"x":'
}],
'1}',
streaming_tool_names={"my_tool"},
)
Expand All @@ -399,7 +475,11 @@ def test_returns_response_when_streaming_names_none(self):
"""Returns response for any tool when streaming_tool_names is None."""
model = _model()
result = model._create_streaming_tool_call_response(
[{"id": "id1", "name": "any_tool", "accumulated_input": ""}],
[{
"id": "id1",
"name": "any_tool",
"accumulated_input": ""
}],
'{}',
streaming_tool_names=None,
)
Expand All @@ -412,6 +492,7 @@ def test_returns_response_when_streaming_names_none(self):


class TestMergeConfigsExtended:

def test_no_request_no_default_returns_empty(self):
"""No request config and no default yields empty GenerateContentConfig."""
model = _model()
Expand All @@ -435,6 +516,7 @@ def test_default_fields_applied_to_request(self):


class TestLogUnsupportedConfigOptions:

def test_logs_warning_for_unsupported_options(self):
"""Logs warnings for every unsupported config option."""
model = _model()
Expand Down Expand Up @@ -466,6 +548,7 @@ def test_no_warning_for_supported_options(self):


class TestGenerateSingleError:

@pytest.mark.asyncio
async def test_api_error_raises_and_closes_client(self):
"""API error during single generation is raised to the retry layer."""
Expand All @@ -485,6 +568,7 @@ async def test_api_error_raises_and_closes_client(self):


class TestCreateAsyncClient:

def test_client_created_with_api_key(self):
"""Client is created with the model's api_key."""
model = _model(api_key="sk-test", base_url="https://example.com")
Expand All @@ -511,12 +595,14 @@ def test_client_without_base_url(self):


class TestValidateRequestExtended:

def test_part_with_inline_data_is_valid(self):
"""Part with inline_data passes validation."""
model = _model()
part = Part(inline_data=Blob(mime_type="image/png", data=b"img"))
request = LlmRequest(contents=[Content(parts=[part], role="user")])
model.validate_request(request)


if __name__ == "__main__":
pytest.main([__file__, "-v"])
14 changes: 12 additions & 2 deletions tests/models/test_litellm_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from trpc_agent_sdk.models._litellm_model import _build_response_format_for_litellm
from trpc_agent_sdk.models._litellm_model import _is_litellm_gemini_model
from trpc_agent_sdk.types import Content
from trpc_agent_sdk.types import FinishReason as GenaiFinishReason
from trpc_agent_sdk.types import GenerateContentConfig
from trpc_agent_sdk.types import Part

Expand Down Expand Up @@ -136,6 +137,8 @@ def test_normal_text_and_usage_no_error_code_when_stop(self):
assert r.content.parts[0].text == "Hi there"
assert getattr(r.content.parts[0], "thought", None) is False
assert r.usage_metadata.prompt_token_count == 10
assert r.finish_reason == GenaiFinishReason.STOP
assert r.finish_reason_raw == "stop"

def test_finish_reason_not_stop_sets_error_code(self):
model = LiteLLMModel(model_name="openai/gpt-4")
Expand All @@ -157,6 +160,8 @@ def test_finish_reason_not_stop_sets_error_code(self):
assert r.error_code == "length"
assert r.content is not None
assert r.content.parts[0].text == "x"
assert r.finish_reason == GenaiFinishReason.MAX_TOKENS
assert r.finish_reason_raw == "length"

def test_tool_calls_from_message(self):
model = LiteLLMModel(model_name="openai/gpt-4")
Expand Down Expand Up @@ -490,7 +495,9 @@ async def test_generate_async_stream_yields_partial_then_final(self):
}
chunk3 = Mock()
chunk3.model_dump.return_value = {
CHOICES: [],
CHOICES: [{
FINISH_REASON: "length"
}],
USAGE: {
"prompt_tokens": 1,
"completion_tokens": 2,
Expand Down Expand Up @@ -526,6 +533,8 @@ def fake_import(name, *a, **k):
assert "Hello " in finals[0].content.parts[0].text or "world" in finals[0].content.parts[0].text
assert finals[0].usage_metadata is not None
assert finals[0].usage_metadata.total_token_count == 3
assert finals[0].finish_reason == GenaiFinishReason.MAX_TOKENS
assert finals[0].finish_reason_raw == "length"

@pytest.mark.asyncio
async def test_generate_async_stream_includes_stream_options_in_api_params(self):
Expand Down Expand Up @@ -947,7 +956,8 @@ def _model(self):
def test_x_should_retry_header_has_priority(self):
model = self._model()
assert model._get_model_retry_info(_LiteLLMRetryTestError(400, {"x-should-retry": "true"})).should_retry is True
assert model._get_model_retry_info(_LiteLLMRetryTestError(500, {"x-should-retry": "false"})).should_retry is False
assert model._get_model_retry_info(_LiteLLMRetryTestError(500,
{"x-should-retry": "false"})).should_retry is False

@pytest.mark.parametrize("status_code", [408, 409, 429, 500, 503])
def test_retryable_status_codes(self, status_code):
Expand Down
Loading
Loading