Skip to content
Open
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
9 changes: 5 additions & 4 deletions src/opengradient/client/twins.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,17 @@ def chat(
payload["max_tokens"] = max_tokens

try:
response = httpx.post(url, json=payload, headers=headers, timeout=60)
response.raise_for_status()
result = response.json()
with httpx.Client() as client:
response = client.post(url, json=payload, headers=headers, timeout=60)
response.raise_for_status()
result = response.json()

choices = result.get("choices")
if not choices:
raise RuntimeError(f"Invalid response: 'choices' missing or empty in {result}")

return TextGenerationOutput(
transaction_hash="",
transaction_hash="external",
finish_reason=choices[0].get("finish_reason"),
chat_output=choices[0].get("message"),
payment_hash=None,
Expand Down
67 changes: 67 additions & 0 deletions tests/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

from opengradient.client.llm import LLM
from opengradient.client.model_hub import ModelHub
from opengradient.client.twins import Twins
from opengradient.types import (
TEE_LLM,
StreamChunk,
x402SettlementMode,
)
Expand Down Expand Up @@ -190,3 +192,68 @@ def test_settlement_modes_values(self):
assert x402SettlementMode.PRIVATE == "private"
assert x402SettlementMode.BATCH_HASHED == "batch"
assert x402SettlementMode.INDIVIDUAL_FULL == "individual"


# --- Twins Tests ---


class TestTwinsChat:
"""Tests for Twins.chat() resource management and response consistency."""

@patch("opengradient.client.twins.httpx.Client")
def test_chat_uses_context_manager(self, mock_client_cls):
"""Verify httpx.Client is used as a context manager so connections are closed."""
mock_response = MagicMock()
mock_response.json.return_value = {
"choices": [{"message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}]
}
mock_response.raise_for_status = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=MagicMock(post=MagicMock(return_value=mock_response)))
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)

twins = Twins(api_key="test-key")
result = twins.chat(
twin_id="0xabc",
model=TEE_LLM.GROK_4,
messages=[{"role": "user", "content": "hello"}],
)

mock_client_cls.return_value.__enter__.assert_called_once()
mock_client_cls.return_value.__exit__.assert_called_once()

@patch("opengradient.client.twins.httpx.Client")
def test_chat_returns_external_transaction_hash(self, mock_client_cls):
"""Verify transaction_hash is 'external' for consistency with LLM class."""
mock_response = MagicMock()
mock_response.json.return_value = {
"choices": [{"message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}]
}
mock_response.raise_for_status = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=MagicMock(post=MagicMock(return_value=mock_response)))
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)

twins = Twins(api_key="test-key")
result = twins.chat(
twin_id="0xabc",
model=TEE_LLM.GROK_4,
messages=[{"role": "user", "content": "hello"}],
)

assert result.transaction_hash == "external"

@patch("opengradient.client.twins.httpx.Client")
def test_chat_empty_choices_raises(self, mock_client_cls):
"""Verify RuntimeError when choices is empty."""
mock_response = MagicMock()
mock_response.json.return_value = {"choices": []}
mock_response.raise_for_status = MagicMock()
mock_client_cls.return_value.__enter__ = MagicMock(return_value=MagicMock(post=MagicMock(return_value=mock_response)))
mock_client_cls.return_value.__exit__ = MagicMock(return_value=False)

twins = Twins(api_key="test-key")
with pytest.raises(RuntimeError, match="'choices' missing or empty"):
twins.chat(
twin_id="0xabc",
model=TEE_LLM.GROK_4,
messages=[{"role": "user", "content": "hello"}],
)