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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
- **Public File URLs**: Added `client.files.create_public_url()` and `client.files.revoke_public_url()` (sync and async) to create and revoke publicly shareable, unauthenticated URLs for stored files. `create_public_url()` accepts an optional `expires_after` (an `int` in seconds or a `datetime.timedelta`).
- **Files List Filter**: `client.files.list()` (sync and async) now accepts an optional `filter` parameter to narrow results server-side by fields such as `content_type`, `size_bytes`, `created_at`, `upload_status`, and `public_url` (e.g. `filter='public_url != null'`).

### Fixed
- `chat.append(response)` now sets `tool_call_id` on replayed tool-role messages (recovered from the tool call echoed on the output), so servers can pair replayed tool turns with their originating calls. This fixes stateless multi-turn follow-ups to server-side tools whose results are re-hydrated by ID — e.g. editing a previously generated image in-memory without `previous_response_id`.

## [v1.14.0]
### Added
- **Image Search**: Added `enable_image_search` parameter to `web_search()` to return image results that can be embedded in responses
Expand Down
19 changes: 18 additions & 1 deletion src/xai_sdk/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,20 @@ def _make_chat(self, conversation_id: Optional[str], batch_request_id: Optional[
"""Creates the proto wrapper for chat requests."""


def _replay_tool_call_id(output_message: chat_pb2.CompletionMessage) -> Optional[str]:
"""Recovers the `tool_call_id` for replaying a completion output as an input message.

`chat_pb2.CompletionMessage` does not carry a `tool_call_id` field, but server-side
tool outputs (ROLE_TOOL) echo the originating call in `tool_calls`. The server pairs
replayed tool turns with their calls via `Message.tool_call_id` (e.g. to re-hydrate
binary tool results such as generated images), so it must be set when appending a
response back onto the conversation.
"""
if output_message.role == chat_pb2.MessageRole.ROLE_TOOL and output_message.tool_calls:
return output_message.tool_calls[0].id
return None


class BaseChat(ProtoDecorator[chat_pb2.GetCompletionsRequest]):
"""Utility class for simplifying the interaction with Chat requests and responses."""

Expand Down Expand Up @@ -377,16 +391,19 @@ def append(self, message: Union[chat_pb2.Message, "Response", "CompactContextRes
reasoning_content=output.message.reasoning_content,
encrypted_content=output.message.encrypted_content,
tool_calls=output.message.tool_calls,
tool_call_id=_replay_tool_call_id(output.message),
)
)
else:
output_message = message._get_output().message
self._proto.messages.append(
chat_pb2.Message(
role=message._get_output().message.role,
role=output_message.role,
content=[text(message.content)],
reasoning_content=message.reasoning_content,
encrypted_content=message.encrypted_content,
tool_calls=message.tool_calls,
tool_call_id=_replay_tool_call_id(output_message),
)
)
else:
Expand Down
76 changes: 76 additions & 0 deletions tests/chat_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,6 +792,82 @@ def test_compact_context_response_empty_encrypted_content():
assert response.encrypted_content == ""


def test_append_agentic_response_sets_tool_call_id_on_tool_messages():
"""Test that append(Response) links replayed ROLE_TOOL outputs to their originating tool call.

Server-side hydration pairs replayed tool turns via `Message.tool_call_id` (e.g. to
re-hydrate binary results like generated images), so appending an agentic response
must set it from the tool call echoed on the ROLE_TOOL output.
"""
from xai_sdk.proto import chat_pb2_grpc
from xai_sdk.sync.chat import Chat as SyncChat

tool_call = chat_pb2.ToolCall(
id="call_1",
type=chat_pb2.TOOL_CALL_TYPE_WEB_SEARCH_TOOL,
status=chat_pb2.TOOL_CALL_STATUS_COMPLETED,
function=chat_pb2.FunctionCall(name="web_search", arguments="{}"),
)
response_pb = chat_pb2.GetChatCompletionResponse(
outputs=[
chat_pb2.CompletionOutput(
index=0,
message=chat_pb2.CompletionMessage(role=chat_pb2.MessageRole.ROLE_ASSISTANT, tool_calls=[tool_call]),
),
chat_pb2.CompletionOutput(
index=1,
message=chat_pb2.CompletionMessage(
role=chat_pb2.MessageRole.ROLE_TOOL,
content='{"__type": "web_search_result"}',
tool_calls=[tool_call],
),
),
chat_pb2.CompletionOutput(
index=2,
message=chat_pb2.CompletionMessage(role=chat_pb2.MessageRole.ROLE_ASSISTANT, content="Done!"),
),
]
)
response = Response(response_pb, None) # None means agentic: replay all outputs.

stub = chat_pb2_grpc.ChatStub.__new__(chat_pb2_grpc.ChatStub)
chat = SyncChat(stub, None, None, model="grok-4.3")
chat.append(response)

assert len(chat.messages) == 3
assert chat.messages[0].role == chat_pb2.MessageRole.ROLE_ASSISTANT
assert chat.messages[0].tool_call_id == ""
assert chat.messages[1].role == chat_pb2.MessageRole.ROLE_TOOL
assert chat.messages[1].tool_call_id == "call_1"
assert [tc.id for tc in chat.messages[1].tool_calls] == ["call_1"]
assert chat.messages[2].role == chat_pb2.MessageRole.ROLE_ASSISTANT
assert chat.messages[2].tool_call_id == ""


def test_append_indexed_response_leaves_tool_call_id_unset_on_assistant_message():
"""Test that append(Response) with an index keeps assistant messages without a tool_call_id."""
from xai_sdk.proto import chat_pb2_grpc
from xai_sdk.sync.chat import Chat as SyncChat

response_pb = chat_pb2.GetChatCompletionResponse(
outputs=[
chat_pb2.CompletionOutput(
index=0,
message=chat_pb2.CompletionMessage(role=chat_pb2.MessageRole.ROLE_ASSISTANT, content="Hello"),
)
]
)
response = Response(response_pb, 0)

stub = chat_pb2_grpc.ChatStub.__new__(chat_pb2_grpc.ChatStub)
chat = SyncChat(stub, None, None, model="grok-4.3")
chat.append(response)

assert len(chat.messages) == 1
assert chat.messages[0].role == chat_pb2.MessageRole.ROLE_ASSISTANT
assert chat.messages[0].tool_call_id == ""


def test_append_compact_context_response_creates_user_message():
"""Test that append(CompactContextResponse) produces a ROLE_USER message with encrypted_content."""
from xai_sdk.proto import chat_pb2_grpc
Expand Down
Loading