Files
wehub-resource-sync c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Web UI / check (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

590 lines
18 KiB
Python

# -*- coding: utf-8 -*-
# pylint: disable=protected-access
"""Unit tests for XAIChatModel with mocked API responses.
Tests cover both non-streaming and streaming modes.
XAI uses xai_sdk with chat.stream() for streaming.
"""
import sys
from typing import Any
from types import ModuleType
import unittest
from unittest import IsolatedAsyncioTestCase
from unittest.mock import AsyncMock, MagicMock, patch
from utils import AnyString
from agentscope.message import TextBlock, ToolCallBlock, ThinkingBlock
from agentscope.model import XAIChatModel
from agentscope.credential import XAICredential
from agentscope.tool import ToolChoice
A = AnyString()
# ---------------------------------------------------------------------------
# Build a lightweight xai_sdk stub so tests run without the real package.
# ---------------------------------------------------------------------------
def _build_xai_sdk_stub() -> None:
"""Register stub modules for xai_sdk so imports don't fail."""
if "xai_sdk" in sys.modules:
return
chat_pb2 = ModuleType("xai_sdk.chat.chat_pb2")
class _EnumHelper:
"""Helper that makes .Value() return an integer."""
_mapping = {
"ROLE_ASSISTANT": 2,
"TOOL_CALL_TYPE_CLIENT_SIDE_TOOL": 1,
}
def Value(self, name: str) -> int:
"""Return the integer value for the given enum name."""
return self._mapping.get(name, 0)
chat_pb2.MessageRole = _EnumHelper()
chat_pb2.ToolCallType = _EnumHelper()
class _RepeatedField(list):
"""Minimal repeated proto field that supports .add()."""
def __init__(self, factory: Any) -> None:
super().__init__()
self._factory = factory
def add(self) -> Any:
"""Add a new item using the factory and return it."""
item = self._factory()
self.append(item)
return item
class _FunctionSpec:
name: str = ""
arguments: str = ""
class _ToolCallProto:
id: str = ""
type: int = 0
function = _FunctionSpec()
class _ContentPart:
text: str = ""
class _MessageProto:
def __init__(self) -> None:
self.role = 0
self.content = _RepeatedField(_ContentPart)
self.tool_calls = _RepeatedField(_ToolCallProto)
chat_pb2.Message = _MessageProto
xai_chat = ModuleType("xai_sdk.chat")
xai_chat.chat_pb2 = chat_pb2
xai_chat.user = lambda *args: MagicMock(role="user", args=args)
xai_chat.assistant = lambda *args: MagicMock(role="assistant", args=args)
xai_chat.system = lambda *args: MagicMock(role="system", args=args)
xai_chat.tool_result = lambda *args, **kw: MagicMock(
role="tool",
args=args,
kwargs=kw,
)
xai_chat.image = lambda url: MagicMock(type="image", url=url)
xai_sdk = ModuleType("xai_sdk")
xai_sdk.chat = xai_chat
xai_sdk.AsyncClient = MagicMock()
sys.modules["xai_sdk"] = xai_sdk
sys.modules["xai_sdk.chat"] = xai_chat
sys.modules["xai_sdk.chat.chat_pb2"] = chat_pb2
_build_xai_sdk_stub()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_model(stream: bool = False) -> Any:
return XAIChatModel(
credential=XAICredential(api_key="test"),
model="grok-3",
stream=stream,
context_size=131_072,
)
def _mock_completion(
text: str = "",
reasoning: str = "",
tool_calls: list | None = None,
response_id: str = "xai-resp-1",
) -> MagicMock:
"""Build a mock xAI non-streaming response."""
resp = MagicMock()
resp.id = response_id
resp.content = text
resp.reasoning_content = reasoning
resp.tool_calls = None
resp.usage = None
if tool_calls:
tc_mocks = []
for tc in tool_calls:
m = MagicMock()
m.id = tc["id"]
m.function.name = tc["name"]
m.function.arguments = tc["arguments"]
tc_mocks.append(m)
resp.tool_calls = tc_mocks
return resp
class _MockStreamChunk:
"""A single chunk from xai chat.stream()."""
def __init__(
self,
content: str = "",
reasoning_content: str = "",
) -> None:
self.content = content
self.reasoning_content = reasoning_content
class _MockChatStream:
"""Mock xai_sdk chat session with stream() and sample()."""
def __init__(
self,
stream_items: list | None = None,
sample_response: Any = None,
) -> None:
self._stream_items = stream_items or []
self._sample_response = sample_response
self._appended: list = []
def append(self, msg: Any) -> None:
"""Append a message to the conversation."""
self._appended.append(msg)
async def sample(self) -> Any:
"""Return the pre-configured sample response."""
return self._sample_response
def stream(self) -> "_MockStreamIterator":
"""Return an async iterator over pre-configured stream items."""
return _MockStreamIterator(self._stream_items)
class _MockStreamIterator:
"""Async iterator for (response, chunk) pairs from xai stream."""
def __init__(self, items: list) -> None:
self._items = items
self._index = 0
def __aiter__(self) -> "_MockStreamIterator":
return self
async def __anext__(self) -> tuple:
if self._index >= len(self._items):
raise StopAsyncIteration
item = self._items[self._index]
self._index += 1
return item
# ---------------------------------------------------------------------------
# Non-streaming tests
# ---------------------------------------------------------------------------
class TestXAINonStream(IsolatedAsyncioTestCase):
"""Tests for XAIChatModel in non-streaming mode."""
def setUp(self) -> None:
self.model = _make_model(stream=False)
@patch("xai_sdk.AsyncClient")
async def test_text_response(self, mock_client_cls: MagicMock) -> None:
"""Non-stream text response returns a single ChatResponse."""
mock_chat = _MockChatStream(
sample_response=_mock_completion(text="Hello!"),
)
mock_client_cls.return_value.chat.create.return_value = mock_chat
mock_client_cls.return_value.close = AsyncMock()
result = await self.model([])
self.assertEqual(
(result.is_last, result.content),
(True, [TextBlock.model_construct(id=A, text="Hello!")]),
)
self.assertEqual(result.id, "xai-resp-1")
@patch("xai_sdk.AsyncClient")
async def test_tool_call_response(
self,
mock_client_cls: MagicMock,
) -> None:
"""Parsing a tool-call response creates a ToolCallBlock."""
mock_chat = _MockChatStream(
sample_response=_mock_completion(
tool_calls=[
{
"id": "call-1",
"name": "get_weather",
"arguments": '{"city":"NY"}',
},
],
),
)
mock_client_cls.return_value.chat.create.return_value = mock_chat
mock_client_cls.return_value.close = AsyncMock()
result = await self.model([])
self.assertEqual(
(result.is_last, result.content),
(
True,
[
ToolCallBlock(
id="call-1",
name="get_weather",
input='{"city":"NY"}',
),
],
),
)
@patch("xai_sdk.AsyncClient")
async def test_thinking_response(
self,
mock_client_cls: MagicMock,
) -> None:
"""Non-stream reasoning plus text returns ThinkingBlock then
TextBlock."""
mock_chat = _MockChatStream(
sample_response=_mock_completion(
text="Answer",
reasoning="Deep thinking...",
),
)
mock_client_cls.return_value.chat.create.return_value = mock_chat
mock_client_cls.return_value.close = AsyncMock()
result = await self.model([])
self.assertEqual(
(result.is_last, result.content),
(
True,
[
ThinkingBlock.model_construct(
id=A,
thinking="Deep thinking...",
),
TextBlock.model_construct(id=A, text="Answer"),
],
),
)
# ---------------------------------------------------------------------------
# Streaming tests
# ---------------------------------------------------------------------------
class TestXAIStream(IsolatedAsyncioTestCase):
"""Tests for XAIChatModel in streaming mode."""
def setUp(self) -> None:
self.model = _make_model(stream=True)
@patch("xai_sdk.AsyncClient")
async def test_stream_text(self, mock_client_cls: MagicMock) -> None:
"""Stream text yields deltas then final with full content."""
final_response = _mock_completion(text="Hello world")
final_response.tool_calls = None
final_response.usage = MagicMock()
final_response.usage.prompt_tokens = 10
final_response.usage.completion_tokens = 5
final_response.usage.cached_prompt_text_tokens = 0
stream_items = [
(final_response, _MockStreamChunk(content="Hello")),
(final_response, _MockStreamChunk(content=" world")),
]
mock_chat = _MockChatStream(stream_items=stream_items)
mock_client_cls.return_value.chat.create.return_value = mock_chat
mock_client_cls.return_value.close = AsyncMock()
gen = await self.model([])
responses = [r async for r in gen]
self.assertListEqual(
[(r.is_last, r.content) for r in responses],
[
(False, [TextBlock.model_construct(id=A, text="Hello")]),
(False, [TextBlock.model_construct(id=A, text=" world")]),
(True, [TextBlock.model_construct(id=A, text="Hello world")]),
],
)
@patch("xai_sdk.AsyncClient")
async def test_stream_thinking_and_text(
self,
mock_client_cls: MagicMock,
) -> None:
"""Stream reasoning and text deltas then final with accumulated
content."""
final_response = _mock_completion(text="")
final_response.tool_calls = None
final_response.usage = MagicMock()
final_response.usage.prompt_tokens = 10
final_response.usage.completion_tokens = 5
final_response.usage.cached_prompt_text_tokens = 0
stream_items = [
(final_response, _MockStreamChunk(reasoning_content="Think")),
(final_response, _MockStreamChunk(content="Answer")),
]
mock_chat = _MockChatStream(stream_items=stream_items)
mock_client_cls.return_value.chat.create.return_value = mock_chat
mock_client_cls.return_value.close = AsyncMock()
gen = await self.model([])
responses = [r async for r in gen]
self.assertListEqual(
[(r.is_last, r.content) for r in responses],
[
(
False,
[ThinkingBlock.model_construct(id=A, thinking="Think")],
),
(False, [TextBlock.model_construct(id=A, text="Answer")]),
(
True,
[
ThinkingBlock.model_construct(id=A, thinking="Think"),
TextBlock.model_construct(id=A, text="Answer"),
],
),
],
)
@patch("xai_sdk.AsyncClient")
async def test_stream_tool_calls_in_final(
self,
mock_client_cls: MagicMock,
) -> None:
"""Stream text delta then final adds tool calls from last_response."""
final_response = _mock_completion(
tool_calls=[
{
"id": "call-1",
"name": "search",
"arguments": '{"q":"test"}',
},
],
)
final_response.usage = MagicMock()
final_response.usage.prompt_tokens = 10
final_response.usage.completion_tokens = 5
final_response.usage.cached_prompt_text_tokens = 0
stream_items = [
(final_response, _MockStreamChunk(content="I'll search")),
]
mock_chat = _MockChatStream(stream_items=stream_items)
mock_client_cls.return_value.chat.create.return_value = mock_chat
mock_client_cls.return_value.close = AsyncMock()
gen = await self.model([])
responses = [r async for r in gen]
self.assertListEqual(
[(r.is_last, r.content) for r in responses],
[
(False, [TextBlock.model_construct(id=A, text="I'll search")]),
# ``xai_sdk`` does not stream tool calls — they only
# appear on the final accumulated response object, so
# the parser emits them as a dedicated trailing carrier
# delta that the base accumulator merges.
(
False,
[
ToolCallBlock(
id="call-1",
name="search",
input='{"q":"test"}',
),
],
),
(
True,
[
TextBlock.model_construct(id=A, text="I'll search"),
ToolCallBlock(
id="call-1",
name="search",
input='{"q":"test"}',
),
],
),
],
)
class TestXAIModelParameters(unittest.TestCase):
"""Tests for XAIChatModel.Parameters."""
def test_thinking_enable_stored_on_model(self) -> None:
"""thinking_enable is accessible through model.parameters."""
model = XAIChatModel(
credential=XAICredential(api_key="test"),
model="grok-3-mini",
stream=False,
context_size=131_072,
parameters=XAIChatModel.Parameters(thinking_enable=True),
)
self.assertTrue(model.parameters.thinking_enable)
def test_reasoning_effort_stored_on_model(self) -> None:
"""reasoning_effort is accessible through model.parameters."""
model = XAIChatModel(
credential=XAICredential(api_key="test"),
model="grok-3-mini",
stream=False,
context_size=131_072,
parameters=XAIChatModel.Parameters(reasoning_effort="high"),
)
self.assertEqual(model.parameters.reasoning_effort, "high")
# ---------------------------------------------------------------------------
# _format_tools tests
# ---------------------------------------------------------------------------
_FT_TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
},
{
"type": "function",
"function": {
"name": "get_time",
"description": "Get the time",
"parameters": {
"type": "object",
"properties": {"timezone": {"type": "string"}},
"required": ["timezone"],
},
},
},
]
def _extend_xai_stub_for_tools() -> None:
"""Add tool and required_tool stubs to the existing xai_sdk stub."""
xai_chat = sys.modules.get("xai_sdk.chat")
if xai_chat is None or hasattr(xai_chat, "required_tool"):
return
class _RequiredTool:
def __init__(self, tool_name: str) -> None:
self.tool_name = tool_name
def __eq__(self, other: object) -> bool:
return (
isinstance(other, _RequiredTool)
and self.tool_name == other.tool_name
)
def _tool_stub(
name: str,
description: str = "",
parameters: Any = None,
) -> MagicMock:
m = MagicMock()
m.name = name
m.description = description
m.parameters = parameters or {}
return m
xai_chat.required_tool = _RequiredTool
xai_chat.tool = _tool_stub
_extend_xai_stub_for_tools()
class TestXAIFormatTools(unittest.TestCase):
"""Tests for XAIChatModel._format_tools."""
def setUp(self) -> None:
self.model = _make_model()
def test_no_tool_choice(self) -> None:
"""All tools are returned when tool_choice is None."""
fmt_tools, fmt_choice = self.model._format_tools(_FT_TOOLS, None)
self.assertIsNotNone(fmt_tools)
self.assertEqual(len(fmt_tools), 2)
self.assertIsNone(fmt_choice)
def test_auto_mode(self) -> None:
"""Auto mode passes tools through with choice 'auto'."""
fmt_tools, fmt_choice = self.model._format_tools(
_FT_TOOLS,
ToolChoice(mode="auto"),
)
self.assertIsNotNone(fmt_tools)
self.assertEqual(fmt_choice, "auto")
def test_none_mode(self) -> None:
"""None mode passes tools through with choice 'none'."""
fmt_tools, fmt_choice = self.model._format_tools(
_FT_TOOLS,
ToolChoice(mode="none"),
)
self.assertIsNotNone(fmt_tools)
self.assertEqual(fmt_choice, "none")
def test_str_mode_force_call(self) -> None:
"""String mode forces a required_tool for the named function."""
fmt_tools, fmt_choice = self.model._format_tools(
_FT_TOOLS,
ToolChoice(mode="get_weather"),
)
self.assertIsNotNone(fmt_tools)
self.assertEqual(fmt_choice.tool_name, "get_weather")
def test_tools_filtered(self) -> None:
"""ToolChoice with tools list filters to matching function names."""
fmt_tools, fmt_choice = self.model._format_tools(
_FT_TOOLS,
ToolChoice(mode="auto", tools=["get_weather"]),
)
self.assertEqual(len(fmt_tools), 1)
self.assertEqual(fmt_tools[0].name, "get_weather")
self.assertEqual(fmt_choice, "auto")