diff --git a/README.md b/README.md index bf990bc2..0892a0a3 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,10 @@ hub = og.ModelHub(email="you@example.com", password="...") ### OPG Token Approval -Before making LLM requests, your wallet must approve OPG token spending via the [Permit2](https://github.com/Uniswap/permit2) protocol. Call this once (it's idempotent — no transaction is sent if the allowance already covers the requested amount): +Before making LLM requests, your wallet must approve OPG token spending via the [Permit2](https://github.com/Uniswap/permit2) protocol. This only sends an on-chain transaction when the current allowance drops below the threshold: ```python -llm.ensure_opg_approval(opg_amount=5) +llm.ensure_opg_approval(min_allowance=5) ``` See [Payment Settlement](#payment-settlement) for details on settlement modes. diff --git a/docs/CLAUDE_SDK_USERS.md b/docs/CLAUDE_SDK_USERS.md index 22a5e4c8..1619c681 100644 --- a/docs/CLAUDE_SDK_USERS.md +++ b/docs/CLAUDE_SDK_USERS.md @@ -21,8 +21,8 @@ import os # Create an LLM client llm = og.LLM(private_key=os.environ["OG_PRIVATE_KEY"]) -# One-time OPG token approval (idempotent — skips if allowance already sufficient) -llm.ensure_opg_approval(opg_amount=0.1) +# Ensure sufficient OPG allowance (only sends tx when below threshold) +llm.ensure_opg_approval(min_allowance=0.1) # LLM Chat (TEE-verified with x402 payments, async) result = await llm.chat( diff --git a/docs/opengradient/client/index.md b/docs/opengradient/client/index.md index f7ee48ad..0a9397f3 100644 --- a/docs/opengradient/client/index.md +++ b/docs/opengradient/client/index.md @@ -24,7 +24,7 @@ import opengradient as og # LLM inference (Base Sepolia OPG tokens) llm = og.LLM(private_key="0x...") -llm.ensure_opg_approval(opg_amount=5) +llm.ensure_opg_approval(min_allowance=5) result = await llm.chat(model=og.TEE_LLM.CLAUDE_HAIKU_4_5, messages=[...]) # On-chain model inference (OpenGradient testnet gas tokens) diff --git a/docs/opengradient/client/llm.md b/docs/opengradient/client/llm.md index edf8230e..2dd07e0b 100644 --- a/docs/opengradient/client/llm.md +++ b/docs/opengradient/client/llm.md @@ -22,8 +22,6 @@ All request methods (``chat``, ``completion``) are async. Before making LLM requests, ensure your wallet has approved sufficient OPG tokens for Permit2 spending by calling ``ensure_opg_approval``. -This only sends an on-chain transaction when the current allowance is -below the requested amount. #### Constructor @@ -193,18 +191,30 @@ TextGenerationOutput: Generated text results including: #### `ensure_opg_approval()` ```python -def ensure_opg_approval(self, opg_amount: float) ‑> [Permit2ApprovalResult](./opg_token) +def ensure_opg_approval( + self, + min_allowance: float, + approve_amount: Optional[float] = None +) ‑> [Permit2ApprovalResult](./opg_token) ``` -Ensure the Permit2 allowance for OPG is at least ``opg_amount``. +Ensure the Permit2 allowance stays above a minimum threshold. + +Only sends a transaction when the current allowance drops below +``min_allowance``. When approval is needed, approves ``approve_amount`` +(defaults to ``2 * min_allowance``) to create a buffer that survives +multiple service restarts without re-approving. + +Best for backend servers that call this on startup:: -Checks the current Permit2 allowance for the wallet. If the allowance -is already >= the requested amount, returns immediately without sending -a transaction. Otherwise, sends an ERC-20 approve transaction. + llm.ensure_opg_approval(min_allowance=5.0, approve_amount=100.0) **Arguments** -* **`opg_amount`**: Minimum number of OPG tokens required (e.g. ``0.1`` - for 0.1 OPG). Must be at least 0.1 OPG. +* **`min_allowance`**: The minimum acceptable allowance in OPG. Must be + at least 0.1 OPG. +* **`approve_amount`**: The amount of OPG to approve when a transaction + is needed. Defaults to ``2 * min_allowance``. Must be + >= ``min_allowance``. **Returns** @@ -214,5 +224,6 @@ Permit2ApprovalResult: Contains ``allowance_before``, **Raises** -* **`ValueError`**: If the OPG amount is less than 0.1. +* **`ValueError`**: If ``min_allowance`` is less than 0.1 or + ``approve_amount`` is less than ``min_allowance``. * **`RuntimeError`**: If the approval transaction fails. \ No newline at end of file diff --git a/docs/opengradient/client/model_hub.md b/docs/opengradient/client/model_hub.md index e3d95916..795de4cf 100644 --- a/docs/opengradient/client/model_hub.md +++ b/docs/opengradient/client/model_hub.md @@ -43,15 +43,16 @@ Create a new model with the given model_name and model_desc, and a specified ver * **`model_name (str)`**: The name of the model. * **`model_desc (str)`**: The description of the model. -* **`version (str)`**: The version identifier (default is "1.00"). +* **`version (str)`**: A label used in the initial version notes (default is "1.00"). +* **`Note`**: the actual version string is assigned by the server. **Returns** -dict: The server response containing model details. +ModelRepository: Object containing the model name and server-assigned version string. **Raises** -* **`CreateModelError`**: If the model creation fails. +* **`RuntimeError`**: If the model creation fails. --- @@ -125,7 +126,7 @@ Upload a model file to the server. **Returns** -dict: The processed result. +FileUploadResult: The processed result. **Raises** diff --git a/docs/opengradient/client/opg_token.md b/docs/opengradient/client/opg_token.md deleted file mode 100644 index 4e507301..00000000 --- a/docs/opengradient/client/opg_token.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -outline: [2,3] ---- - -[opengradient](../index) / [client](./index) / opg_token - -# Package opengradient.client.opg_token - -OPG token Permit2 approval utilities for x402 payments. - -## Functions - ---- - -### `ensure_opg_approval()` - -```python -def ensure_opg_approval( - wallet_account: `LocalAccount`, - opg_amount: float -) ‑> `Permit2ApprovalResult` -``` -Ensure the Permit2 allowance for OPG is at least ``opg_amount``. - -Checks the current Permit2 allowance for the wallet. If the allowance -is already >= the requested amount, returns immediately without sending -a transaction. Otherwise, sends an ERC-20 approve transaction. - -**Arguments** - -* **`wallet_account`**: The wallet account to check and approve from. -* **`opg_amount`**: Minimum number of OPG tokens required (e.g. ``5.0`` - for 5 OPG). Converted to base units (18 decimals) internally. - -**Returns** - -Permit2ApprovalResult: Contains ``allowance_before``, - ``allowance_after``, and ``tx_hash`` (None when no approval - was needed). - -**`Permit2ApprovalResult` fields:** - -* **`allowance_before`**: The Permit2 allowance before the method ran. -* **`allowance_after`**: The Permit2 allowance after the method ran. -* **`tx_hash`**: Transaction hash of the approval, or None if no transaction was needed. - -**Raises** - -* **`RuntimeError`**: If the approval transaction fails. - -## Classes - -### `Permit2ApprovalResult` - -Result of a Permit2 allowance check / approval. - -**Attributes** - -* **`allowance_before`**: The Permit2 allowance before the method ran. -* **`allowance_after`**: The Permit2 allowance after the method ran. -* **`tx_hash`**: Transaction hash of the approval, or None if no transaction was needed. - -#### Constructor - -```python -def __init__(allowance_before: int, allowance_after: int, tx_hash: Optional[str] = None) -``` - -#### Variables - -* static `allowance_after` : int -* static `allowance_before` : int -* static `tx_hash` : Optional[str] \ No newline at end of file diff --git a/docs/opengradient/index.md b/docs/opengradient/index.md index 06e70e21..c9b01fda 100644 --- a/docs/opengradient/index.md +++ b/docs/opengradient/index.md @@ -35,7 +35,7 @@ import opengradient as og llm = og.LLM(private_key="0x...") # One-time OPG token approval (idempotent -- skips if allowance is sufficient) -llm.ensure_opg_approval(opg_amount=5) +llm.ensure_opg_approval(min_allowance=5) # Chat with an LLM (TEE-verified) response = asyncio.run(llm.chat( diff --git a/examples/langchain_react_agent.py b/examples/langchain_react_agent.py index 9ca5faae..7d372cae 100644 --- a/examples/langchain_react_agent.py +++ b/examples/langchain_react_agent.py @@ -17,9 +17,9 @@ private_key = os.environ["OG_PRIVATE_KEY"] -# One-time Permit2 approval for OPG spending (idempotent) +# Ensure sufficient OPG allowance for Permit2 spending llm_client = og.LLM(private_key=private_key) -llm_client.ensure_opg_approval(opg_amount=5) +llm_client.ensure_opg_approval(min_allowance=5) # Create the OpenGradient LangChain adapter llm = og.agents.langchain_adapter( diff --git a/examples/llm_chat.py b/examples/llm_chat.py index a2c599cb..68bf19e2 100644 --- a/examples/llm_chat.py +++ b/examples/llm_chat.py @@ -10,7 +10,7 @@ async def main(): llm = og.LLM(private_key=os.environ.get("OG_PRIVATE_KEY")) - llm.ensure_opg_approval(opg_amount=0.1) + llm.ensure_opg_approval(min_allowance=0.1) messages = [ {"role": "user", "content": "What is the capital of France?"}, diff --git a/examples/llm_chat_streaming.py b/examples/llm_chat_streaming.py index 17db4774..d6cb6023 100644 --- a/examples/llm_chat_streaming.py +++ b/examples/llm_chat_streaming.py @@ -6,7 +6,7 @@ async def main(): llm = og.LLM(private_key=os.environ.get("OG_PRIVATE_KEY")) - llm.ensure_opg_approval(opg_amount=0.1) + llm.ensure_opg_approval(min_allowance=0.1) messages = [ {"role": "user", "content": "What is Python?"}, diff --git a/examples/llm_tool_calling.py b/examples/llm_tool_calling.py index 51fbbbb3..b7632983 100644 --- a/examples/llm_tool_calling.py +++ b/examples/llm_tool_calling.py @@ -50,8 +50,8 @@ async def main(): {"role": "user", "content": "What's the weather like in Dallas, Texas? Give me the temperature in fahrenheit."}, ] - # One-time Permit2 approval for OPG spending (idempotent) - llm.ensure_opg_approval(opg_amount=0.1) + # Ensure sufficient OPG allowance for Permit2 spending + llm.ensure_opg_approval(min_allowance=0.1) print("Testing Gemini tool calls...") print(f"Model: {og.TEE_LLM.GEMINI_2_5_FLASH_LITE}") diff --git a/integrationtest/llm/test_llm_chat.py b/integrationtest/llm/test_llm_chat.py index 632436ac..1241e016 100644 --- a/integrationtest/llm/test_llm_chat.py +++ b/integrationtest/llm/test_llm_chat.py @@ -20,6 +20,13 @@ "stateMutability": "nonpayable", "type": "function", }, + { + "inputs": [{"name": "account", "type": "address"}], + "name": "balanceOf", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, ] # Amount of OPG tokens to fund the test account with @@ -76,7 +83,7 @@ def _fund_account(funder_key: str, recipient_address: str): if opg_receipt.status != 1: raise RuntimeError(f"OPG transfer failed: {opg_hash.hex()}") - # Wait for the recipient balance to be visible on the RPC node + # Wait for the recipient balances to be visible on the RPC node for _ in range(5): if w3.eth.get_balance(recipient) > 0: break @@ -84,6 +91,13 @@ def _fund_account(funder_key: str, recipient_address: str): else: raise RuntimeError("Recipient ETH balance is still 0 after funding") + for _ in range(10): + if token.functions.balanceOf(recipient).call() > 0: + break + time.sleep(1) + else: + raise RuntimeError("Recipient OPG token balance is still 0 after funding") + @pytest.fixture(scope="module") def llm_client(): @@ -99,7 +113,7 @@ def llm_client(): print("Account funded with ETH and OPG") llm = og.LLM(private_key=account.key.hex()) - llm.ensure_opg_approval(opg_amount=OPG_FUND_AMOUNT) + llm.ensure_opg_approval(min_allowance=OPG_FUND_AMOUNT, approve_amount=OPG_FUND_AMOUNT) print("Permit2 approval complete") # Wait for the approval to propagate on-chain diff --git a/src/opengradient/__init__.py b/src/opengradient/__init__.py index a849d220..89d5ff08 100644 --- a/src/opengradient/__init__.py +++ b/src/opengradient/__init__.py @@ -26,7 +26,7 @@ llm = og.LLM(private_key="0x...") # One-time OPG token approval (idempotent -- skips if allowance is sufficient) -llm.ensure_opg_approval(opg_amount=5) +llm.ensure_opg_approval(min_allowance=5) # Chat with an LLM (TEE-verified) response = asyncio.run(llm.chat( diff --git a/src/opengradient/client/__init__.py b/src/opengradient/client/__init__.py index 8e1ebbe5..7206d385 100644 --- a/src/opengradient/client/__init__.py +++ b/src/opengradient/client/__init__.py @@ -17,7 +17,7 @@ # LLM inference (Base Sepolia OPG tokens) llm = og.LLM(private_key="0x...") -llm.ensure_opg_approval(opg_amount=5) +llm.ensure_opg_approval(min_allowance=5) result = await llm.chat(model=og.TEE_LLM.CLAUDE_HAIKU_4_5, messages=[...]) # On-chain model inference (OpenGradient testnet gas tokens) diff --git a/src/opengradient/client/llm.py b/src/opengradient/client/llm.py index 5a1e67a9..ed54fd99 100644 --- a/src/opengradient/client/llm.py +++ b/src/opengradient/client/llm.py @@ -59,8 +59,6 @@ class LLM: Before making LLM requests, ensure your wallet has approved sufficient OPG tokens for Permit2 spending by calling ``ensure_opg_approval``. - This only sends an on-chain transaction when the current allowance is - below the requested amount. Usage: # Via on-chain registry (default) @@ -69,8 +67,8 @@ class LLM: # Via hardcoded URL (development / self-hosted) llm = og.LLM.from_url(private_key="0x...", llm_server_url="https://1.2.3.4") - # One-time approval (idempotent — skips if allowance is already sufficient) - llm.ensure_opg_approval(opg_amount=5) + # Ensure sufficient OPG allowance (only sends tx when below threshold) + llm.ensure_opg_approval(min_allowance=5) result = await llm.chat(model=TEE_LLM.CLAUDE_HAIKU_4_5, messages=[...]) result = await llm.completion(model=TEE_LLM.CLAUDE_HAIKU_4_5, prompt="Hello") @@ -184,16 +182,28 @@ async def _call_with_tee_retry( # ── Public API ────────────────────────────────────────────────────── - def ensure_opg_approval(self, opg_amount: float) -> Permit2ApprovalResult: - """Ensure the Permit2 allowance for OPG is at least ``opg_amount``. + def ensure_opg_approval( + self, + min_allowance: float, + approve_amount: Optional[float] = None, + ) -> Permit2ApprovalResult: + """Ensure the Permit2 allowance stays above a minimum threshold. + + Only sends a transaction when the current allowance drops below + ``min_allowance``. When approval is needed, approves ``approve_amount`` + (defaults to ``2 * min_allowance``) to create a buffer that survives + multiple service restarts without re-approving. + + Best for backend servers that call this on startup:: - Checks the current Permit2 allowance for the wallet. If the allowance - is already >= the requested amount, returns immediately without sending - a transaction. Otherwise, sends an ERC-20 approve transaction. + llm.ensure_opg_approval(min_allowance=5.0, approve_amount=100.0) Args: - opg_amount: Minimum number of OPG tokens required (e.g. ``0.1`` - for 0.1 OPG). Must be at least 0.1 OPG. + min_allowance: The minimum acceptable allowance in OPG. Must be + at least 0.1 OPG. + approve_amount: The amount of OPG to approve when a transaction + is needed. Defaults to ``2 * min_allowance``. Must be + >= ``min_allowance``. Returns: Permit2ApprovalResult: Contains ``allowance_before``, @@ -201,12 +211,13 @@ def ensure_opg_approval(self, opg_amount: float) -> Permit2ApprovalResult: was needed). Raises: - ValueError: If the OPG amount is less than 0.1. + ValueError: If ``min_allowance`` is less than 0.1 or + ``approve_amount`` is less than ``min_allowance``. RuntimeError: If the approval transaction fails. """ - if opg_amount < 0.1: - raise ValueError("OPG amount must be at least 0.1.") - return ensure_opg_approval(self._wallet_account, opg_amount) + if min_allowance < 0.1: + raise ValueError("min_allowance must be at least 0.1.") + return ensure_opg_approval(self._wallet_account, min_allowance, approve_amount) async def completion( self, diff --git a/src/opengradient/client/opg_token.py b/src/opengradient/client/opg_token.py index af80783a..efcca7be 100644 --- a/src/opengradient/client/opg_token.py +++ b/src/opengradient/client/opg_token.py @@ -1,13 +1,17 @@ """OPG token Permit2 approval utilities for x402 payments.""" -from dataclasses import dataclass +import logging import time +from dataclasses import dataclass from typing import Optional from eth_account.account import LocalAccount from web3 import Web3 +from web3.types import ChecksumAddress from x402.mechanisms.evm.constants import PERMIT2_ADDRESS +logger = logging.getLogger(__name__) + BASE_OPG_ADDRESS = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F" BASE_SEPOLIA_RPC = "https://sepolia.base.org" APPROVAL_TX_TIMEOUT = 120 @@ -35,6 +39,13 @@ "stateMutability": "nonpayable", "type": "function", }, + { + "inputs": [{"name": "account", "type": "address"}], + "name": "balanceOf", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, ] @@ -53,42 +64,32 @@ class Permit2ApprovalResult: tx_hash: Optional[str] = None -def ensure_opg_approval(wallet_account: LocalAccount, opg_amount: float) -> Permit2ApprovalResult: - """Ensure the Permit2 allowance for OPG is at least ``opg_amount``. - - Checks the current Permit2 allowance for the wallet. If the allowance - is already >= the requested amount, returns immediately without sending - a transaction. Otherwise, sends an ERC-20 approve transaction. +def _send_approve_tx( + wallet_account: LocalAccount, + w3: Web3, + token, + owner: ChecksumAddress, + spender: ChecksumAddress, + amount_base: int, +) -> Permit2ApprovalResult: + """Send an ERC-20 approve transaction and wait for confirmation. Args: - wallet_account: The wallet account to check and approve from. - opg_amount: Minimum number of OPG tokens required (e.g. ``5.0`` - for 5 OPG). Converted to base units (18 decimals) internally. + wallet_account: The wallet to sign the transaction with. + w3: Web3 instance connected to the RPC. + token: The ERC-20 contract instance. + owner: Checksummed owner address. + spender: Checksummed spender (Permit2) address. + amount_base: The amount to approve in base units (18 decimals). Returns: - Permit2ApprovalResult: Contains ``allowance_before``, - ``allowance_after``, and ``tx_hash`` (None when no approval - was needed). + Permit2ApprovalResult with the before/after allowance and tx hash. Raises: - RuntimeError: If the approval transaction fails. + RuntimeError: If the transaction reverts or fails. """ - amount_base = int(opg_amount * 10**18) - - w3 = Web3(Web3.HTTPProvider(BASE_SEPOLIA_RPC)) - token = w3.eth.contract(address=Web3.to_checksum_address(BASE_OPG_ADDRESS), abi=ERC20_ABI) - owner = Web3.to_checksum_address(wallet_account.address) - spender = Web3.to_checksum_address(PERMIT2_ADDRESS) - allowance_before = token.functions.allowance(owner, spender).call() - # Only approve if the allowance is less than 50% of the requested amount - if allowance_before >= amount_base * 0.5: - return Permit2ApprovalResult( - allowance_before=allowance_before, - allowance_after=allowance_before, - ) - try: approve_fn = token.functions.approve(spender, amount_base) nonce = w3.eth.get_transaction_count(owner, "pending") @@ -133,3 +134,91 @@ def ensure_opg_approval(wallet_account: LocalAccount, opg_amount: float) -> Perm raise except Exception as e: raise RuntimeError(f"Failed to approve Permit2 for OPG: {e}") + + +def _get_web3_and_contract(): + """Create a Web3 instance and OPG token contract.""" + w3 = Web3(Web3.HTTPProvider(BASE_SEPOLIA_RPC)) + token = w3.eth.contract(address=Web3.to_checksum_address(BASE_OPG_ADDRESS), abi=ERC20_ABI) + spender = Web3.to_checksum_address(PERMIT2_ADDRESS) + return w3, token, spender + + +def ensure_opg_approval( + wallet_account: LocalAccount, + min_allowance: float, + approve_amount: Optional[float] = None, +) -> Permit2ApprovalResult: + """Ensure the Permit2 allowance stays above a minimum threshold. + + Only sends an approval transaction when the current allowance drops + below ``min_allowance``. When approval is needed, approves + ``approve_amount`` (defaults to ``2 * min_allowance``) to create a + buffer that survives multiple service restarts without re-approving. + + Best for backend servers that call this on startup:: + + # On startup — only sends a tx when allowance < 5 OPG, + # then approves 100 OPG so subsequent restarts are free. + result = ensure_opg_approval(wallet, min_allowance=5.0, approve_amount=100.0) + + Args: + wallet_account: The wallet account to check and approve from. + min_allowance: The minimum acceptable allowance in OPG. A + transaction is only sent when the current allowance is + strictly below this value. + approve_amount: The amount of OPG to approve when a transaction + is needed. Defaults to ``2 * min_allowance``. Must be + >= ``min_allowance``. + + Returns: + Permit2ApprovalResult: Contains ``allowance_before``, + ``allowance_after``, and ``tx_hash`` (None when no approval + was needed). + + Raises: + ValueError: If ``approve_amount`` is less than ``min_allowance``, + or if the wallet has zero OPG balance. + RuntimeError: If the approval transaction fails. + """ + if approve_amount is None: + approve_amount = min_allowance * 2 + if approve_amount < min_allowance: + raise ValueError(f"approve_amount ({approve_amount}) must be >= min_allowance ({min_allowance})") + + w3, token, spender = _get_web3_and_contract() + owner = Web3.to_checksum_address(wallet_account.address) + allowance_before = token.functions.allowance(owner, spender).call() + + min_base = int(min_allowance * 10**18) + approve_base = int(approve_amount * 10**18) + + if allowance_before >= min_base: + return Permit2ApprovalResult( + allowance_before=allowance_before, + allowance_after=allowance_before, + ) + + balance = token.functions.balanceOf(owner).call() + if balance == 0: + raise ValueError(f"Wallet {owner} has no OPG tokens. Fund the wallet before approving.") + elif min_base > balance: + raise ValueError( + f"Wallet {owner} has less OPG tokens than the minimum allowance ({min_base} < {balance}). " + f"Fund the wallet with at least {min_base / 10**18} OPG before approving." + ) + elif approve_base > balance: + logger.warning( + "Requested approve_amount (%.6f OPG) exceeds wallet balance (%.6f OPG), capping approval to wallet balance", + approve_amount, + balance / 10**18, + ) + approve_base = balance + + logger.info( + "Permit2 allowance below minimum threshold (%s < %s), approving %s base units", + allowance_before, + min_base, + approve_base, + ) + return _send_approve_tx(wallet_account, w3, token, owner, spender, approve_base) diff --git a/stresstest/llm.py b/stresstest/llm.py index f9652c19..a234cee5 100644 --- a/stresstest/llm.py +++ b/stresstest/llm.py @@ -13,7 +13,7 @@ async def main(private_key: str): llm = og.LLM(private_key=private_key) - llm.ensure_opg_approval(opg_amount=0.1) + llm.ensure_opg_approval(min_allowance=0.1) async def run_prompt(prompt: str): messages = [{"role": "user", "content": prompt}] diff --git a/tests/client_test.py b/tests/client_test.py index d7d70f8a..6829fc98 100644 --- a/tests/client_test.py +++ b/tests/client_test.py @@ -1,15 +1,11 @@ import json -import os -import sys from unittest.mock import MagicMock, mock_open, patch import pytest -sys.path.append(os.path.join(os.path.dirname(__file__), "..")) - -from src.opengradient.client.llm import LLM -from src.opengradient.client.model_hub import ModelHub -from src.opengradient.types import ( +from opengradient.client.llm import LLM +from opengradient.client.model_hub import ModelHub +from opengradient.types import ( StreamChunk, x402SettlementMode, ) @@ -23,9 +19,9 @@ def mock_tee_registry(): """Mock the TEE registry so LLM.__init__ doesn't need a live registry.""" with ( - patch("src.opengradient.client.llm.TEERegistry") as mock_tee_registry, + patch("opengradient.client.llm.TEERegistry") as mock_tee_registry, patch( - "src.opengradient.client.tee_connection.build_ssl_context_from_der", + "opengradient.client.tee_connection.build_ssl_context_from_der", return_value=MagicMock(), ), ): @@ -41,7 +37,7 @@ def mock_tee_registry(): @pytest.fixture def mock_web3(): """Create a mock Web3 instance for Alpha.""" - with patch("src.opengradient.client.alpha.Web3") as mock: + with patch("opengradient.client.alpha.Web3") as mock: mock_instance = MagicMock() mock.return_value = mock_instance mock.HTTPProvider.return_value = MagicMock() @@ -94,8 +90,8 @@ class TestAuthentication: def test_login_to_hub_success(self): """Test successful login to hub.""" with ( - patch("src.opengradient.client.model_hub._FIREBASE_CONFIG", {"apiKey": "fake"}), - patch("src.opengradient.client.model_hub.firebase") as mock_firebase, + patch("opengradient.client.model_hub._FIREBASE_CONFIG", {"apiKey": "fake"}), + patch("opengradient.client.model_hub.firebase") as mock_firebase, ): mock_auth = MagicMock() mock_auth.sign_in_with_email_and_password.return_value = { @@ -112,8 +108,8 @@ def test_login_to_hub_success(self): def test_login_to_hub_failure(self): """Test login failure raises exception.""" with ( - patch("src.opengradient.client.model_hub._FIREBASE_CONFIG", {"apiKey": "fake"}), - patch("src.opengradient.client.model_hub.firebase") as mock_firebase, + patch("opengradient.client.model_hub._FIREBASE_CONFIG", {"apiKey": "fake"}), + patch("opengradient.client.model_hub.firebase") as mock_firebase, ): mock_auth = MagicMock() mock_auth.sign_in_with_email_and_password.side_effect = Exception("Invalid credentials") diff --git a/tests/langchain_adapter_test.py b/tests/langchain_adapter_test.py index e651ab49..284fab69 100644 --- a/tests/langchain_adapter_test.py +++ b/tests/langchain_adapter_test.py @@ -1,6 +1,4 @@ import json -import os -import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -8,16 +6,14 @@ from langchain_core.messages.tool import ToolMessage from langchain_core.tools import tool -sys.path.append(os.path.join(os.path.dirname(__file__), "..")) - -from src.opengradient.agents.og_langchain import OpenGradientChatModel, _extract_content, _parse_tool_call -from src.opengradient.types import TEE_LLM, TextGenerationOutput, x402SettlementMode +from opengradient.agents.og_langchain import OpenGradientChatModel, _extract_content, _parse_tool_call +from opengradient.types import TEE_LLM, TextGenerationOutput, x402SettlementMode @pytest.fixture def mock_llm_client(): """Create a mock LLM instance.""" - with patch("src.opengradient.agents.og_langchain.LLM") as MockLLM: + with patch("opengradient.agents.og_langchain.LLM") as MockLLM: mock_instance = MagicMock() mock_instance.chat = AsyncMock() MockLLM.return_value = mock_instance diff --git a/tests/llm_test.py b/tests/llm_test.py index ce0cd48b..8e5aba28 100644 --- a/tests/llm_test.py +++ b/tests/llm_test.py @@ -13,8 +13,8 @@ import httpx import pytest -from src.opengradient.client.llm import LLM -from src.opengradient.types import TEE_LLM, x402SettlementMode +from opengradient.client.llm import LLM +from opengradient.types import TEE_LLM, x402SettlementMode # ── Fake HTTP transport ────────────────────────────────────────────── @@ -107,11 +107,11 @@ async def aread(self) -> bytes: # so LLM.__init__ runs its real code but gets our FakeHTTPClient. _PATCHES = { - "x402_httpx": "src.opengradient.client.tee_connection.x402HttpxClient", - "x402_client": "src.opengradient.client.llm.x402Client", - "signer": "src.opengradient.client.llm.EthAccountSigner", - "register_exact": "src.opengradient.client.llm.register_exact_evm_client", - "register_upto": "src.opengradient.client.llm.register_upto_evm_client", + "x402_httpx": "opengradient.client.tee_connection.x402HttpxClient", + "x402_client": "opengradient.client.llm.x402Client", + "signer": "opengradient.client.llm.EthAccountSigner", + "register_exact": "opengradient.client.llm.register_exact_evm_client", + "register_upto": "opengradient.client.llm.register_upto_evm_client", } @@ -488,17 +488,6 @@ async def test_tools_with_stream_falls_back_to_single_chunk(self, fake_http): assert chunks[0].choices[0].finish_reason == "tool_calls" -# ── ensure_opg_approval tests ──────────────────────────────────────── - - -class TestEnsureOpgApproval: - def test_rejects_amount_below_minimum(self, fake_http): - llm = _make_llm() - - with pytest.raises(ValueError, match="at least"): - llm.ensure_opg_approval(opg_amount=0.01) - - # ── Lifecycle tests ────────────────────────────────────────────────── diff --git a/tests/opg_token_test.py b/tests/opg_token_test.py index 0e44a072..b237ccd5 100644 --- a/tests/opg_token_test.py +++ b/tests/opg_token_test.py @@ -4,7 +4,6 @@ import pytest from opengradient.client.opg_token import ( - Permit2ApprovalResult, ensure_opg_approval, ) @@ -36,219 +35,162 @@ def mock_web3(monkeypatch): return mock_w3 -def _setup_allowance(mock_w3, allowance_value): - """Configure the mock contract to return a specific allowance.""" +def _setup_allowance(mock_w3, allowance_value, balance=None): + """Configure the mock contract to return a specific allowance and balance.""" contract = MagicMock() contract.functions.allowance.return_value.call.return_value = allowance_value + # Default balance to a large value so existing tests aren't affected + contract.functions.balanceOf.return_value.call.return_value = balance if balance is not None else int(1_000_000 * 10**18) mock_w3.eth.contract.return_value = contract return contract -class TestEnsureOpgApprovalSkips: - """Cases where the existing allowance is sufficient.""" +def _setup_approval_mocks(mock_web3, mock_wallet, contract): + """Set up common mocks for approval transactions.""" + approve_fn = MagicMock() + contract.functions.approve.return_value = approve_fn + approve_fn.estimate_gas.return_value = 50_000 + approve_fn.build_transaction.return_value = {"mock": "tx"} - def test_exact_allowance_skips_tx(self, mock_wallet, mock_web3): - """When allowance == requested amount, no transaction is sent.""" - amount = 5.0 - amount_base = int(amount * 10**18) - _setup_allowance(mock_web3, amount_base) + mock_web3.eth.get_transaction_count.return_value = 7 + mock_web3.eth.gas_price = 1_000_000_000 + mock_web3.eth.chain_id = 84532 - result = ensure_opg_approval(mock_wallet, amount) + signed = MagicMock() + signed.raw_transaction = b"\x00" + mock_wallet.sign_transaction.return_value = signed - assert result.allowance_before == amount_base - assert result.allowance_after == amount_base - assert result.tx_hash is None - - def test_excess_allowance_skips_tx(self, mock_wallet, mock_web3): - """When allowance > requested amount, no transaction is sent.""" - amount_base = int(5.0 * 10**18) - _setup_allowance(mock_web3, amount_base * 2) - - result = ensure_opg_approval(mock_wallet, 5.0) - - assert result.allowance_before == amount_base * 2 - assert result.tx_hash is None + tx_hash = MagicMock() + tx_hash.hex.return_value = "0xabc123" + mock_web3.eth.send_raw_transaction.return_value = tx_hash - def test_zero_amount_with_zero_allowance_skips(self, mock_wallet, mock_web3): - """Requesting 0 tokens with 0 allowance should skip (0 >= 0).""" - _setup_allowance(mock_web3, 0) + receipt = SimpleNamespace(status=1) + mock_web3.eth.wait_for_transaction_receipt.return_value = receipt - result = ensure_opg_approval(mock_wallet, 0.0) + return approve_fn, tx_hash - assert result.tx_hash is None +# ── ensure_opg_approval tests ────────────────────────────────────── -class TestEnsureOpgApprovalSendsTx: - """Cases where allowance is insufficient and a transaction is sent.""" - def test_approval_sent_when_allowance_insufficient(self, mock_wallet, mock_web3): - """When allowance < requested, an approve tx is sent.""" - amount = 5.0 - amount_base = int(amount * 10**18) - contract = _setup_allowance(mock_web3, 0) +class TestEnsureOpgAllowanceSkips: + """Cases where the existing allowance exceeds the minimum threshold.""" - # Set up the approval transaction mocks - approve_fn = MagicMock() - contract.functions.approve.return_value = approve_fn - approve_fn.estimate_gas.return_value = 50_000 - approve_fn.build_transaction.return_value = {"mock": "tx"} + def test_above_minimum_skips(self, mock_wallet, mock_web3): + """When allowance >= min_allowance, no transaction is sent.""" + min_base = int(5.0 * 10**18) + _setup_allowance(mock_web3, min_base * 2) - mock_web3.eth.get_transaction_count.return_value = 7 - mock_web3.eth.gas_price = 1_000_000_000 - mock_web3.eth.chain_id = 84532 + result = ensure_opg_approval(mock_wallet, min_allowance=5.0) - signed = MagicMock() - signed.raw_transaction = b"\x00" - mock_wallet.sign_transaction.return_value = signed - - tx_hash = MagicMock() - tx_hash.hex.return_value = "0xabc123" - mock_web3.eth.send_raw_transaction.return_value = tx_hash + assert result.tx_hash is None + assert result.allowance_before == min_base * 2 - receipt = SimpleNamespace(status=1) - mock_web3.eth.wait_for_transaction_receipt.return_value = receipt + def test_exact_minimum_skips(self, mock_wallet, mock_web3): + """When allowance == min_allowance, no transaction is sent.""" + min_base = int(5.0 * 10**18) + _setup_allowance(mock_web3, min_base) - # After approval the allowance call returns the new value - contract.functions.allowance.return_value.call.side_effect = [0, amount_base] + result = ensure_opg_approval(mock_wallet, min_allowance=5.0) - result = ensure_opg_approval(mock_wallet, amount) + assert result.tx_hash is None - assert result.allowance_before == 0 - assert result.allowance_after == amount_base - assert result.tx_hash == "0xabc123" - # Verify the approve was called with the right amount - contract.functions.approve.assert_called_once() - args = contract.functions.approve.call_args[0] - assert args[1] == amount_base +class TestEnsureOpgAllowanceSendsTx: + """Cases where allowance is below the minimum and a tx is needed.""" - def test_gas_estimate_has_20_percent_buffer(self, mock_wallet, mock_web3): - """Gas limit should be estimatedGas * 1.2.""" + def test_approves_default_with_greater_amount(self, mock_wallet, mock_web3): + """When no approve_amount given, approves 2x min_allowance.""" contract = _setup_allowance(mock_web3, 0) + _setup_approval_mocks(mock_web3, mock_wallet, contract) - approve_fn = MagicMock() - contract.functions.approve.return_value = approve_fn - approve_fn.estimate_gas.return_value = 50_000 - - mock_web3.eth.get_transaction_count.return_value = 0 - mock_web3.eth.gas_price = 1_000_000_000 - mock_web3.eth.chain_id = 84532 - - signed = MagicMock() - signed.raw_transaction = b"\x00" - mock_wallet.sign_transaction.return_value = signed + approve_base = int(10.0 * 10**18) # 2x of 5.0 + contract.functions.allowance.return_value.call.side_effect = [0, 0, approve_base] - tx_hash = MagicMock() - tx_hash.hex.return_value = "0x0" - mock_web3.eth.send_raw_transaction.return_value = tx_hash - mock_web3.eth.wait_for_transaction_receipt.return_value = SimpleNamespace(status=1) + result = ensure_opg_approval(mock_wallet, min_allowance=5.0) - contract.functions.allowance.return_value.call.side_effect = [0, int(1 * 10**18)] - - ensure_opg_approval(mock_wallet, 1.0) - - tx_dict = approve_fn.build_transaction.call_args[0][0] - assert tx_dict["gas"] == int(50_000 * 1.2) + assert result.tx_hash == "0xabc123" + # Verify approve was called with 2x min_allowance + args = contract.functions.approve.call_args[0] + assert args[1] == approve_base - def test_waits_for_allowance_update_after_receipt(self, mock_wallet, mock_web3, monkeypatch): - """After a successful receipt, poll allowance until the updated value is visible.""" - monkeypatch.setattr("opengradient.client.opg_token.ALLOWANCE_POLL_INTERVAL", 0) + def test_approves_custom_amount(self, mock_wallet, mock_web3): + """When approve_amount is specified, uses that exact amount.""" contract = _setup_allowance(mock_web3, 0) + _setup_approval_mocks(mock_web3, mock_wallet, contract) - approve_fn = MagicMock() - contract.functions.approve.return_value = approve_fn - approve_fn.estimate_gas.return_value = 50_000 - approve_fn.build_transaction.return_value = {"mock": "tx"} + approve_base = int(100.0 * 10**18) + contract.functions.allowance.return_value.call.side_effect = [0, 0, approve_base] - mock_web3.eth.get_transaction_count.return_value = 0 - mock_web3.eth.gas_price = 1_000_000_000 - mock_web3.eth.chain_id = 84532 + result = ensure_opg_approval(mock_wallet, min_allowance=5.0, approve_amount=100.0) - signed = MagicMock() - signed.raw_transaction = b"\x00" - mock_wallet.sign_transaction.return_value = signed - - tx_hash = MagicMock() - tx_hash.hex.return_value = "0xconfirmed" - mock_web3.eth.send_raw_transaction.return_value = tx_hash - mock_web3.eth.wait_for_transaction_receipt.return_value = SimpleNamespace(status=1, blockNumber=100) - - amount_base = int(1.0 * 10**18) - contract.functions.allowance.return_value.call.side_effect = [0, 0, amount_base] - - result = ensure_opg_approval(mock_wallet, 1.0) - - assert result.allowance_before == 0 - assert result.allowance_after == amount_base - assert result.tx_hash == "0xconfirmed" + assert result.tx_hash == "0xabc123" + args = contract.functions.approve.call_args[0] + assert args[1] == approve_base + def test_no_tx_on_restart_when_above_min(self, mock_wallet, mock_web3): + """Simulates server restart: allowance is above min but below approve_amount.""" + # After first approval of 100 OPG, some was consumed leaving 60 OPG. + # min_allowance=5.0 so no tx should be sent. + remaining = int(60.0 * 10**18) + _setup_allowance(mock_web3, remaining) -class TestEnsureOpgApprovalErrors: - """Error handling paths.""" + result = ensure_opg_approval(mock_wallet, min_allowance=5.0, approve_amount=100.0) - def test_reverted_tx_raises(self, mock_wallet, mock_web3): - """A reverted transaction raises RuntimeError.""" - contract = _setup_allowance(mock_web3, 0) + assert result.tx_hash is None + assert result.allowance_before == remaining - approve_fn = MagicMock() - contract.functions.approve.return_value = approve_fn - approve_fn.estimate_gas.return_value = 50_000 - mock_web3.eth.get_transaction_count.return_value = 0 - mock_web3.eth.gas_price = 1_000_000_000 - mock_web3.eth.chain_id = 84532 +class TestEnsureOpgAllowanceBalanceCheck: + """Balance-aware approval capping.""" - signed = MagicMock() - signed.raw_transaction = b"\x00" - mock_wallet.sign_transaction.return_value = signed + def test_approve_amount_capped_to_balance(self, mock_wallet, mock_web3): + """When approve_amount > balance >= min_allowance, cap to balance.""" + balance = int(0.1 * 10**18) + contract = _setup_allowance(mock_web3, 0, balance=balance) + _setup_approval_mocks(mock_web3, mock_wallet, contract) - tx_hash = MagicMock() - tx_hash.hex.return_value = "0xfailed" - mock_web3.eth.send_raw_transaction.return_value = tx_hash - mock_web3.eth.wait_for_transaction_receipt.return_value = SimpleNamespace(status=0) + # allowance calls: 1st for the check, 2nd in _send_approve_tx before, 3rd in post-tx poll + contract.functions.allowance.return_value.call.side_effect = [0, 0, balance] - with pytest.raises(RuntimeError, match="reverted"): - ensure_opg_approval(mock_wallet, 5.0) + result = ensure_opg_approval(mock_wallet, min_allowance=0.1) - def test_generic_exception_wrapped(self, mock_wallet, mock_web3): - """Non-RuntimeError exceptions are wrapped in RuntimeError.""" - contract = _setup_allowance(mock_web3, 0) + # Default approve_amount would be 0.2, but balance is only 0.1 — capped + args = contract.functions.approve.call_args[0] + assert args[1] == balance + assert result.tx_hash == "0xabc123" - approve_fn = MagicMock() - contract.functions.approve.return_value = approve_fn - approve_fn.estimate_gas.side_effect = ConnectionError("RPC unavailable") + def test_zero_balance_raises(self, mock_wallet, mock_web3): + """When balance is 0, raises ValueError.""" + _setup_allowance(mock_web3, 0, balance=0) - mock_web3.eth.get_transaction_count.return_value = 0 + with pytest.raises(ValueError, match="has no OPG tokens"): + ensure_opg_approval(mock_wallet, min_allowance=0.1) - with pytest.raises(RuntimeError, match="Failed to approve Permit2 for OPG"): - ensure_opg_approval(mock_wallet, 5.0) + def test_no_cap_when_balance_sufficient(self, mock_wallet, mock_web3): + """When balance >= approve_amount, no capping occurs.""" + balance = int(1.0 * 10**18) + approve_base = int(0.2 * 10**18) + contract = _setup_allowance(mock_web3, 0, balance=balance) + _setup_approval_mocks(mock_web3, mock_wallet, contract) - def test_opengradient_error_not_double_wrapped(self, mock_wallet, mock_web3): - """RuntimeError raised inside the try block should propagate as-is.""" - contract = _setup_allowance(mock_web3, 0) + contract.functions.allowance.return_value.call.side_effect = [0, 0, approve_base] - approve_fn = MagicMock() - contract.functions.approve.return_value = approve_fn - approve_fn.estimate_gas.return_value = 50_000 + result = ensure_opg_approval(mock_wallet, min_allowance=0.1) - mock_web3.eth.get_transaction_count.return_value = 0 - mock_web3.eth.gas_price = 1_000_000_000 - mock_web3.eth.chain_id = 84532 + args = contract.functions.approve.call_args[0] + assert args[1] == approve_base - signed = MagicMock() - signed.raw_transaction = b"\x00" - mock_wallet.sign_transaction.return_value = signed - tx_hash = MagicMock() - tx_hash.hex.return_value = "0xfailed" - mock_web3.eth.send_raw_transaction.return_value = tx_hash - mock_web3.eth.wait_for_transaction_receipt.return_value = SimpleNamespace(status=0) +class TestEnsureOpgAllowanceValidation: + """Input validation.""" - with pytest.raises(RuntimeError, match="reverted") as exc_info: - ensure_opg_approval(mock_wallet, 5.0) + def test_approve_amount_less_than_min_raises(self, mock_wallet, mock_web3): + """approve_amount < min_allowance should raise ValueError.""" + _setup_allowance(mock_web3, 0) - # Should be the original error, not wrapped - assert "Failed to approve" not in str(exc_info.value) + with pytest.raises(ValueError, match="approve_amount.*must be >= min_allowance"): + ensure_opg_approval(mock_wallet, min_allowance=10.0, approve_amount=5.0) class TestAmountConversion: @@ -259,7 +201,7 @@ def test_fractional_amount(self, mock_wallet, mock_web3): expected_base = int(0.5 * 10**18) _setup_allowance(mock_web3, expected_base) - result = ensure_opg_approval(mock_wallet, 0.5) + result = ensure_opg_approval(mock_wallet, min_allowance=0.5) assert result.allowance_before == expected_base assert result.tx_hash is None @@ -269,21 +211,7 @@ def test_large_amount(self, mock_wallet, mock_web3): expected_base = int(1000.0 * 10**18) _setup_allowance(mock_web3, expected_base) - result = ensure_opg_approval(mock_wallet, 1000.0) + result = ensure_opg_approval(mock_wallet, min_allowance=1000.0) assert result.allowance_before == expected_base assert result.tx_hash is None - - -class TestPermit2ApprovalResult: - """Dataclass behavior.""" - - def test_default_tx_hash_is_none(self): - result = Permit2ApprovalResult(allowance_before=100, allowance_after=200) - assert result.tx_hash is None - - def test_fields(self): - result = Permit2ApprovalResult(allowance_before=0, allowance_after=500, tx_hash="0xabc") - assert result.allowance_before == 0 - assert result.allowance_after == 500 - assert result.tx_hash == "0xabc" diff --git a/tests/tee_connection_test.py b/tests/tee_connection_test.py index 2d54c771..20d02f7c 100644 --- a/tests/tee_connection_test.py +++ b/tests/tee_connection_test.py @@ -15,11 +15,11 @@ from cryptography.x509.oid import NameOID from x402 import x402Client -from src.opengradient.client.tee_connection import ( +from opengradient.client.tee_connection import ( ActiveTEE, RegistryTEEConnection, ) -from src.opengradient.client.tee_registry import build_ssl_context_from_der +from opengradient.client.tee_registry import build_ssl_context_from_der # ── Helpers ────────────────────────────────────────────────────────── @@ -44,11 +44,11 @@ def _make_registry_connection(*, registry=None, http_factory=None): factory = http_factory or FakeHTTPClient with ( patch( - "src.opengradient.client.tee_connection.x402HttpxClient", + "opengradient.client.tee_connection.x402HttpxClient", side_effect=factory, ), patch( - "src.opengradient.client.tee_connection.build_ssl_context_from_der", + "opengradient.client.tee_connection.build_ssl_context_from_der", return_value=MagicMock(spec=ssl.SSLContext), ), ): @@ -122,7 +122,7 @@ async def test_resolve_none_raises(self): mock_reg.get_llm_tee.return_value = None with patch( - "src.opengradient.client.tee_connection.x402HttpxClient", + "opengradient.client.tee_connection.x402HttpxClient", side_effect=FakeHTTPClient, ): with pytest.raises(ValueError, match="No active LLM proxy TEE"): @@ -133,7 +133,7 @@ async def test_resolve_exception_wraps_in_runtime_error(self): mock_reg.get_llm_tee.side_effect = Exception("rpc down") with patch( - "src.opengradient.client.tee_connection.x402HttpxClient", + "opengradient.client.tee_connection.x402HttpxClient", side_effect=FakeHTTPClient, ): with pytest.raises(RuntimeError, match="Failed to fetch LLM TEE"): @@ -157,11 +157,11 @@ async def test_builds_ssl_context_from_der(self): with ( patch( - "src.opengradient.client.tee_connection.x402HttpxClient", + "opengradient.client.tee_connection.x402HttpxClient", side_effect=FakeHTTPClient, ), patch( - "src.opengradient.client.tee_connection.build_ssl_context_from_der", + "opengradient.client.tee_connection.build_ssl_context_from_der", return_value=mock_ssl_ctx, ) as mock_build, ): @@ -187,11 +187,11 @@ def make_client(*args, **kwargs): with ( patch( - "src.opengradient.client.tee_connection.x402HttpxClient", + "opengradient.client.tee_connection.x402HttpxClient", side_effect=make_client, ), patch( - "src.opengradient.client.tee_connection.build_ssl_context_from_der", + "opengradient.client.tee_connection.build_ssl_context_from_der", return_value=MagicMock(spec=ssl.SSLContext), ), ): @@ -211,7 +211,7 @@ async def test_reconnect_swallows_close_failure(self): conn.get().http_client.aclose = AsyncMock(side_effect=OSError("already closed")) with patch( - "src.opengradient.client.tee_connection.x402HttpxClient", + "opengradient.client.tee_connection.x402HttpxClient", side_effect=FakeHTTPClient, ): await conn.reconnect() # should not raise @@ -229,12 +229,16 @@ def slow_connect(self): mock_reg = _mock_registry_with_tee() conn = _make_registry_connection(registry=mock_reg) - with patch.object(RegistryTEEConnection, "_connect", slow_connect), patch( - "src.opengradient.client.tee_connection.build_ssl_context_from_der", - return_value=MagicMock(spec=ssl.SSLContext), - ), patch( - "src.opengradient.client.tee_connection.x402HttpxClient", - side_effect=FakeHTTPClient, + with ( + patch.object(RegistryTEEConnection, "_connect", slow_connect), + patch( + "opengradient.client.tee_connection.build_ssl_context_from_der", + return_value=MagicMock(spec=ssl.SSLContext), + ), + patch( + "opengradient.client.tee_connection.x402HttpxClient", + side_effect=FakeHTTPClient, + ), ): await asyncio.gather(conn.reconnect(), conn.reconnect()) @@ -283,7 +287,7 @@ async def test_refresh_loop_skips_when_tee_still_active(self): with patch.object(conn, "reconnect", new_callable=AsyncMock) as mock_reconnect: with patch( - "src.opengradient.client.tee_connection.asyncio.sleep", + "opengradient.client.tee_connection.asyncio.sleep", side_effect=[None, asyncio.CancelledError], ): with pytest.raises(asyncio.CancelledError): @@ -301,7 +305,7 @@ async def test_refresh_loop_reconnects_when_tee_gone(self): with patch.object(conn, "reconnect", new_callable=AsyncMock) as mock_reconnect: with patch( - "src.opengradient.client.tee_connection.asyncio.sleep", + "opengradient.client.tee_connection.asyncio.sleep", side_effect=[None, asyncio.CancelledError], ): with pytest.raises(asyncio.CancelledError): @@ -319,7 +323,7 @@ async def test_refresh_loop_survives_registry_error(self): conn = _make_registry_connection(registry=mock_reg) with patch( - "src.opengradient.client.tee_connection.asyncio.sleep", + "opengradient.client.tee_connection.asyncio.sleep", side_effect=[None, None], ): with pytest.raises(asyncio.CancelledError): diff --git a/tests/tee_registry_test.py b/tests/tee_registry_test.py index 189ad26c..faa14205 100644 --- a/tests/tee_registry_test.py +++ b/tests/tee_registry_test.py @@ -1,13 +1,9 @@ -import os import ssl -import sys from unittest.mock import MagicMock, patch import pytest -sys.path.append(os.path.join(os.path.dirname(__file__), "..")) - -from src.opengradient.client.tee_registry import ( +from opengradient.client.tee_registry import ( TEE_TYPE_LLM_PROXY, TEE_TYPE_VALIDATOR, TEERegistry, @@ -69,8 +65,8 @@ def _make_self_signed_der() -> bytes: def mock_contract(): """Create a TEERegistry with a mocked Web3 contract.""" with ( - patch("src.opengradient.client.tee_registry.Web3") as mock_web3_cls, - patch("src.opengradient.client.tee_registry.get_abi") as mock_get_abi, + patch("opengradient.client.tee_registry.Web3") as mock_web3_cls, + patch("opengradient.client.tee_registry.get_abi") as mock_get_abi, ): mock_get_abi.return_value = [] mock_web3 = MagicMock() diff --git a/tutorials/01-verifiable-ai-agent.md b/tutorials/01-verifiable-ai-agent.md index 1ae369eb..b7737a86 100644 --- a/tutorials/01-verifiable-ai-agent.md +++ b/tutorials/01-verifiable-ai-agent.md @@ -37,8 +37,8 @@ export OG_PRIVATE_KEY="0x..." Before making any LLM calls, you need to approve OPG token spending for the x402 payment protocol. The `ensure_opg_approval` method checks your wallet's current -Permit2 allowance and only sends an on-chain transaction if the allowance is below -the requested amount -- so it's safe to call every time. +Permit2 allowance and only sends an on-chain transaction if the allowance drops +below the threshold -- so it's safe to call every time. ```python import os @@ -46,9 +46,9 @@ import opengradient as og private_key = os.environ["OG_PRIVATE_KEY"] -# Approve OPG spending for x402 payments (idempotent -- skips if already approved). +# Ensure sufficient OPG allowance for x402 payments (only sends tx when below threshold). llm_client = og.LLM(private_key=private_key) -llm_client.ensure_opg_approval(opg_amount=5) +llm_client.ensure_opg_approval(min_allowance=5) # Create the LangChain chat model backed by OpenGradient TEE. # The adapter creates its own internal LLM client. The approval above applies @@ -287,9 +287,9 @@ SAMPLE_PRICES = { # ── Clients ─────────────────────────────────────────────────────────────── private_key = os.environ["OG_PRIVATE_KEY"] -# Approve OPG spending for x402 payments (idempotent -- skips if already approved). +# Ensure sufficient OPG allowance for x402 payments (only sends tx when below threshold). llm_client = og.LLM(private_key=private_key) -llm_client.ensure_opg_approval(opg_amount=5) +llm_client.ensure_opg_approval(min_allowance=5) # Alpha client for on-chain model inference. alpha = og.Alpha(private_key=private_key) diff --git a/tutorials/02-streaming-multi-provider.md b/tutorials/02-streaming-multi-provider.md index 57ee4ce7..e941137b 100644 --- a/tutorials/02-streaming-multi-provider.md +++ b/tutorials/02-streaming-multi-provider.md @@ -35,9 +35,9 @@ export OG_PRIVATE_KEY="0x..." ## Step 1: Basic Non-Streaming Chat Start with the simplest possible call -- send a message and get a response. Before -making any LLM calls, approve OPG token spending for the x402 payment protocol using -`ensure_opg_approval`. This is idempotent -- it checks the current Permit2 allowance -and only sends a transaction if the allowance is below the requested amount. +making any LLM calls, ensure sufficient OPG token allowance for the x402 payment +protocol using `ensure_opg_approval`. This only sends a transaction when the +current allowance drops below the threshold. ```python import asyncio @@ -52,8 +52,8 @@ if not private_key: llm = og.LLM(private_key=private_key) -# Approve OPG spending for x402 payments (one-time, idempotent). -llm.ensure_opg_approval(opg_amount=5) +# Ensure sufficient OPG allowance for x402 payments (only sends tx when below threshold). +llm.ensure_opg_approval(min_allowance=5) async def main(): result = await llm.chat( @@ -273,8 +273,8 @@ if not private_key: llm = og.LLM(private_key=private_key) -# Approve OPG spending for x402 payments (idempotent -- skips if already approved). -llm.ensure_opg_approval(opg_amount=5) +# Ensure sufficient OPG allowance for x402 payments (only sends tx when below threshold). +llm.ensure_opg_approval(min_allowance=5) PROMPT = "Explain what a Trusted Execution Environment is in two sentences." diff --git a/tutorials/03-verified-tool-calling.md b/tutorials/03-verified-tool-calling.md index 86ea1198..8ddead38 100644 --- a/tutorials/03-verified-tool-calling.md +++ b/tutorials/03-verified-tool-calling.md @@ -35,10 +35,9 @@ export OG_PRIVATE_KEY="0x..." ## Step 1: Initialize the Client -Before making any LLM calls, approve OPG token spending for the x402 payment -protocol. The `ensure_opg_approval` method is idempotent -- it checks the current -Permit2 allowance and only sends a transaction if the allowance is below the -requested amount. +Before making any LLM calls, ensure sufficient OPG token allowance for the x402 +payment protocol. The `ensure_opg_approval` method only sends a transaction when +the current allowance drops below the threshold. ```python import json @@ -54,8 +53,8 @@ if not private_key: llm = og.LLM(private_key=private_key) -# Approve OPG spending for x402 payments (one-time, idempotent). -llm.ensure_opg_approval(opg_amount=5) +# Ensure sufficient OPG allowance for x402 payments (only sends tx when below threshold). +llm.ensure_opg_approval(min_allowance=5) ``` ## Step 2: Define Local Tool Implementations @@ -317,8 +316,8 @@ if not private_key: llm = og.LLM(private_key=private_key) -# Approve OPG spending for x402 payments (idempotent -- skips if already approved). -llm.ensure_opg_approval(opg_amount=5) +# Ensure sufficient OPG allowance for x402 payments (only sends tx when below threshold). +llm.ensure_opg_approval(min_allowance=5) # ── Mock data ───────────────────────────────────────────────────────────── PORTFOLIO = {"ETH": {"amount": 5.0, "avg_cost": 1950.00},