From 3d353961f9ea432c660958b28b29e626b7e14b55 Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Wed, 18 Feb 2026 14:27:04 +0100 Subject: [PATCH 01/10] permit logic --- examples/run_x402_llm.py | 3 +- examples/run_x402_llm_stream.py | 3 +- examples/x402_permit2.py | 5 +- src/opengradient/client/llm.py | 27 ++++-- src/opengradient/client/opg_token.py | 120 +++++++++++++++++++++++++++ 5 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 src/opengradient/client/opg_token.py diff --git a/examples/run_x402_llm.py b/examples/run_x402_llm.py index d85e55e..74af94c 100644 --- a/examples/run_x402_llm.py +++ b/examples/run_x402_llm.py @@ -1,13 +1,12 @@ import os import opengradient as og -from x402_permit2 import check_permit2_approval client = og.Client( private_key=os.environ.get("OG_PRIVATE_KEY"), ) -check_permit2_approval(client.wallet_address) +client.llm.approve_opg(opg_amount=5) messages = [ {"role": "user", "content": "What is Python?"}, diff --git a/examples/run_x402_llm_stream.py b/examples/run_x402_llm_stream.py index 3b7d6da..c057f79 100644 --- a/examples/run_x402_llm_stream.py +++ b/examples/run_x402_llm_stream.py @@ -1,13 +1,12 @@ import os import opengradient as og -from x402_permit2 import check_permit2_approval client = og.Client( private_key=os.environ.get("OG_PRIVATE_KEY"), ) -check_permit2_approval(client.wallet_address) +client.llm.approve_opg(opg_amount=5) messages = [ {"role": "user", "content": "What is Python?"}, diff --git a/examples/x402_permit2.py b/examples/x402_permit2.py index 6d921d8..7b46e0f 100644 --- a/examples/x402_permit2.py +++ b/examples/x402_permit2.py @@ -2,9 +2,10 @@ import os from typing import Optional -import opengradient as og -from x402v2.mechanisms.evm.constants import PERMIT2_ADDRESS from web3 import Web3 +from x402v2.mechanisms.evm.constants import PERMIT2_ADDRESS + +import opengradient as og BASE_OPG_ADDRESS = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F" BASE_SEPOLIA_RPC = "https://sepolia.base.org" diff --git a/src/opengradient/client/llm.py b/src/opengradient/client/llm.py index 8f000eb..5772874 100644 --- a/src/opengradient/client/llm.py +++ b/src/opengradient/client/llm.py @@ -2,22 +2,19 @@ import asyncio import json -from typing import Dict, List, Optional, Union, AsyncGenerator +from typing import AsyncGenerator, Dict, List, Optional, Union import httpx from eth_account.account import LocalAccount from x402v2 import x402Client as x402Clientv2 -from x402v2.http import x402HTTPClient as x402HTTPClientv2 from x402v2.http.clients import x402HttpxClient as x402HttpxClientv2 from x402v2.mechanisms.evm import EthAccountSigner as EthAccountSignerv2 -from x402v2.mechanisms.evm.exact import ExactEvmServerScheme as ExactEvmServerSchemev2 -from x402v2.mechanisms.evm.upto import UptoEvmServerScheme as UptoEvmServerSchemev2 from x402v2.mechanisms.evm.exact.register import register_exact_evm_client as register_exact_evm_clientv2 from x402v2.mechanisms.evm.upto.register import register_upto_evm_client as register_upto_evm_clientv2 -from eth_account import Account from ..types import TEE_LLM, StreamChunk, TextGenerationOutput, TextGenerationStream, x402SettlementMode from .exceptions import OpenGradientError +from .opg_token import Permit2ApprovalResult, approve_opg X402_PROCESSING_HASH_HEADER = "x-processing-hash" X402_PLACEHOLDER_API_KEY = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -56,6 +53,26 @@ def __init__(self, wallet_account: LocalAccount, og_llm_server_url: str, og_llm_ self._og_llm_server_url = og_llm_server_url self._og_llm_streaming_server_url = og_llm_streaming_server_url + def approve_opg(self, opg_amount: float) -> Permit2ApprovalResult: + """Approve OPG tokens for Permit2 spending on Base Sepolia. + + Checks the current Permit2 allowance for the wallet. If the allowance + is zero, automatically sends an ERC-20 approve transaction. + + Args: + opg_amount: Number of OPG tokens to approve (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). + + Raises: + OpenGradientError: If the approval transaction fails. + """ + return approve_opg(self._wallet_account, opg_amount) + def completion( self, model: TEE_LLM, diff --git a/src/opengradient/client/opg_token.py b/src/opengradient/client/opg_token.py new file mode 100644 index 0000000..3b89c41 --- /dev/null +++ b/src/opengradient/client/opg_token.py @@ -0,0 +1,120 @@ +"""OPG token Permit2 approval utilities for x402 payments.""" + +from dataclasses import dataclass +from typing import Optional + +from eth_account.account import LocalAccount +from web3 import Web3 +from x402v2.mechanisms.evm.constants import PERMIT2_ADDRESS + +from .exceptions import OpenGradientError + +BASE_OPG_ADDRESS = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F" +BASE_SEPOLIA_RPC = "https://sepolia.base.org" + +ERC20_ABI = [ + { + "inputs": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + ], + "name": "allowance", + "outputs": [{"name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, + { + "inputs": [ + {"name": "spender", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "name": "approve", + "outputs": [{"name": "", "type": "bool"}], + "stateMutability": "nonpayable", + "type": "function", + }, +] + + +@dataclass +class 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. + """ + + allowance_before: int + allowance_after: int + tx_hash: Optional[str] = None + + +def approve_opg(wallet_account: LocalAccount, opg_amount: float) -> Permit2ApprovalResult: + """Approve OPG tokens for Permit2 spending on Base Sepolia. + + Checks the current Permit2 allowance for the wallet. If the allowance + is zero, automatically sends an ERC-20 approve transaction. + + Args: + wallet_account: The wallet account to check and approve from. + opg_amount: Number of OPG tokens to approve (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). + + Raises: + OpenGradientError: If the approval transaction 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() + + if allowance_before > 0: + 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") + estimated_gas = approve_fn.estimate_gas({"from": owner}) + + tx = approve_fn.build_transaction( + { + "from": owner, + "nonce": nonce, + "gas": int(estimated_gas * 1.2), + "gasPrice": w3.eth.gas_price, + "chainId": w3.eth.chain_id, + } + ) + + signed = wallet_account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + + if receipt.status != 1: + raise OpenGradientError(f"Permit2 approval transaction reverted: {tx_hash.hex()}") + + allowance_after = token.functions.allowance(owner, spender).call() + + return Permit2ApprovalResult( + allowance_before=allowance_before, + allowance_after=allowance_after, + tx_hash=tx_hash.hex(), + ) + except OpenGradientError: + raise + except Exception as e: + raise OpenGradientError(f"Failed to approve Permit2 for OPG: {e}") From 6a87316c6b210648cfb7a54278c5472b2941ce28 Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Wed, 18 Feb 2026 14:31:49 +0100 Subject: [PATCH 02/10] idempotent function --- examples/run_x402_llm.py | 2 +- examples/run_x402_llm_stream.py | 2 +- src/opengradient/client/llm.py | 15 ++++++++------- src/opengradient/client/opg_token.py | 13 +++++++------ 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/examples/run_x402_llm.py b/examples/run_x402_llm.py index 74af94c..2287285 100644 --- a/examples/run_x402_llm.py +++ b/examples/run_x402_llm.py @@ -6,7 +6,7 @@ private_key=os.environ.get("OG_PRIVATE_KEY"), ) -client.llm.approve_opg(opg_amount=5) +client.llm.ensure_opg_approval(opg_amount=5) messages = [ {"role": "user", "content": "What is Python?"}, diff --git a/examples/run_x402_llm_stream.py b/examples/run_x402_llm_stream.py index c057f79..6abefe5 100644 --- a/examples/run_x402_llm_stream.py +++ b/examples/run_x402_llm_stream.py @@ -6,7 +6,7 @@ private_key=os.environ.get("OG_PRIVATE_KEY"), ) -client.llm.approve_opg(opg_amount=5) +client.llm.ensure_opg_approval(opg_amount=5) messages = [ {"role": "user", "content": "What is Python?"}, diff --git a/src/opengradient/client/llm.py b/src/opengradient/client/llm.py index 5772874..815072d 100644 --- a/src/opengradient/client/llm.py +++ b/src/opengradient/client/llm.py @@ -14,7 +14,7 @@ from ..types import TEE_LLM, StreamChunk, TextGenerationOutput, TextGenerationStream, x402SettlementMode from .exceptions import OpenGradientError -from .opg_token import Permit2ApprovalResult, approve_opg +from .opg_token import Permit2ApprovalResult, ensure_opg_approval X402_PROCESSING_HASH_HEADER = "x-processing-hash" X402_PLACEHOLDER_API_KEY = "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -53,15 +53,16 @@ def __init__(self, wallet_account: LocalAccount, og_llm_server_url: str, og_llm_ self._og_llm_server_url = og_llm_server_url self._og_llm_streaming_server_url = og_llm_streaming_server_url - def approve_opg(self, opg_amount: float) -> Permit2ApprovalResult: - """Approve OPG tokens for Permit2 spending on Base Sepolia. + def ensure_opg_approval(self, 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 zero, automatically sends an ERC-20 approve transaction. + is already >= the requested amount, returns immediately without sending + a transaction. Otherwise, sends an ERC-20 approve transaction. Args: - opg_amount: Number of OPG tokens to approve (e.g. ``5.0`` for - 5 OPG). Converted to base units (18 decimals) internally. + 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``, @@ -71,7 +72,7 @@ def approve_opg(self, opg_amount: float) -> Permit2ApprovalResult: Raises: OpenGradientError: If the approval transaction fails. """ - return approve_opg(self._wallet_account, opg_amount) + return ensure_opg_approval(self._wallet_account, opg_amount) def completion( self, diff --git a/src/opengradient/client/opg_token.py b/src/opengradient/client/opg_token.py index 3b89c41..ee7b3b1 100644 --- a/src/opengradient/client/opg_token.py +++ b/src/opengradient/client/opg_token.py @@ -51,16 +51,17 @@ class Permit2ApprovalResult: tx_hash: Optional[str] = None -def approve_opg(wallet_account: LocalAccount, opg_amount: float) -> Permit2ApprovalResult: - """Approve OPG tokens for Permit2 spending on Base Sepolia. +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 zero, automatically sends an ERC-20 approve transaction. + is already >= the requested amount, returns immediately without sending + a transaction. Otherwise, sends an ERC-20 approve transaction. Args: wallet_account: The wallet account to check and approve from. - opg_amount: Number of OPG tokens to approve (e.g. ``5.0`` for - 5 OPG). Converted to base units (18 decimals) internally. + 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``, @@ -79,7 +80,7 @@ def approve_opg(wallet_account: LocalAccount, opg_amount: float) -> Permit2Appro allowance_before = token.functions.allowance(owner, spender).call() - if allowance_before > 0: + if allowance_before >= amount_base: return Permit2ApprovalResult( allowance_before=allowance_before, allowance_after=allowance_before, From 3ccf1859cdd7c8eb55169afc3895c5b824a4c0d8 Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Wed, 18 Feb 2026 14:33:36 +0100 Subject: [PATCH 03/10] rm excample --- examples/README.md | 12 ---- examples/x402_permit2.py | 138 --------------------------------------- 2 files changed, 150 deletions(-) delete mode 100644 examples/x402_permit2.py diff --git a/examples/README.md b/examples/README.md index bdeb496..5ed8872 100644 --- a/examples/README.md +++ b/examples/README.md @@ -49,18 +49,6 @@ python examples/upload_model.py ## x402 LLM Examples -#### `x402_permit2.py` -Grants Permit2 approval so x402 payments can spend OPG on Base Sepolia. - -```bash -python examples/x402_permit2.py -``` - -**What it does:** -- Sends an `approve(PERMIT2, amount)` transaction for OPG -- Uses `OG_PRIVATE_KEY` to sign and submit the transaction -- Prints allowance before and after approval - #### `run_x402_llm.py` Runs LLM inference with x402 transaction processing. diff --git a/examples/x402_permit2.py b/examples/x402_permit2.py deleted file mode 100644 index 7b46e0f..0000000 --- a/examples/x402_permit2.py +++ /dev/null @@ -1,138 +0,0 @@ -import argparse -import os -from typing import Optional - -from web3 import Web3 -from x402v2.mechanisms.evm.constants import PERMIT2_ADDRESS - -import opengradient as og - -BASE_OPG_ADDRESS = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F" -BASE_SEPOLIA_RPC = "https://sepolia.base.org" -MAX_UINT256 = (1 << 256) - 1 - -ERC20_ABI = [ - { - "inputs": [ - {"name": "owner", "type": "address"}, - {"name": "spender", "type": "address"}, - ], - "name": "allowance", - "outputs": [{"name": "", "type": "uint256"}], - "stateMutability": "view", - "type": "function", - }, - { - "inputs": [ - {"name": "spender", "type": "address"}, - {"name": "amount", "type": "uint256"}, - ], - "name": "approve", - "outputs": [{"name": "", "type": "bool"}], - "stateMutability": "nonpayable", - "type": "function", - }, -] - - -def _get_base_sepolia_web3() -> Web3: - return Web3(Web3.HTTPProvider(BASE_SEPOLIA_RPC)) - - -def get_permit2_allowance(client_address: str) -> int: - w3 = _get_base_sepolia_web3() - token = w3.eth.contract(address=Web3.to_checksum_address(BASE_OPG_ADDRESS), abi=ERC20_ABI) - return token.functions.allowance( - Web3.to_checksum_address(client_address), - Web3.to_checksum_address(PERMIT2_ADDRESS), - ).call() - - -def check_permit2_approval(client_address: str) -> None: - """Raise an error if Permit2 approval is missing for base-testnet.""" - allowance = get_permit2_allowance(client_address) - print(f"Current OPG Permit2 allowance: {allowance}") - - if allowance == 0: - raise RuntimeError( - f"ERROR: No Permit2 approval found for address {client_address}. " - f"Approve Permit2 ({PERMIT2_ADDRESS}) to spend OPG ({BASE_OPG_ADDRESS}) " - "on Base Sepolia before using x402 payments." - ) - - -def grant_permit2_approval( - private_key: str, - amount: int = MAX_UINT256, - gas_multiplier: float = 1.2, - nonce: Optional[int] = None, -) -> str: - """Send ERC-20 approve(spender=Permit2, amount=amount) for OPG on Base Sepolia.""" - w3 = _get_base_sepolia_web3() - - account = w3.eth.account.from_key(private_key) - token = w3.eth.contract(address=Web3.to_checksum_address(BASE_OPG_ADDRESS), abi=ERC20_ABI) - - tx_nonce = nonce - if tx_nonce is None: - tx_nonce = w3.eth.get_transaction_count(account.address, "pending") - - approve_fn = token.functions.approve(Web3.to_checksum_address(PERMIT2_ADDRESS), amount) - estimated_gas = approve_fn.estimate_gas({"from": account.address}) - - tx = approve_fn.build_transaction( - { - "from": account.address, - "nonce": tx_nonce, - "gas": int(estimated_gas * gas_multiplier), - "gasPrice": w3.eth.gas_price, - "chainId": w3.eth.chain_id, - } - ) - - signed = account.sign_transaction(tx) - tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) - receipt = w3.eth.wait_for_transaction_receipt(tx_hash) - - if receipt.status != 1: - raise RuntimeError(f"Permit2 approval transaction failed: {tx_hash.hex()}") - - return tx_hash.hex() - - -def _parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Grant Permit2 approval to spend OPG on Base Sepolia.") - parser.add_argument( - "--amount", - type=int, - default=MAX_UINT256, - help="Approval amount in base units (default: max uint256).", - ) - return parser.parse_args() - - -def main() -> None: - args = _parse_args() - - private_key = os.environ.get("OG_PRIVATE_KEY") - if not private_key: - raise RuntimeError("OG_PRIVATE_KEY is not set.") - - client = og.Client(private_key=private_key) - wallet_address = client.wallet_address - - before = get_permit2_allowance(wallet_address) - print(f"Wallet: {wallet_address}") - print(f"Permit2: {PERMIT2_ADDRESS}") - print(f"OPG Token: {BASE_OPG_ADDRESS}") - print(f"Allowance before: {before}") - - tx_hash = grant_permit2_approval(private_key=private_key, amount=args.amount) - after = get_permit2_allowance(wallet_address) - - print(f"Approval transaction hash: {tx_hash}") - print(f"Allowance after: {after}") - - -if __name__ == "__main__": - main() From 2f2993b27ccc49ef17d47fdaaee8daaf387aade9 Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Wed, 18 Feb 2026 14:35:35 +0100 Subject: [PATCH 04/10] readme --- README.md | 12 ++++++++++++ src/opengradient/client/llm.py | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/README.md b/README.md index 5a03dee..1bdc7da 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,18 @@ result = client.llm.chat( ) ``` +### OPG Token Approval + +LLM inference payments use OPG tokens via the [Permit2](https://github.com/Uniswap/permit2) protocol. Before making requests, ensure your wallet has approved sufficient OPG for spending: + +```python +# Checks current Permit2 allowance — only sends an on-chain transaction +# if the allowance is below the requested amount. +client.llm.ensure_opg_approval(opg_amount=5) +``` + +This is idempotent: if your wallet already has an allowance >= the requested amount, no transaction is sent. + ## Examples Additional code examples are available in the [examples](./examples) directory. diff --git a/src/opengradient/client/llm.py b/src/opengradient/client/llm.py index 815072d..77cb834 100644 --- a/src/opengradient/client/llm.py +++ b/src/opengradient/client/llm.py @@ -42,8 +42,17 @@ class LLM: (Trusted Execution Environment) with x402 payment protocol support. Supports both streaming and non-streaming responses. + 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: client = og.Client(...) + + # One-time approval (idempotent — skips if allowance is already sufficient) + client.llm.ensure_opg_approval(opg_amount=5) + result = client.llm.chat(model=TEE_LLM.CLAUDE_3_5_HAIKU, messages=[...]) result = client.llm.completion(model=TEE_LLM.CLAUDE_3_5_HAIKU, prompt="Hello") """ From cab8ad54413e38f256d74aced4ac3738a9be565d Mon Sep 17 00:00:00 2001 From: kukac Date: Wed, 18 Feb 2026 14:39:12 +0100 Subject: [PATCH 05/10] Update src/opengradient/client/opg_token.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/opengradient/client/opg_token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/opengradient/client/opg_token.py b/src/opengradient/client/opg_token.py index ee7b3b1..73b6d9a 100644 --- a/src/opengradient/client/opg_token.py +++ b/src/opengradient/client/opg_token.py @@ -103,7 +103,7 @@ def ensure_opg_approval(wallet_account: LocalAccount, opg_amount: float) -> Perm signed = wallet_account.sign_transaction(tx) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) - receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120) if receipt.status != 1: raise OpenGradientError(f"Permit2 approval transaction reverted: {tx_hash.hex()}") From 14ce2f858f0812620141bf829b8bc83d80f3ee90 Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Wed, 18 Feb 2026 14:44:11 +0100 Subject: [PATCH 06/10] opg test --- tests/opg_token_test.py | 260 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 tests/opg_token_test.py diff --git a/tests/opg_token_test.py b/tests/opg_token_test.py new file mode 100644 index 0000000..f3d144c --- /dev/null +++ b/tests/opg_token_test.py @@ -0,0 +1,260 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from src.opengradient.client.exceptions import OpenGradientError +from src.opengradient.client.opg_token import ( + Permit2ApprovalResult, + ensure_opg_approval, +) + +OWNER_ADDRESS = "0x1234567890abcdef1234567890ABCDEF12345678" +SPENDER_ADDRESS = "0xAABBCCDDEEFF00112233445566778899AABBCCDD" + + +@pytest.fixture +def mock_wallet(): + wallet = MagicMock() + wallet.address = OWNER_ADDRESS + return wallet + + +@pytest.fixture +def mock_web3(monkeypatch): + """Patch Web3 and PERMIT2_ADDRESS so no real RPC calls are made.""" + mock_w3 = MagicMock() + + # Make Web3.to_checksum_address pass through + mock_web3_cls = MagicMock() + mock_web3_cls.return_value = mock_w3 + mock_web3_cls.to_checksum_address = lambda addr: addr + mock_web3_cls.HTTPProvider.return_value = MagicMock() + + monkeypatch.setattr("src.opengradient.client.opg_token.Web3", mock_web3_cls) + monkeypatch.setattr("src.opengradient.client.opg_token.PERMIT2_ADDRESS", SPENDER_ADDRESS) + + return mock_w3 + + +def _setup_allowance(mock_w3, allowance_value): + """Configure the mock contract to return a specific allowance.""" + contract = MagicMock() + contract.functions.allowance.return_value.call.return_value = allowance_value + mock_w3.eth.contract.return_value = contract + return contract + + +class TestEnsureOpgApprovalSkips: + """Cases where the existing allowance is sufficient.""" + + 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) + + result = ensure_opg_approval(mock_wallet, amount) + + 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 + + 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) + + result = ensure_opg_approval(mock_wallet, 0.0) + + assert result.tx_hash is None + + +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) + + # 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"} + + mock_web3.eth.get_transaction_count.return_value = 7 + 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 + + tx_hash = MagicMock() + tx_hash.hex.return_value = "0xabc123" + mock_web3.eth.send_raw_transaction.return_value = tx_hash + + receipt = SimpleNamespace(status=1) + mock_web3.eth.wait_for_transaction_receipt.return_value = receipt + + # 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, amount) + + 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 + + def test_gas_estimate_has_20_percent_buffer(self, mock_wallet, mock_web3): + """Gas limit should be estimatedGas * 1.2.""" + contract = _setup_allowance(mock_web3, 0) + + 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 + + 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) + + 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) + + +class TestEnsureOpgApprovalErrors: + """Error handling paths.""" + + def test_reverted_tx_raises(self, mock_wallet, mock_web3): + """A reverted transaction raises OpenGradientError.""" + contract = _setup_allowance(mock_web3, 0) + + 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 + + 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) + + with pytest.raises(OpenGradientError, match="reverted"): + ensure_opg_approval(mock_wallet, 5.0) + + def test_generic_exception_wrapped(self, mock_wallet, mock_web3): + """Non-OpenGradientError exceptions are wrapped in OpenGradientError.""" + contract = _setup_allowance(mock_web3, 0) + + approve_fn = MagicMock() + contract.functions.approve.return_value = approve_fn + approve_fn.estimate_gas.side_effect = RuntimeError("RPC unavailable") + + mock_web3.eth.get_transaction_count.return_value = 0 + + with pytest.raises(OpenGradientError, match="Failed to approve Permit2 for OPG"): + ensure_opg_approval(mock_wallet, 5.0) + + def test_opengradient_error_not_double_wrapped(self, mock_wallet, mock_web3): + """OpenGradientError raised inside the try block should propagate as-is.""" + contract = _setup_allowance(mock_web3, 0) + + 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 + + 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) + + with pytest.raises(OpenGradientError, match="reverted") as exc_info: + ensure_opg_approval(mock_wallet, 5.0) + + # Should be the original error, not wrapped + assert "Failed to approve" not in str(exc_info.value) + + +class TestAmountConversion: + """Verify float-to-base-unit conversion.""" + + def test_fractional_amount(self, mock_wallet, mock_web3): + """Fractional OPG amounts convert correctly to 18-decimal base units.""" + expected_base = int(0.5 * 10**18) + _setup_allowance(mock_web3, expected_base) + + result = ensure_opg_approval(mock_wallet, 0.5) + + assert result.allowance_before == expected_base + assert result.tx_hash is None + + def test_large_amount(self, mock_wallet, mock_web3): + """Large OPG amounts convert correctly.""" + expected_base = int(1000.0 * 10**18) + _setup_allowance(mock_web3, expected_base) + + result = ensure_opg_approval(mock_wallet, 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" From 63202a8b9f5eba09b01d4004df0fe5b9756521c5 Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Wed, 18 Feb 2026 14:45:59 +0100 Subject: [PATCH 07/10] docs --- docs/opengradient/client/client.md | 21 ++++++--- docs/opengradient/client/index.md | 37 +++++++++++++--- docs/opengradient/client/llm.md | 37 +++++++++++++++- docs/opengradient/client/opg_token.md | 64 +++++++++++++++++++++++++++ docs/opengradient/index.md | 45 +++++++++++++++---- 5 files changed, 181 insertions(+), 23 deletions(-) create mode 100644 docs/opengradient/client/opg_token.md diff --git a/docs/opengradient/client/client.md b/docs/opengradient/client/client.md index 2f295f7..fc3aae7 100644 --- a/docs/opengradient/client/client.md +++ b/docs/opengradient/client/client.md @@ -15,22 +15,33 @@ Main Client class that unifies all OpenGradient service namespaces. Main OpenGradient SDK client. Provides unified access to all OpenGradient services including LLM inference, -on-chain model inference, and the Model Hub. Handles authentication via -blockchain private key and optional Model Hub credentials. +on-chain model inference, and the Model Hub. + +The client operates across two chains: + +- **LLM inference** (``client.llm``) settles via x402 on **Base Sepolia** + using OPG tokens (funded by ``private_key``). +- **Alpha Testnet** (``client.alpha``) runs on the **OpenGradient network** + using testnet gas tokens (funded by ``alpha_private_key``, or ``private_key`` + when not provided). #### Constructor ```python -def __init__(private_key: str, email: Optional[str] = None, password: Optional[str] = None, twins_api_key: Optional[str] = None, wallet_address: str = None, rpc_url: str = 'https://ogevmdevnet.opengradient.ai', api_url: str = 'https://sdk-devnet.opengradient.ai', contract_address: str = '0x8383C9bD7462F12Eb996DD02F78234C0421A6FaE', og_llm_server_url: Optional[str] = 'https://llm.opengradient.ai', og_llm_streaming_server_url: Optional[str] = 'https://llm.opengradient.ai') +def __init__(private_key: str, alpha_private_key: Optional[str] = None, email: Optional[str] = None, password: Optional[str] = None, twins_api_key: Optional[str] = None, wallet_address: str = None, rpc_url: str = 'https://ogevmdevnet.opengradient.ai', api_url: str = 'https://sdk-devnet.opengradient.ai', contract_address: str = '0x8383C9bD7462F12Eb996DD02F78234C0421A6FaE', og_llm_server_url: Optional[str] = 'https://llm.opengradient.ai', og_llm_streaming_server_url: Optional[str] = 'https://llm.opengradient.ai') ``` **Arguments** -* **`private_key`**: Private key for OpenGradient transactions. +* **`private_key`**: Private key whose wallet holds **Base Sepolia OPG tokens** + for x402 LLM payments. +* **`alpha_private_key`**: Private key whose wallet holds **OpenGradient testnet + gas tokens** for on-chain inference. Optional -- falls back to + ``private_key`` for backward compatibility. * **`email`**: Email for Model Hub authentication. Optional. * **`password`**: Password for Model Hub authentication. Optional. * **`twins_api_key`**: API key for digital twins chat (twin.fun). Optional. -* **`rpc_url`**: RPC URL for the blockchain network. +* **`rpc_url`**: RPC URL for the OpenGradient Alpha Testnet. * **`api_url`**: API URL for the OpenGradient API. * **`contract_address`**: Inference contract address. * **`og_llm_server_url`**: OpenGradient LLM server URL. diff --git a/docs/opengradient/client/index.md b/docs/opengradient/client/index.md index 19a04b0..8719d42 100644 --- a/docs/opengradient/client/index.md +++ b/docs/opengradient/client/index.md @@ -12,18 +12,29 @@ OpenGradient Client -- the central entry point to all SDK services. The [Client](./client) class provides unified access to four service namespaces: -- **[llm](./llm)** -- LLM chat and text completion with TEE-verified execution and x402 payment settlement +- **[llm](./llm)** -- LLM chat and text completion with TEE-verified execution and x402 payment settlement (Base Sepolia OPG tokens) - **[model_hub](./model_hub)** -- Model repository management: create, version, and upload ML models -- **[alpha](./alpha)** -- Alpha Testnet features: on-chain ONNX model inference (VANILLA, TEE, ZKML modes), workflow deployment, and scheduled ML model execution +- **[alpha](./alpha)** -- Alpha Testnet features: on-chain ONNX model inference (VANILLA, TEE, ZKML modes), workflow deployment, and scheduled ML model execution (OpenGradient testnet gas tokens) - **[twins](./twins)** -- Digital twins chat via OpenGradient verifiable inference +## Private Keys + +The SDK operates across two chains: + +- **`private_key`** -- used for LLM inference (``client.llm``). Pays via x402 on **Base Sepolia** with OPG tokens. +- **`alpha_private_key`** *(optional)* -- used for Alpha Testnet features (``client.alpha``). Pays gas on the **OpenGradient network** with testnet tokens. Falls back to ``private_key`` when omitted. + ## Usage ```python import opengradient as og +# Single key for both chains (backward compatible) client = og.init(private_key="0x...") +# Separate keys: Base Sepolia OPG for LLM, OpenGradient testnet gas for Alpha +client = og.init(private_key="0xLLM_KEY...", alpha_private_key="0xALPHA_KEY...") + # LLM chat (TEE-verified, streamed) for chunk in client.llm.chat( model=og.TEE_LLM.CLAUDE_3_5_HAIKU, @@ -53,6 +64,7 @@ repo = client.model_hub.create_model("my-model", "A price prediction model") * [exceptions](./exceptions): Exception types for OpenGradient SDK errors. * [llm](./llm): LLM chat and completion via TEE-verified execution with x402 payments. * [model_hub](./model_hub): Model Hub for creating, versioning, and uploading ML models. +* [opg_token](./opg_token): OPG token Permit2 approval utilities for x402 payments. * [twins](./twins): Digital twins chat via OpenGradient verifiable inference. ## Classes @@ -62,22 +74,33 @@ repo = client.model_hub.create_model("my-model", "A price prediction model") Main OpenGradient SDK client. Provides unified access to all OpenGradient services including LLM inference, -on-chain model inference, and the Model Hub. Handles authentication via -blockchain private key and optional Model Hub credentials. +on-chain model inference, and the Model Hub. + +The client operates across two chains: + +- **LLM inference** (``client.llm``) settles via x402 on **Base Sepolia** + using OPG tokens (funded by ``private_key``). +- **Alpha Testnet** (``client.alpha``) runs on the **OpenGradient network** + using testnet gas tokens (funded by ``alpha_private_key``, or ``private_key`` + when not provided). #### Constructor ```python -def __init__(private_key: str, email: Optional[str] = None, password: Optional[str] = None, twins_api_key: Optional[str] = None, wallet_address: str = None, rpc_url: str = 'https://ogevmdevnet.opengradient.ai', api_url: str = 'https://sdk-devnet.opengradient.ai', contract_address: str = '0x8383C9bD7462F12Eb996DD02F78234C0421A6FaE', og_llm_server_url: Optional[str] = 'https://llm.opengradient.ai', og_llm_streaming_server_url: Optional[str] = 'https://llm.opengradient.ai') +def __init__(private_key: str, alpha_private_key: Optional[str] = None, email: Optional[str] = None, password: Optional[str] = None, twins_api_key: Optional[str] = None, wallet_address: str = None, rpc_url: str = 'https://ogevmdevnet.opengradient.ai', api_url: str = 'https://sdk-devnet.opengradient.ai', contract_address: str = '0x8383C9bD7462F12Eb996DD02F78234C0421A6FaE', og_llm_server_url: Optional[str] = 'https://llm.opengradient.ai', og_llm_streaming_server_url: Optional[str] = 'https://llm.opengradient.ai') ``` **Arguments** -* **`private_key`**: Private key for OpenGradient transactions. +* **`private_key`**: Private key whose wallet holds **Base Sepolia OPG tokens** + for x402 LLM payments. +* **`alpha_private_key`**: Private key whose wallet holds **OpenGradient testnet + gas tokens** for on-chain inference. Optional -- falls back to + ``private_key`` for backward compatibility. * **`email`**: Email for Model Hub authentication. Optional. * **`password`**: Password for Model Hub authentication. Optional. * **`twins_api_key`**: API key for digital twins chat (twin.fun). Optional. -* **`rpc_url`**: RPC URL for the blockchain network. +* **`rpc_url`**: RPC URL for the OpenGradient Alpha Testnet. * **`api_url`**: API URL for the OpenGradient API. * **`contract_address`**: Inference contract address. * **`og_llm_server_url`**: OpenGradient LLM server URL. diff --git a/docs/opengradient/client/llm.md b/docs/opengradient/client/llm.md index eb40f00..8378b6c 100644 --- a/docs/opengradient/client/llm.md +++ b/docs/opengradient/client/llm.md @@ -18,6 +18,11 @@ Provides access to large language model completions and chat via TEE (Trusted Execution Environment) with x402 payment protocol support. Supports both streaming and non-streaming responses. +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 ```python @@ -31,7 +36,7 @@ def __init__(wallet_account: `LocalAccount`, og_llm_server_url: str, og_llm_st #### `chat()` ```python -def chat(self, model: `TEE_LLM`, messages: List[Dict], max_tokens: int = 100, stop_sequence: Optional[List[str]] = None, temperature: float = 0.0, tools: Optional[List[Dict]] = [], tool_choice: Optional[str] = None, x402_settlement_mode: Optional[`x402SettlementMode`] = x402SettlementMode.SETTLE_BATCH, stream: bool = False) ‑> Union[`TextGenerationOutput`, `TextGenerationStream`] +def chat(self, model: `TEE_LLM`, messages: List[Dict], max_tokens: int = 100, stop_sequence: Optional[List[str]] = None, temperature: float = 0.0, tools: Optional[List[Dict]] = None, tool_choice: Optional[str] = None, x402_settlement_mode: Optional[`x402SettlementMode`] = x402SettlementMode.SETTLE_BATCH, stream: bool = False) ‑> Union[`TextGenerationOutput`, `TextGenerationStream`] ``` Perform inference on an LLM model using chat via TEE. @@ -92,4 +97,32 @@ TextGenerationOutput: Generated text results including: **Raises** -* **`OpenGradientError`**: If the inference fails. \ No newline at end of file +* **`OpenGradientError`**: If the inference fails. + +--- + +#### `ensure_opg_approval()` + +```python +def ensure_opg_approval(self, 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** + +* **`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). + +**Raises** + +* **`OpenGradientError`**: If the approval transaction fails. \ No newline at end of file diff --git a/docs/opengradient/client/opg_token.md b/docs/opengradient/client/opg_token.md new file mode 100644 index 0000000..3d9c499 --- /dev/null +++ b/docs/opengradient/client/opg_token.md @@ -0,0 +1,64 @@ +--- +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). + +**Raises** + +* **`OpenGradientError`**: 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 9e25b19..c7a1968 100644 --- a/docs/opengradient/index.md +++ b/docs/opengradient/index.md @@ -55,12 +55,24 @@ result = client.alpha.infer( print(result.model_output) ``` +## Private Keys + +The SDK operates across two chains. You can use a single key for both, or provide separate keys: + +- **``private_key``** -- pays for LLM inference via x402 on **Base Sepolia** (requires OPG tokens) +- **``alpha_private_key``** *(optional)* -- pays gas for Alpha Testnet on-chain inference on the **OpenGradient network** (requires testnet gas tokens). Falls back to ``private_key`` when omitted. + +```python +# Separate keys for each chain +client = og.init(private_key="0xBASE_KEY...", alpha_private_key="0xALPHA_KEY...") +``` + ## Client Namespaces The [Client](./client/index) object exposes four namespaces: -- **[llm](./client/llm)** -- Verifiable LLM chat and completion via TEE-verified execution with x402 payments -- **[alpha](./client/alpha)** -- On-chain ONNX model inference, workflow deployment, and scheduled ML model execution (only available on the Alpha Testnet) +- **[llm](./client/llm)** -- Verifiable LLM chat and completion via TEE-verified execution with x402 payments (Base Sepolia OPG tokens) +- **[alpha](./client/alpha)** -- On-chain ONNX model inference, workflow deployment, and scheduled ML model execution (OpenGradient testnet gas tokens) - **[model_hub](./client/model_hub)** -- Model repository management - **[twins](./client/twins)** -- Digital twins chat via OpenGradient verifiable inference (requires twins API key) @@ -96,7 +108,7 @@ The SDK includes adapters for popular AI frameworks -- see the `agents` submodul ### `init()` ```python -def init(private_key: str, email: Optional[str] = None, password: Optional[str] = None, **kwargs) ‑> `Client` +def init(private_key: str, alpha_private_key: Optional[str] = None, email: Optional[str] = None, password: Optional[str] = None, **kwargs) ‑> `Client` ``` Initialize the global OpenGradient client. @@ -105,7 +117,11 @@ and stores it as the global client for convenience. **Arguments** -* **`private_key`**: Private key for OpenGradient transactions. +* **`private_key`**: Private key whose wallet holds **Base Sepolia OPG tokens** + for x402 LLM payments. +* **`alpha_private_key`**: Private key whose wallet holds **OpenGradient testnet + gas tokens** for on-chain inference. Optional -- falls back to + ``private_key`` for backward compatibility. * **`email`**: Email for Model Hub authentication. Optional. * **`password`**: Password for Model Hub authentication. Optional. **kwargs: Additional arguments forwarded to `Client`. @@ -121,22 +137,33 @@ The newly created `Client` instance. Main OpenGradient SDK client. Provides unified access to all OpenGradient services including LLM inference, -on-chain model inference, and the Model Hub. Handles authentication via -blockchain private key and optional Model Hub credentials. +on-chain model inference, and the Model Hub. + +The client operates across two chains: + +- **LLM inference** (``client.llm``) settles via x402 on **Base Sepolia** + using OPG tokens (funded by ``private_key``). +- **Alpha Testnet** (``client.alpha``) runs on the **OpenGradient network** + using testnet gas tokens (funded by ``alpha_private_key``, or ``private_key`` + when not provided). #### Constructor ```python -def __init__(private_key: str, email: Optional[str] = None, password: Optional[str] = None, twins_api_key: Optional[str] = None, wallet_address: str = None, rpc_url: str = 'https://ogevmdevnet.opengradient.ai', api_url: str = 'https://sdk-devnet.opengradient.ai', contract_address: str = '0x8383C9bD7462F12Eb996DD02F78234C0421A6FaE', og_llm_server_url: Optional[str] = 'https://llm.opengradient.ai', og_llm_streaming_server_url: Optional[str] = 'https://llm.opengradient.ai') +def __init__(private_key: str, alpha_private_key: Optional[str] = None, email: Optional[str] = None, password: Optional[str] = None, twins_api_key: Optional[str] = None, wallet_address: str = None, rpc_url: str = 'https://ogevmdevnet.opengradient.ai', api_url: str = 'https://sdk-devnet.opengradient.ai', contract_address: str = '0x8383C9bD7462F12Eb996DD02F78234C0421A6FaE', og_llm_server_url: Optional[str] = 'https://llm.opengradient.ai', og_llm_streaming_server_url: Optional[str] = 'https://llm.opengradient.ai') ``` **Arguments** -* **`private_key`**: Private key for OpenGradient transactions. +* **`private_key`**: Private key whose wallet holds **Base Sepolia OPG tokens** + for x402 LLM payments. +* **`alpha_private_key`**: Private key whose wallet holds **OpenGradient testnet + gas tokens** for on-chain inference. Optional -- falls back to + ``private_key`` for backward compatibility. * **`email`**: Email for Model Hub authentication. Optional. * **`password`**: Password for Model Hub authentication. Optional. * **`twins_api_key`**: API key for digital twins chat (twin.fun). Optional. -* **`rpc_url`**: RPC URL for the blockchain network. +* **`rpc_url`**: RPC URL for the OpenGradient Alpha Testnet. * **`api_url`**: API URL for the OpenGradient API. * **`contract_address`**: Inference contract address. * **`og_llm_server_url`**: OpenGradient LLM server URL. From a714fb010b062f21cdc73490c11db2b3be9c2306 Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Wed, 18 Feb 2026 14:48:08 +0100 Subject: [PATCH 08/10] tests --- Makefile | 7 +++++-- tests/opg_token_test.py | 8 ++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 161f312..1c2ab74 100644 --- a/Makefile +++ b/Makefile @@ -31,7 +31,7 @@ docs: # Testing # ============================================================================ -test: utils_test client_test langchain_adapter_test +test: utils_test client_test langchain_adapter_test opg_token_test utils_test: pytest tests/utils_test.py -v @@ -42,6 +42,9 @@ client_test: langchain_adapter_test: pytest tests/langchain_adapter_test.py -v +opg_token_test: + pytest tests/opg_token_test.py -v + integrationtest: python integrationtest/agent/test_agent.py python integrationtest/workflow_models/test_workflow_models.py @@ -89,5 +92,5 @@ chat-tool: --max-tokens 100 \ --stream -.PHONY: install build publish check docs test utils_test client_test integrationtest examples \ +.PHONY: install build publish check docs test utils_test client_test langchain_adapter_test opg_token_test integrationtest examples \ infer completion chat chat-stream chat-tool diff --git a/tests/opg_token_test.py b/tests/opg_token_test.py index f3d144c..57865bf 100644 --- a/tests/opg_token_test.py +++ b/tests/opg_token_test.py @@ -3,8 +3,8 @@ import pytest -from src.opengradient.client.exceptions import OpenGradientError -from src.opengradient.client.opg_token import ( +from opengradient.client.exceptions import OpenGradientError +from opengradient.client.opg_token import ( Permit2ApprovalResult, ensure_opg_approval, ) @@ -31,8 +31,8 @@ def mock_web3(monkeypatch): mock_web3_cls.to_checksum_address = lambda addr: addr mock_web3_cls.HTTPProvider.return_value = MagicMock() - monkeypatch.setattr("src.opengradient.client.opg_token.Web3", mock_web3_cls) - monkeypatch.setattr("src.opengradient.client.opg_token.PERMIT2_ADDRESS", SPENDER_ADDRESS) + monkeypatch.setattr("opengradient.client.opg_token.Web3", mock_web3_cls) + monkeypatch.setattr("opengradient.client.opg_token.PERMIT2_ADDRESS", SPENDER_ADDRESS) return mock_w3 From 061c42d637e1b8f2d6752a797468f0f65d5340cb Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Wed, 18 Feb 2026 14:49:11 +0100 Subject: [PATCH 09/10] example --- examples/run_x402_llm.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/run_x402_llm.py b/examples/run_x402_llm.py index 2287285..3146848 100644 --- a/examples/run_x402_llm.py +++ b/examples/run_x402_llm.py @@ -6,7 +6,8 @@ private_key=os.environ.get("OG_PRIVATE_KEY"), ) -client.llm.ensure_opg_approval(opg_amount=5) +approval = client.llm.ensure_opg_approval(opg_amount=5) +print(f"Approval: {approval}") messages = [ {"role": "user", "content": "What is Python?"}, From 2b06e88013562858cf8e30bbc57f82ff65df9588 Mon Sep 17 00:00:00 2001 From: "balogh.adam@icloud.com" Date: Wed, 18 Feb 2026 14:49:55 +0100 Subject: [PATCH 10/10] version bump --- docs/opengradient/index.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/opengradient/index.md b/docs/opengradient/index.md index c7a1968..831feda 100644 --- a/docs/opengradient/index.md +++ b/docs/opengradient/index.md @@ -6,7 +6,7 @@ opengradient # Package opengradient -**Version: 0.7.0** +**Version: 0.7.1** OpenGradient Python SDK for decentralized AI inference with end-to-end verification. diff --git a/pyproject.toml b/pyproject.toml index c3819fe..802a19f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "opengradient" -version = "0.7.0" +version = "0.7.1" description = "Python SDK for OpenGradient decentralized model management & inference services" authors = [{name = "OpenGradient", email = "kyle@vannalabs.ai"}] readme = "README.md"