From d2ec58331fdc2b4bfd2f8c4b09b500697682e78b Mon Sep 17 00:00:00 2001 From: 1bcMax Date: Fri, 9 Jan 2026 14:25:33 -0500 Subject: [PATCH] feat(integrations): add BlockRun AI client for x402 payments --- python/ampersend-sdk/README.md | 29 ++ .../examples/blockrun_ai_example.py | 71 +++ .../src/ampersend_sdk/__init__.py | 14 + .../ampersend_sdk/integrations/__init__.py | 15 + .../ampersend_sdk/integrations/blockrun.py | 424 ++++++++++++++++++ .../tests/test_blockrun_integration.py | 265 +++++++++++ 6 files changed, 818 insertions(+) create mode 100644 python/ampersend-sdk/examples/blockrun_ai_example.py create mode 100644 python/ampersend-sdk/src/ampersend_sdk/integrations/__init__.py create mode 100644 python/ampersend-sdk/src/ampersend_sdk/integrations/blockrun.py create mode 100644 python/ampersend-sdk/tests/test_blockrun_integration.py diff --git a/python/ampersend-sdk/README.md b/python/ampersend-sdk/README.md index ef7bd31..d81f493 100644 --- a/python/ampersend-sdk/README.md +++ b/python/ampersend-sdk/README.md @@ -22,6 +22,33 @@ agent = X402RemoteA2aAgent( result = await agent.run("your query") ``` +## BlockRun AI Integration + +Access 30+ AI models (GPT-4o, Claude, Gemini, etc.) with x402 micropayments. + +```python +from ampersend_sdk.integrations.blockrun import BlockRunAI +from ampersend_sdk.x402.treasurers.naive import NaiveTreasurer +from ampersend_sdk.x402.wallets.account import AccountWallet + +wallet = AccountWallet(private_key="0x...") +treasurer = NaiveTreasurer(wallet) + +async with BlockRunAI(treasurer=treasurer) as ai: + response = await ai.chat("gpt-4o", "What is 2+2?") + print(response) # "4" +``` + +### Available Models + +- **OpenAI**: `gpt-4o`, `gpt-4o-mini`, `o1`, `o1-mini` +- **Anthropic**: `claude-sonnet`, `claude-haiku` +- **Google**: `gemini-pro`, `gemini-flash` +- **DeepSeek**: `deepseek`, `deepseek-reasoner` +- **Meta**: `llama` + +Powered by [BlockRun.ai](https://blockrun.ai) - The Discovery Layer for AI Agents. + ## Package Structure ``` @@ -29,6 +56,8 @@ ampersend_sdk/ ├── a2a/ │ ├── client/ # Client-side x402 support │ └── server/ # Server-side x402 support +├── integrations/ # Pre-built integrations +│ └── blockrun.py # BlockRun AI (30+ models) └── x402/ # Core x402 components ├── treasurer.py └── wallets/ # EOA & Smart Account wallets diff --git a/python/ampersend-sdk/examples/blockrun_ai_example.py b/python/ampersend-sdk/examples/blockrun_ai_example.py new file mode 100644 index 0000000..0e95f4b --- /dev/null +++ b/python/ampersend-sdk/examples/blockrun_ai_example.py @@ -0,0 +1,71 @@ +""" +BlockRun AI Integration Example. + +This example shows how to use BlockRun AI with Ampersend SDK to add +AI capabilities to your agents. BlockRun provides 30+ AI models including +GPT-4o, Claude, Gemini via x402 micropayments. + +Setup: + pip install ampersend-sdk httpx + +Usage: + export BASE_CHAIN_WALLET_KEY="0x..." + python blockrun_ai_example.py +""" + +import asyncio +import os + +from ampersend_sdk.integrations.blockrun import BlockRunAI +from ampersend_sdk.x402.treasurers.naive import NaiveTreasurer +from ampersend_sdk.x402.wallets.account import AccountWallet + + +async def main(): + # Setup wallet with your private key + private_key = os.environ.get("BASE_CHAIN_WALLET_KEY") + if not private_key: + print("Please set BASE_CHAIN_WALLET_KEY environment variable") + return + + # Create wallet and treasurer + wallet = AccountWallet(private_key=private_key) + treasurer = NaiveTreasurer(wallet) + + print(f"Wallet address: {wallet.address}") + + # Create BlockRun AI client + async with BlockRunAI(treasurer=treasurer) as ai: + # Simple chat with GPT-4o + print("\n--- GPT-4o ---") + response = await ai.chat("gpt-4o", "What is the capital of France?") + print(f"Response: {response}") + + # Chat with Claude + print("\n--- Claude Sonnet ---") + response = await ai.chat( + "claude-sonnet", + "Explain x402 payments in one sentence.", + ) + print(f"Response: {response}") + + # Chat with system prompt + print("\n--- GPT-4o with system prompt ---") + response = await ai.chat( + "gpt-4o", + "What should I invest in?", + system="You are a helpful AI assistant. Keep responses brief.", + ) + print(f"Response: {response}") + + # List available models + print("\n--- Available Models ---") + models = await ai.list_models() + for model in models[:5]: # Show first 5 + print(f" - {model.get('id')}: {model.get('pricing', {})}") + + print("\nDone!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/ampersend-sdk/src/ampersend_sdk/__init__.py b/python/ampersend-sdk/src/ampersend_sdk/__init__.py index e69de29..cb9387a 100644 --- a/python/ampersend-sdk/src/ampersend_sdk/__init__.py +++ b/python/ampersend-sdk/src/ampersend_sdk/__init__.py @@ -0,0 +1,14 @@ +""" +Ampersend SDK - x402 payment capabilities for A2A protocol applications. + +BlockRun AI Integration: + - BlockRunAI: Direct AI client for 30+ models (GPT-4o, Claude, Gemini, etc.) +""" + +# Re-export integrations for convenience +from .integrations import BlockRunAI, BlockRunAIError + +__all__ = [ + "BlockRunAI", + "BlockRunAIError", +] diff --git a/python/ampersend-sdk/src/ampersend_sdk/integrations/__init__.py b/python/ampersend-sdk/src/ampersend_sdk/integrations/__init__.py new file mode 100644 index 0000000..258204a --- /dev/null +++ b/python/ampersend-sdk/src/ampersend_sdk/integrations/__init__.py @@ -0,0 +1,15 @@ +""" +Ampersend SDK Integrations. + +BlockRun AI: + - BlockRunAI: Direct AI client for 30+ models (GPT-4o, Claude, Gemini, etc.) + - chat: Quick one-line chat function +""" + +from .blockrun import BlockRunAI, BlockRunAIError, chat + +__all__ = [ + "BlockRunAI", + "BlockRunAIError", + "chat", +] diff --git a/python/ampersend-sdk/src/ampersend_sdk/integrations/blockrun.py b/python/ampersend-sdk/src/ampersend_sdk/integrations/blockrun.py new file mode 100644 index 0000000..436e68b --- /dev/null +++ b/python/ampersend-sdk/src/ampersend_sdk/integrations/blockrun.py @@ -0,0 +1,424 @@ +""" +BlockRun AI Integration for Ampersend SDK. + +Provides AI capabilities (ChatGPT, Claude, Gemini, and 30+ models) via +BlockRun's x402 AI Gateway with automatic payment handling. + +Usage: + ```python + from ampersend_sdk.integrations.blockrun import BlockRunAI + + async with BlockRunAI(treasurer=treasurer) as ai: + response = await ai.chat("gpt-4o", "What is 2+2?") + print(response) # "4" + ``` + +Available Models: + - OpenAI: gpt-4o, gpt-4o-mini, gpt-4-turbo, o1, o1-mini + - Anthropic: claude-sonnet, claude-haiku + - Google: gemini-pro, gemini-flash + - DeepSeek: deepseek, deepseek-reasoner + - Meta: llama + +Powered by BlockRun.ai - The Discovery Layer for AI Agents +""" + +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import httpx +from x402_a2a.types import ( + PaymentRequirements, + x402PaymentRequiredResponse, +) + +from ..x402.treasurer import X402Treasurer + +logger = logging.getLogger(__name__) + + +@dataclass +class ChatMessage: + """A chat message.""" + role: str + content: str + + +@dataclass +class ChatChoice: + """A single choice in a chat response.""" + index: int + message: ChatMessage + finish_reason: str + + +@dataclass +class ChatUsage: + """Token usage information.""" + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +@dataclass +class ChatResponse: + """Chat completion response.""" + id: str + model: str + choices: List[ChatChoice] + usage: ChatUsage + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ChatResponse": + """Create from API response dict.""" + choices = [ + ChatChoice( + index=c.get("index", 0), + message=ChatMessage( + role=c["message"]["role"], + content=c["message"]["content"], + ), + finish_reason=c.get("finish_reason", "stop"), + ) + for c in data.get("choices", []) + ] + usage_data = data.get("usage", {}) + usage = ChatUsage( + prompt_tokens=usage_data.get("prompt_tokens", 0), + completion_tokens=usage_data.get("completion_tokens", 0), + total_tokens=usage_data.get("total_tokens", 0), + ) + return cls( + id=data.get("id", ""), + model=data.get("model", ""), + choices=choices, + usage=usage, + ) + + +class BlockRunAIError(Exception): + """BlockRun AI error.""" + pass + + +class BlockRunAI: + """ + BlockRun AI Client for Ampersend SDK. + + Provides access to 30+ AI models (GPT-4o, Claude, Gemini, etc.) via + BlockRun's x402 AI Gateway. Uses Ampersend's Treasurer for payment + authorization. + + Features: + - 30+ AI models from OpenAI, Anthropic, Google, DeepSeek, Meta + - Pay-per-request pricing with x402 micropayments + - No API keys needed - wallet is your identity + - Automatic payment handling via Treasurer + + Example: + ```python + from ampersend_sdk.integrations.blockrun import BlockRunAI + from ampersend_sdk.x402.treasurers.naive import NaiveTreasurer + from ampersend_sdk.x402.wallets.account import AccountWallet + + wallet = AccountWallet(private_key="0x...") + treasurer = NaiveTreasurer(wallet) + + async with BlockRunAI(treasurer=treasurer) as ai: + response = await ai.chat("gpt-4o", "Hello!") + print(response) + ``` + """ + + DEFAULT_API_URL = "https://blockrun.ai/api" + DEFAULT_MAX_TOKENS = 1024 + + # Popular models with aliases for convenience + MODELS = { + # OpenAI + "gpt-4o": "openai/gpt-4o", + "gpt-4o-mini": "openai/gpt-4o-mini", + "gpt-4-turbo": "openai/gpt-4-turbo", + "o1": "openai/o1", + "o1-mini": "openai/o1-mini", + # Anthropic + "claude-sonnet": "anthropic/claude-sonnet-4", + "claude-haiku": "anthropic/claude-haiku", + # Google + "gemini-pro": "google/gemini-2.5-pro", + "gemini-flash": "google/gemini-2.5-flash", + # DeepSeek + "deepseek": "deepseek/deepseek-chat", + "deepseek-reasoner": "deepseek/deepseek-reasoner", + # Meta + "llama": "meta/llama-3.3-70b", + } + + def __init__( + self, + treasurer: X402Treasurer, + *, + api_url: Optional[str] = None, + timeout: float = 60.0, + ): + """ + Initialize BlockRun AI client. + + Args: + treasurer: X402Treasurer for payment authorization + api_url: API endpoint URL (default: https://blockrun.ai/api) + timeout: Request timeout in seconds + """ + self._treasurer = treasurer + self._api_url = (api_url or self.DEFAULT_API_URL).rstrip("/") + self._timeout = timeout + self._client = httpx.AsyncClient(timeout=timeout) + + async def chat( + self, + model: str, + prompt: str, + *, + system: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + ) -> str: + """ + Simple chat interface. + + Args: + model: Model ID (e.g., "gpt-4o", "claude-sonnet", "gemini-pro") + prompt: User message + system: Optional system prompt + max_tokens: Max tokens to generate + temperature: Sampling temperature (0.0-2.0) + + Returns: + Assistant's response text + + Example: + response = await ai.chat("gpt-4o", "What is 2+2?") + print(response) # "4" + """ + messages: List[Dict[str, str]] = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + result = await self.chat_completion( + model=model, + messages=messages, + max_tokens=max_tokens, + temperature=temperature, + ) + return result.choices[0].message.content + + async def chat_completion( + self, + model: str, + messages: List[Dict[str, str]], + *, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + top_p: Optional[float] = None, + ) -> ChatResponse: + """ + Full chat completion interface (OpenAI-compatible). + + Args: + model: Model ID or alias + messages: List of message dicts with 'role' and 'content' + max_tokens: Max tokens to generate + temperature: Sampling temperature + top_p: Nucleus sampling parameter + + Returns: + ChatResponse with choices and usage + """ + # Resolve model alias + resolved_model = self.MODELS.get(model, model) + + body: Dict[str, Any] = { + "model": resolved_model, + "messages": messages, + "max_tokens": max_tokens or self.DEFAULT_MAX_TOKENS, + } + if temperature is not None: + body["temperature"] = temperature + if top_p is not None: + body["top_p"] = top_p + + return await self._request_with_payment("/v1/chat/completions", body) + + async def _request_with_payment( + self, + endpoint: str, + body: Dict[str, Any], + ) -> ChatResponse: + """ + Make request with automatic x402 payment handling. + + Flow: + 1. Send initial request + 2. If 402, use Treasurer to authorize payment + 3. Retry with X-Payment header + """ + url = f"{self._api_url}{endpoint}" + + # First attempt + response = await self._client.post( + url, + json=body, + headers={"Content-Type": "application/json"}, + ) + + # Handle 402 Payment Required + if response.status_code == 402: + return await self._handle_payment(url, body, response) + + # Handle other errors + if response.status_code != 200: + try: + error = response.json() + except Exception: + error = {"error": response.text} + raise BlockRunAIError(f"API error {response.status_code}: {error}") + + return ChatResponse.from_dict(response.json()) + + async def _handle_payment( + self, + url: str, + body: Dict[str, Any], + response: httpx.Response, + ) -> ChatResponse: + """ + Handle 402 Payment Required response. + + Uses Ampersend Treasurer for payment authorization. + """ + # Parse payment requirements from header or body + payment_header = response.headers.get("payment-required") + + if payment_header: + try: + payment_data = json.loads(payment_header) + except json.JSONDecodeError: + raise BlockRunAIError( + "Invalid payment-required header format" + ) + else: + # Try body + try: + payment_data = response.json() + except Exception: + raise BlockRunAIError( + "402 response but no payment requirements found" + ) + + # Build x402PaymentRequiredResponse + accepts = [] + for accept_data in payment_data.get("accepts", []): + req = PaymentRequirements( + scheme=accept_data.get("scheme", "exact"), + network=accept_data.get("network", "base"), + max_amount_required=accept_data.get("maxAmountRequired", "0"), + resource=accept_data.get("resource", url), + description=accept_data.get("description", "BlockRun AI API"), + mime_type=accept_data.get("mimeType", "application/json"), + pay_to=accept_data.get("payTo", ""), + max_timeout_seconds=accept_data.get("maxTimeoutSeconds", 300), + asset=accept_data.get("asset", ""), + extra=accept_data.get("extra"), + ) + accepts.append(req) + + if not accepts: + raise BlockRunAIError("No payment requirements in 402 response") + + payment_required = x402PaymentRequiredResponse( + x402Version=payment_data.get("x402Version", 1), + accepts=accepts, + error=payment_data.get("error", ""), + ) + + # Ask Treasurer to authorize payment + authorization = await self._treasurer.onPaymentRequired( + payment_required, + context={"url": url, "body": body}, + ) + + if authorization is None: + raise BlockRunAIError("Payment not authorized by treasurer") + + # Retry with payment + retry_response = await self._client.post( + url, + json=body, + headers={ + "Content-Type": "application/json", + "X-PAYMENT": authorization.payment, + }, + ) + + if retry_response.status_code == 402: + raise BlockRunAIError( + "Payment rejected. Check wallet balance or treasurer policy." + ) + + if retry_response.status_code != 200: + try: + error = retry_response.json() + except Exception: + error = {"error": retry_response.text} + raise BlockRunAIError( + f"API error after payment {retry_response.status_code}: {error}" + ) + + return ChatResponse.from_dict(retry_response.json()) + + async def list_models(self) -> List[Dict[str, Any]]: + """ + List available models with pricing. + + Returns: + List of model information dicts with id, pricing, etc. + """ + response = await self._client.get(f"{self._api_url}/v1/models") + + if response.status_code != 200: + raise BlockRunAIError(f"Failed to list models: {response.status_code}") + + return response.json().get("data", []) + + async def close(self): + """Close the HTTP client.""" + await self._client.aclose() + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self.close() + + +# Convenience function for quick usage +async def chat( + treasurer: X402Treasurer, + model: str, + prompt: str, + *, + system: Optional[str] = None, +) -> str: + """ + Quick chat function without creating a client. + + Example: + from ampersend_sdk.integrations.blockrun import chat + + response = await chat(treasurer, "gpt-4o", "Hello!") + """ + async with BlockRunAI(treasurer=treasurer) as ai: + return await ai.chat(model, prompt, system=system) diff --git a/python/ampersend-sdk/tests/test_blockrun_integration.py b/python/ampersend-sdk/tests/test_blockrun_integration.py new file mode 100644 index 0000000..957d9a6 --- /dev/null +++ b/python/ampersend-sdk/tests/test_blockrun_integration.py @@ -0,0 +1,265 @@ +"""Tests for BlockRun AI integration.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +import json + +from ampersend_sdk.integrations.blockrun import ( + BlockRunAI, + BlockRunAIError, + ChatResponse, +) + + +class MockTreasurer: + """Mock treasurer for testing.""" + + async def onPaymentRequired(self, payment_required, context=None): + """Mock payment authorization.""" + from ampersend_sdk.x402.treasurer import X402Authorization + + return X402Authorization( + authorization_id="test-auth-123", + payment="mock-payment-payload", + ) + + async def onStatus(self, status, authorization, context=None): + pass + + +@pytest.fixture +def mock_treasurer(): + return MockTreasurer() + + +class TestBlockRunAI: + """Tests for BlockRunAI client.""" + + def test_init(self, mock_treasurer): + """Test client initialization.""" + ai = BlockRunAI(treasurer=mock_treasurer) + assert ai._api_url == "https://blockrun.ai/api" + assert ai._timeout == 60.0 + + def test_init_custom_url(self, mock_treasurer): + """Test client with custom API URL.""" + ai = BlockRunAI( + treasurer=mock_treasurer, + api_url="https://custom.api.com/", + timeout=30.0, + ) + assert ai._api_url == "https://custom.api.com" + assert ai._timeout == 30.0 + + def test_model_aliases(self, mock_treasurer): + """Test model alias resolution.""" + ai = BlockRunAI(treasurer=mock_treasurer) + assert ai.MODELS["gpt-4o"] == "openai/gpt-4o" + assert ai.MODELS["claude-sonnet"] == "anthropic/claude-sonnet-4" + assert ai.MODELS["gemini-pro"] == "google/gemini-2.5-pro" + + @pytest.mark.asyncio + async def test_chat_success(self, mock_treasurer): + """Test successful chat completion.""" + ai = BlockRunAI(treasurer=mock_treasurer) + + # Mock the HTTP response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "test-123", + "model": "openai/gpt-4o", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello!"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + } + + with patch.object(ai._client, "post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_response + + response = await ai.chat("gpt-4o", "Hi!") + assert response == "Hello!" + + # Verify request was made correctly + mock_post.assert_called_once() + call_args = mock_post.call_args + assert "/v1/chat/completions" in call_args[0][0] + assert call_args[1]["json"]["model"] == "openai/gpt-4o" + + await ai.close() + + @pytest.mark.asyncio + async def test_chat_with_402(self, mock_treasurer): + """Test chat with 402 payment required.""" + ai = BlockRunAI(treasurer=mock_treasurer) + + # First response: 402 + mock_402_response = MagicMock() + mock_402_response.status_code = 402 + mock_402_response.headers = {"payment-required": None} + mock_402_response.json.return_value = { + "x402Version": 1, + "accepts": [ + { + "scheme": "exact", + "network": "base", + "maxAmountRequired": "1000", + "resource": "https://blockrun.ai/api/v1/chat/completions", + "description": "BlockRun AI API", + "mimeType": "application/json", + "payTo": "0x123...", + "maxTimeoutSeconds": 300, + "asset": "USDC", + } + ], + } + + # Second response: 200 success + mock_200_response = MagicMock() + mock_200_response.status_code = 200 + mock_200_response.json.return_value = { + "id": "test-123", + "model": "openai/gpt-4o", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello after payment!"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + + with patch.object(ai._client, "post", new_callable=AsyncMock) as mock_post: + mock_post.side_effect = [mock_402_response, mock_200_response] + + response = await ai.chat("gpt-4o", "Hi!") + assert response == "Hello after payment!" + + # Should have made 2 requests + assert mock_post.call_count == 2 + + # Second request should have X-PAYMENT header + second_call = mock_post.call_args_list[1] + assert "X-PAYMENT" in second_call[1]["headers"] + + await ai.close() + + @pytest.mark.asyncio + async def test_chat_completion_full(self, mock_treasurer): + """Test full chat completion response.""" + ai = BlockRunAI(treasurer=mock_treasurer) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "id": "chatcmpl-test", + "model": "openai/gpt-4o", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Test response"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 20, + "completion_tokens": 10, + "total_tokens": 30, + }, + } + + with patch.object(ai._client, "post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_response + + result = await ai.chat_completion( + "gpt-4o", + [{"role": "user", "content": "Hello"}], + max_tokens=100, + temperature=0.7, + ) + + assert isinstance(result, ChatResponse) + assert result.id == "chatcmpl-test" + assert result.model == "openai/gpt-4o" + assert len(result.choices) == 1 + assert result.choices[0].message.content == "Test response" + assert result.usage.total_tokens == 30 + + await ai.close() + + @pytest.mark.asyncio + async def test_list_models(self, mock_treasurer): + """Test listing available models.""" + ai = BlockRunAI(treasurer=mock_treasurer) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "data": [ + {"id": "openai/gpt-4o", "pricing": {"input": 0.01, "output": 0.03}}, + {"id": "anthropic/claude-sonnet-4", "pricing": {"input": 0.02}}, + ] + } + + with patch.object(ai._client, "get", new_callable=AsyncMock) as mock_get: + mock_get.return_value = mock_response + + models = await ai.list_models() + assert len(models) == 2 + assert models[0]["id"] == "openai/gpt-4o" + + await ai.close() + + +class TestChatResponse: + """Tests for ChatResponse dataclass.""" + + def test_from_dict(self): + """Test creating ChatResponse from dict.""" + data = { + "id": "test-123", + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello"}, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 3, + "total_tokens": 8, + }, + } + + response = ChatResponse.from_dict(data) + assert response.id == "test-123" + assert response.model == "gpt-4o" + assert len(response.choices) == 1 + assert response.choices[0].message.content == "Hello" + assert response.usage.total_tokens == 8 + + def test_from_dict_minimal(self): + """Test creating ChatResponse from minimal dict.""" + data = { + "choices": [ + {"message": {"role": "assistant", "content": "Hi"}} + ], + } + + response = ChatResponse.from_dict(data) + assert response.id == "" + assert response.model == "" + assert response.choices[0].message.content == "Hi" + assert response.usage.total_tokens == 0