From b8b857b1a4cf78ab1b679528871fa366bcc7a7d0 Mon Sep 17 00:00:00 2001 From: crazywriter1 Date: Wed, 1 Apr 2026 20:33:55 +0300 Subject: [PATCH] fix: use httpx.Client context manager and set transaction_hash to 'external' in Twins.chat() httpx.post() was called without a context manager, leaving connections unclosed. transaction_hash was set to empty string instead of 'external' used by LLM class. Signed-off-by: crazywriter1 --- src/opengradient/client/twins.py | 9 +++-- tests/client_test.py | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/opengradient/client/twins.py b/src/opengradient/client/twins.py index 94bafcc2..2b4e5ae3 100644 --- a/src/opengradient/client/twins.py +++ b/src/opengradient/client/twins.py @@ -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, diff --git a/tests/client_test.py b/tests/client_test.py index 6829fc98..c439c532 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -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, ) @@ -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"}], + )