From 0f9726f612bc874309ecc8e60ea8b80bdceb2306 Mon Sep 17 00:00:00 2001 From: Aniket Dixit Date: Wed, 1 Apr 2026 18:53:25 +0530 Subject: [PATCH 01/25] init token update --- tee_gateway/__main__.py | 20 ++++++++++++++++++-- tee_gateway/definitions.py | 11 +++++++++-- tests/test_pricing.py | 4 ++-- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/tee_gateway/__main__.py b/tee_gateway/__main__.py index a27f426..8afcdfb 100644 --- a/tee_gateway/__main__.py +++ b/tee_gateway/__main__.py @@ -39,7 +39,9 @@ BASE_TESTNET_NETWORK, EVM_PAYMENT_ADDRESS, USDC_ADDRESS, - BASE_OPG_ADDRESS, + BASE_TESTNET_OPG_ADDRESS, + BASE_MAINNET_NETWORK, + BASE_MAINNET_OPG_ADDRESS, CHAT_COMPLETIONS_USDC_AMOUNT, CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, COMPLETIONS_USDC_AMOUNT, @@ -138,7 +140,7 @@ def _shutdown_heartbeat(): pay_to=EVM_PAYMENT_ADDRESS, price=AssetAmount( amount=CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, - asset=BASE_OPG_ADDRESS, + asset=BASE_TESTNET_OPG_ADDRESS, extra={ "name": "OPG", "version": "2", @@ -147,6 +149,20 @@ def _shutdown_heartbeat(): ), network=BASE_TESTNET_NETWORK, ), + PaymentOption( + scheme="upto", + pay_to=EVM_PAYMENT_ADDRESS, + price=AssetAmount( + amount=CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, + asset=BASE_MAINNET_OPG_ADDRESS, + extra={ + "name": "OPG", + "version": "2", + "assetTransferMethod": "permit2", + }, + ), + network=BASE_MAINNET_NETWORK, + ), ], mime_type="application/json", description="Chat completion", diff --git a/tee_gateway/definitions.py b/tee_gateway/definitions.py index 6ff58de..2c14a4e 100644 --- a/tee_gateway/definitions.py +++ b/tee_gateway/definitions.py @@ -25,6 +25,9 @@ # Base Testnet — where OPG payments are accepted BASE_TESTNET_NETWORK: str = "eip155:84532" +# Base Mainnet — where OPG payments are accepted +BASE_MAINNET_NETWORK: str = "eip155:8453" + # --------------------------------------------------------------------------- # Payment recipient # --------------------------------------------------------------------------- @@ -45,7 +48,10 @@ USDC_ADDRESS: str = "0x094E464A23B90A71a0894D5D1e5D470FfDD074e1" # OpenGradient token (OPG) on Base Testnet -BASE_OPG_ADDRESS: str = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F" +BASE_TESTNET_OPG_ADDRESS: str = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F" + +# OpenGradient token (OPG) on Base Mainnet +BASE_MAINNET_OPG_ADDRESS: str = "0x5feCcD17C393CaF1001D18164236A37E731FCb9d" # --------------------------------------------------------------------------- # Token decimal places @@ -54,7 +60,8 @@ # Maps lowercase contract address → number of decimals for unit conversion. ASSET_DECIMALS_BY_ADDRESS: dict[str, int] = { USDC_ADDRESS.lower(): 6, # USDC / OUSDC standard: 6 decimals - BASE_OPG_ADDRESS.lower(): 18, # OPG: 18 decimals (ERC-20 standard) + BASE_TESTNET_OPG_ADDRESS.lower(): 18, # OPG: 18 decimals (ERC-20 standard) + BASE_MAINNET_OPG_ADDRESS.lower(): 18, # OPG: 18 decimals (ERC-20 standard) } # Fallback for any asset not explicitly listed above diff --git a/tests/test_pricing.py b/tests/test_pricing.py index 2b397c3..1a2f528 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -11,7 +11,7 @@ import unittest from decimal import Decimal -from tee_gateway.definitions import BASE_OPG_ADDRESS, USDC_ADDRESS +from tee_gateway.definitions import BASE_TESTNET_OPG_ADDRESS, USDC_ADDRESS from tee_gateway.model_registry import ( _MODEL_LOOKUP, get_model_config, @@ -26,7 +26,7 @@ def _opg_requirements() -> dict: """Fake PaymentRequirements dict for OPG (18 decimals).""" - return {"asset": BASE_OPG_ADDRESS, "amount": "50000000000000000"} + return {"asset": BASE_TESTNET_OPG_ADDRESS, "amount": "50000000000000000"} def _usdc_requirements() -> dict: From dac78ea91b4281f6668270e58b0ec387b8264028 Mon Sep 17 00:00:00 2001 From: Aniket Dixit Date: Thu, 9 Apr 2026 19:09:22 +0530 Subject: [PATCH 02/25] base mainnet changes --- pyproject.toml | 2 +- tee_gateway/__main__.py | 70 ++++++++++++++++++++++++----------------- uv.lock | 18 ++++++++--- 3 files changed, 56 insertions(+), 34 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5f27fb3..285ca61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "setuptools>=21.0.0", "Flask>=3.0.0", "gunicorn>=23.0.0", - "og-x402[evm]==0.0.1.dev6", + "og-x402[evm]>=0.0.1.dev7", "fastapi>=0.128.0", "uvicorn[standard]>=0.40.0", "pydantic>=2.12.5", diff --git a/tee_gateway/__main__.py b/tee_gateway/__main__.py index 8afcdfb..c92c4e7 100644 --- a/tee_gateway/__main__.py +++ b/tee_gateway/__main__.py @@ -27,24 +27,22 @@ from x402.http.types import RouteConfig from x402.mechanisms.evm.exact import ExactEvmServerScheme from x402.mechanisms.evm.upto import UptoEvmServerScheme +from x402.extensions.eip2612_gas_sponsoring import declare_eip2612_gas_sponsoring_extension +from x402.extensions.erc20_approval_gas_sponsoring import declare_erc20_approval_gas_sponsoring_extension from x402.schemas import AssetAmount from x402.server import x402ResourceServerSync from x402.session import SessionStore -import types as _types import x402.http.middleware.flask as x402_flask +import types as _types from .util import dynamic_session_cost_calculator from .definitions import ( - EVM_NETWORK, BASE_TESTNET_NETWORK, EVM_PAYMENT_ADDRESS, - USDC_ADDRESS, BASE_TESTNET_OPG_ADDRESS, BASE_MAINNET_NETWORK, BASE_MAINNET_OPG_ADDRESS, - CHAT_COMPLETIONS_USDC_AMOUNT, CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, - COMPLETIONS_USDC_AMOUNT, FACILITATOR_URL, ) @@ -113,28 +111,16 @@ def _shutdown_heartbeat(): server = x402ResourceServerSync(facilitator) store = SessionStore() -server.register(EVM_NETWORK, ExactEvmServerScheme()) server.register(BASE_TESTNET_NETWORK, ExactEvmServerScheme()) -server.register(EVM_NETWORK, UptoEvmServerScheme()) +server.register(BASE_MAINNET_NETWORK, ExactEvmServerScheme()) + +# Upto scheme registrations (permit2-based, variable settlement) server.register(BASE_TESTNET_NETWORK, UptoEvmServerScheme()) +server.register(BASE_MAINNET_NETWORK, UptoEvmServerScheme()) routes = { "POST /v1/chat/completions": RouteConfig( accepts=[ - PaymentOption( - scheme="upto", - pay_to=EVM_PAYMENT_ADDRESS, - price=AssetAmount( - amount=CHAT_COMPLETIONS_USDC_AMOUNT, - asset=USDC_ADDRESS, - extra={ - "name": "OUSDC", - "version": "2", - "assetTransferMethod": "permit2", - }, - ), - network=EVM_NETWORK, - ), PaymentOption( scheme="upto", pay_to=EVM_PAYMENT_ADDRESS, @@ -142,8 +128,8 @@ def _shutdown_heartbeat(): amount=CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, asset=BASE_TESTNET_OPG_ADDRESS, extra={ - "name": "OPG", - "version": "2", + "name": "OpenGradient", + "version": "1", "assetTransferMethod": "permit2", }, ), @@ -156,14 +142,18 @@ def _shutdown_heartbeat(): amount=CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, asset=BASE_MAINNET_OPG_ADDRESS, extra={ - "name": "OPG", - "version": "2", + "name": "OpenGradient", + "version": "1", "assetTransferMethod": "permit2", }, ), network=BASE_MAINNET_NETWORK, ), ], + extensions={ + **declare_eip2612_gas_sponsoring_extension(), + **declare_erc20_approval_gas_sponsoring_extension(), + }, mime_type="application/json", description="Chat completion", ), @@ -173,13 +163,35 @@ def _shutdown_heartbeat(): scheme="upto", pay_to=EVM_PAYMENT_ADDRESS, price=AssetAmount( - amount=COMPLETIONS_USDC_AMOUNT, - asset=USDC_ADDRESS, - extra={"name": "USDC", "version": "2"}, + amount=CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, + asset=BASE_TESTNET_OPG_ADDRESS, + extra={ + "name": "OpenGradient", + "version": "1", + "assetTransferMethod": "permit2", + }, ), - network=EVM_NETWORK, + network=BASE_TESTNET_NETWORK, ), + PaymentOption( + scheme="upto", + pay_to=EVM_PAYMENT_ADDRESS, + price=AssetAmount( + amount=CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, + asset=BASE_MAINNET_OPG_ADDRESS, + extra={ + "name": "OpenGradient", + "version": "1", + "assetTransferMethod": "permit2", + }, + ), + network=BASE_MAINNET_NETWORK, + ), ], + extensions={ + **declare_eip2612_gas_sponsoring_extension(), + **declare_erc20_approval_gas_sponsoring_extension(), + }, mime_type="application/json", description="Completion", ), diff --git a/uv.lock b/uv.lock index 4026c9d..9f67ce2 100644 --- a/uv.lock +++ b/uv.lock @@ -1242,17 +1242,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + [[package]] name = "og-x402" -version = "0.0.1.dev6" +version = "0.0.1.dev7" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "nest-asyncio" }, { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/70/aa/2b616b9be6dfa4dfee98bde3ed20dd41cb446d0569e0069c1d6c11faa032/og_x402-0.0.1.dev6.tar.gz", hash = "sha256:140c4b725f372e81f4a3c2caf392f58b6fcf242bc51a1c3a6417f58e3ef9e347", size = 900115, upload-time = "2026-03-30T07:13:25.623Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/45/5fa555d9c8ba319780b6f12d8f7222e31019e90b032b85ce9a7f9a08a9f4/og_x402-0.0.1.dev7.tar.gz", hash = "sha256:a4bce840c07b783d14debad0c11941c8660ea8d80c423164e0e18da012bf92d6", size = 1306033, upload-time = "2026-04-09T11:42:59.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/03/5e/a64de6f29eb80bb180288297882d5aba2a894363622d4f94417b420cf0b5/og_x402-0.0.1.dev6-py3-none-any.whl", hash = "sha256:2a1f962fa2a50d02f28421199027245d5c5f013f36a143ec2f184a546325f1bd", size = 952670, upload-time = "2026-03-30T07:13:00.408Z" }, + { url = "https://files.pythonhosted.org/packages/36/a0/30f882406ba5e52913127f1e85c6fd2ec977291273be0fdc89980dcb7c4c/og_x402-0.0.1.dev7-py3-none-any.whl", hash = "sha256:947f91a13134350997fcac2a78c435587111d6ea7d69d8a4a96fbf63d21306c7", size = 1386876, upload-time = "2026-04-09T11:42:57.667Z" }, ] [package.optional-dependencies] @@ -1881,7 +1891,7 @@ requires-dist = [ { name = "langchain-google-genai", specifier = ">=4.2.0" }, { name = "langchain-openai", specifier = ">=0.3.35" }, { name = "langchain-xai", specifier = ">=0.2.5" }, - { name = "og-x402", extras = ["evm"], specifier = "==0.0.1.dev6" }, + { name = "og-x402", extras = ["evm"], specifier = ">=0.0.1.dev7" }, { name = "openai", specifier = ">=2.15.0" }, { name = "psutil", specifier = ">=7.2.1" }, { name = "pydantic", specifier = ">=2.12.5" }, From ce3a491c8e876d11ff8ef42b3cd7a458854863b5 Mon Sep 17 00:00:00 2001 From: Aniket Dixit Date: Thu, 9 Apr 2026 20:09:49 +0530 Subject: [PATCH 03/25] addr updates --- tee_gateway/definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tee_gateway/definitions.py b/tee_gateway/definitions.py index 2c14a4e..4337e87 100644 --- a/tee_gateway/definitions.py +++ b/tee_gateway/definitions.py @@ -37,7 +37,7 @@ # your own instance. EVM_PAYMENT_ADDRESS: str = os.getenv( "EVM_PAYMENT_ADDRESS", - "0x40eFb45552EDfB2502D90A657a8ab41F03ec460d", + "0x9deEBB5D1b22e4a6e027977CeAd13893A7E4cC1a", ) # --------------------------------------------------------------------------- From 015edb96aedce1712c4655b0267c3ce7869c4f17 Mon Sep 17 00:00:00 2001 From: Aniket Dixit Date: Fri, 10 Apr 2026 01:15:45 +0530 Subject: [PATCH 04/25] dep updates --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 285ca61..42d915e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "setuptools>=21.0.0", "Flask>=3.0.0", "gunicorn>=23.0.0", - "og-x402[evm]>=0.0.1.dev7", + "og-x402[evm]>=0.0.1.dev8", "fastapi>=0.128.0", "uvicorn[standard]>=0.40.0", "pydantic>=2.12.5", diff --git a/uv.lock b/uv.lock index 9f67ce2..5029d5d 100644 --- a/uv.lock +++ b/uv.lock @@ -1253,16 +1253,16 @@ wheels = [ [[package]] name = "og-x402" -version = "0.0.1.dev7" +version = "0.0.1.dev8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nest-asyncio" }, { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e2/45/5fa555d9c8ba319780b6f12d8f7222e31019e90b032b85ce9a7f9a08a9f4/og_x402-0.0.1.dev7.tar.gz", hash = "sha256:a4bce840c07b783d14debad0c11941c8660ea8d80c423164e0e18da012bf92d6", size = 1306033, upload-time = "2026-04-09T11:42:59.898Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/9e/1d718f3e0f7a6f6fd53c8a183c1794bc4aa15d986b0faa76139d5b04096b/og_x402-0.0.1.dev8.tar.gz", hash = "sha256:9d02c2c81112b7a612cd1aea03c09af75fc75d70766d042b5ddcc82ee7d8f98a", size = 1306960, upload-time = "2026-04-09T19:44:24.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/a0/30f882406ba5e52913127f1e85c6fd2ec977291273be0fdc89980dcb7c4c/og_x402-0.0.1.dev7-py3-none-any.whl", hash = "sha256:947f91a13134350997fcac2a78c435587111d6ea7d69d8a4a96fbf63d21306c7", size = 1386876, upload-time = "2026-04-09T11:42:57.667Z" }, + { url = "https://files.pythonhosted.org/packages/c8/0e/48facce5d73330d1cb79bbd67eda9c94b9786ea86f433338ee4423a6b1d0/og_x402-0.0.1.dev8-py3-none-any.whl", hash = "sha256:2b5b9601a6d312f9b1cf68967eaf98229eb203c54ca403e46994d6eed2488ccc", size = 1387989, upload-time = "2026-04-09T19:44:23.174Z" }, ] [package.optional-dependencies] @@ -1891,7 +1891,7 @@ requires-dist = [ { name = "langchain-google-genai", specifier = ">=4.2.0" }, { name = "langchain-openai", specifier = ">=0.3.35" }, { name = "langchain-xai", specifier = ">=0.2.5" }, - { name = "og-x402", extras = ["evm"], specifier = ">=0.0.1.dev7" }, + { name = "og-x402", extras = ["evm"], specifier = ">=0.0.1.dev8" }, { name = "openai", specifier = ">=2.15.0" }, { name = "psutil", specifier = ">=7.2.1" }, { name = "pydantic", specifier = ">=2.12.5" }, From 6bd40759658c33a146893714163c7308d36e657a Mon Sep 17 00:00:00 2001 From: Aniket Dixit Date: Fri, 10 Apr 2026 16:11:12 +0530 Subject: [PATCH 05/25] updates --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 42d915e..027e43e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "setuptools>=21.0.0", "Flask>=3.0.0", "gunicorn>=23.0.0", - "og-x402[evm]>=0.0.1.dev8", + "og-x402[evm]>=0.0.1.dev9", "fastapi>=0.128.0", "uvicorn[standard]>=0.40.0", "pydantic>=2.12.5", diff --git a/uv.lock b/uv.lock index 5029d5d..4cab594 100644 --- a/uv.lock +++ b/uv.lock @@ -1253,16 +1253,16 @@ wheels = [ [[package]] name = "og-x402" -version = "0.0.1.dev8" +version = "0.0.1.dev9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nest-asyncio" }, { name = "pydantic" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/94/9e/1d718f3e0f7a6f6fd53c8a183c1794bc4aa15d986b0faa76139d5b04096b/og_x402-0.0.1.dev8.tar.gz", hash = "sha256:9d02c2c81112b7a612cd1aea03c09af75fc75d70766d042b5ddcc82ee7d8f98a", size = 1306960, upload-time = "2026-04-09T19:44:24.966Z" } +sdist = { url = "https://files.pythonhosted.org/packages/97/f5/02e7b68af825c200da2aa88292f2c07823d321a4fd9e2a3d20130358fc10/og_x402-0.0.1.dev9.tar.gz", hash = "sha256:d3cfd05443636712cb1277e3d904b878d875a60b3728d64265098ea06eeb116b", size = 1312652, upload-time = "2026-04-10T10:40:13.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/0e/48facce5d73330d1cb79bbd67eda9c94b9786ea86f433338ee4423a6b1d0/og_x402-0.0.1.dev8-py3-none-any.whl", hash = "sha256:2b5b9601a6d312f9b1cf68967eaf98229eb203c54ca403e46994d6eed2488ccc", size = 1387989, upload-time = "2026-04-09T19:44:23.174Z" }, + { url = "https://files.pythonhosted.org/packages/8b/08/f5a05fc8454541e96650d44bf15b34491505d0e4f1e9e77b26c804fbbdd3/og_x402-0.0.1.dev9-py3-none-any.whl", hash = "sha256:2db171be2526aa13a1243255538d185c4f1f6106f615eff532d1720a89672034", size = 1392934, upload-time = "2026-04-10T10:40:11.224Z" }, ] [package.optional-dependencies] @@ -1891,7 +1891,7 @@ requires-dist = [ { name = "langchain-google-genai", specifier = ">=4.2.0" }, { name = "langchain-openai", specifier = ">=0.3.35" }, { name = "langchain-xai", specifier = ">=0.2.5" }, - { name = "og-x402", extras = ["evm"], specifier = ">=0.0.1.dev8" }, + { name = "og-x402", extras = ["evm"], specifier = ">=0.0.1.dev9" }, { name = "openai", specifier = ">=2.15.0" }, { name = "psutil", specifier = ">=7.2.1" }, { name = "pydantic", specifier = ">=2.12.5" }, From 7d3ec36da1bd6e9580677368d3316be5ac5fc1ac Mon Sep 17 00:00:00 2001 From: Aniket Dixit Date: Sun, 12 Apr 2026 11:21:21 +0530 Subject: [PATCH 06/25] token addr update --- tee_gateway/definitions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tee_gateway/definitions.py b/tee_gateway/definitions.py index 4337e87..7ff3111 100644 --- a/tee_gateway/definitions.py +++ b/tee_gateway/definitions.py @@ -51,7 +51,7 @@ BASE_TESTNET_OPG_ADDRESS: str = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F" # OpenGradient token (OPG) on Base Mainnet -BASE_MAINNET_OPG_ADDRESS: str = "0x5feCcD17C393CaF1001D18164236A37E731FCb9d" +BASE_MAINNET_OPG_ADDRESS: str = "0xFbC2051AE2265686a469421b2C5A2D5462FbF5eB" # --------------------------------------------------------------------------- # Token decimal places From 793cc931d6b6b4ec918fe07fe640cd9f658bf1a2 Mon Sep 17 00:00:00 2001 From: kylexqian Date: Sun, 12 Apr 2026 11:53:54 -0700 Subject: [PATCH 07/25] Remove unused USDC var and lint --- tee_gateway/__main__.py | 10 +++++++--- tee_gateway/definitions.py | 1 - tests/test_pricing.py | 2 +- uv.lock | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tee_gateway/__main__.py b/tee_gateway/__main__.py index c92c4e7..ea43a7e 100644 --- a/tee_gateway/__main__.py +++ b/tee_gateway/__main__.py @@ -27,8 +27,12 @@ from x402.http.types import RouteConfig from x402.mechanisms.evm.exact import ExactEvmServerScheme from x402.mechanisms.evm.upto import UptoEvmServerScheme -from x402.extensions.eip2612_gas_sponsoring import declare_eip2612_gas_sponsoring_extension -from x402.extensions.erc20_approval_gas_sponsoring import declare_erc20_approval_gas_sponsoring_extension +from x402.extensions.eip2612_gas_sponsoring import ( + declare_eip2612_gas_sponsoring_extension, +) +from x402.extensions.erc20_approval_gas_sponsoring import ( + declare_erc20_approval_gas_sponsoring_extension, +) from x402.schemas import AssetAmount from x402.server import x402ResourceServerSync from x402.session import SessionStore @@ -186,7 +190,7 @@ def _shutdown_heartbeat(): }, ), network=BASE_MAINNET_NETWORK, - ), + ), ], extensions={ **declare_eip2612_gas_sponsoring_extension(), diff --git a/tee_gateway/definitions.py b/tee_gateway/definitions.py index aac907c..6517185 100644 --- a/tee_gateway/definitions.py +++ b/tee_gateway/definitions.py @@ -53,7 +53,6 @@ # Maps lowercase contract address → number of decimals for unit conversion. ASSET_DECIMALS_BY_ADDRESS: dict[str, int] = { - USDC_ADDRESS.lower(): 6, # USDC / OUSDC standard: 6 decimals BASE_TESTNET_OPG_ADDRESS.lower(): 18, # OPG: 18 decimals (ERC-20 standard) BASE_MAINNET_OPG_ADDRESS.lower(): 18, # OPG: 18 decimals (ERC-20 standard) } diff --git a/tests/test_pricing.py b/tests/test_pricing.py index 3629ebd..088a849 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -11,7 +11,7 @@ import unittest from decimal import Decimal -from tee_gateway.definitions import BASE_TESTNET_OPG_ADDRESS, USDC_ADDRESS +from tee_gateway.definitions import BASE_TESTNET_OPG_ADDRESS from tee_gateway.model_registry import ( _MODEL_LOOKUP, get_model_config, diff --git a/uv.lock b/uv.lock index 8dad936..bfb2691 100644 --- a/uv.lock +++ b/uv.lock @@ -1891,7 +1891,7 @@ requires-dist = [ { name = "langchain-google-genai", specifier = ">=4.2.1" }, { name = "langchain-openai", specifier = ">=1.1.12" }, { name = "langchain-xai", specifier = ">=1.2.2" }, - { name = "og-x402", extras = ["evm"], specifier = "==0.0.1.dev9" }, + { name = "og-x402", extras = ["evm"], specifier = ">=0.0.1.dev9" }, { name = "openai", specifier = ">=2.15.0" }, { name = "psutil", specifier = ">=7.2.1" }, { name = "pydantic", specifier = ">=2.12.5" }, From fa2c5457acfa749eeecf7a84712245b965e932aa Mon Sep 17 00:00:00 2001 From: Aniket Dixit Date: Tue, 14 Apr 2026 15:58:53 +0530 Subject: [PATCH 08/25] extension change --- tee_gateway/__main__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tee_gateway/__main__.py b/tee_gateway/__main__.py index ea43a7e..935f077 100644 --- a/tee_gateway/__main__.py +++ b/tee_gateway/__main__.py @@ -27,9 +27,6 @@ from x402.http.types import RouteConfig from x402.mechanisms.evm.exact import ExactEvmServerScheme from x402.mechanisms.evm.upto import UptoEvmServerScheme -from x402.extensions.eip2612_gas_sponsoring import ( - declare_eip2612_gas_sponsoring_extension, -) from x402.extensions.erc20_approval_gas_sponsoring import ( declare_erc20_approval_gas_sponsoring_extension, ) @@ -155,7 +152,6 @@ def _shutdown_heartbeat(): ), ], extensions={ - **declare_eip2612_gas_sponsoring_extension(), **declare_erc20_approval_gas_sponsoring_extension(), }, mime_type="application/json", @@ -193,7 +189,6 @@ def _shutdown_heartbeat(): ), ], extensions={ - **declare_eip2612_gas_sponsoring_extension(), **declare_erc20_approval_gas_sponsoring_extension(), }, mime_type="application/json", From baf01e90028146b567b4316713872c35ef8a607c Mon Sep 17 00:00:00 2001 From: Aniket Dixit Date: Fri, 17 Apr 2026 14:36:33 +0530 Subject: [PATCH 09/25] mainnet changes --- tee_gateway/__main__.py | 32 -------------------------------- tee_gateway/definitions.py | 6 ------ tests/test_pricing.py | 4 ++-- 3 files changed, 2 insertions(+), 40 deletions(-) diff --git a/tee_gateway/__main__.py b/tee_gateway/__main__.py index 935f077..1ba44c2 100644 --- a/tee_gateway/__main__.py +++ b/tee_gateway/__main__.py @@ -38,9 +38,7 @@ from .util import dynamic_session_cost_calculator from .definitions import ( - BASE_TESTNET_NETWORK, EVM_PAYMENT_ADDRESS, - BASE_TESTNET_OPG_ADDRESS, BASE_MAINNET_NETWORK, BASE_MAINNET_OPG_ADDRESS, CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, @@ -112,30 +110,14 @@ def _shutdown_heartbeat(): server = x402ResourceServerSync(facilitator) store = SessionStore() -server.register(BASE_TESTNET_NETWORK, ExactEvmServerScheme()) server.register(BASE_MAINNET_NETWORK, ExactEvmServerScheme()) # Upto scheme registrations (permit2-based, variable settlement) -server.register(BASE_TESTNET_NETWORK, UptoEvmServerScheme()) server.register(BASE_MAINNET_NETWORK, UptoEvmServerScheme()) routes = { "POST /v1/chat/completions": RouteConfig( accepts=[ - PaymentOption( - scheme="upto", - pay_to=EVM_PAYMENT_ADDRESS, - price=AssetAmount( - amount=CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, - asset=BASE_TESTNET_OPG_ADDRESS, - extra={ - "name": "OpenGradient", - "version": "1", - "assetTransferMethod": "permit2", - }, - ), - network=BASE_TESTNET_NETWORK, - ), PaymentOption( scheme="upto", pay_to=EVM_PAYMENT_ADDRESS, @@ -159,20 +141,6 @@ def _shutdown_heartbeat(): ), "POST /v1/completions": RouteConfig( accepts=[ - PaymentOption( - scheme="upto", - pay_to=EVM_PAYMENT_ADDRESS, - price=AssetAmount( - amount=CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, - asset=BASE_TESTNET_OPG_ADDRESS, - extra={ - "name": "OpenGradient", - "version": "1", - "assetTransferMethod": "permit2", - }, - ), - network=BASE_TESTNET_NETWORK, - ), PaymentOption( scheme="upto", pay_to=EVM_PAYMENT_ADDRESS, diff --git a/tee_gateway/definitions.py b/tee_gateway/definitions.py index 6517185..ca316bd 100644 --- a/tee_gateway/definitions.py +++ b/tee_gateway/definitions.py @@ -19,8 +19,6 @@ # Network IDs (EIP-155 chain identifiers) # --------------------------------------------------------------------------- -# Base Testnet — where OPG payments are accepted -BASE_TESTNET_NETWORK: str = "eip155:84532" # Base Mainnet — where OPG payments are accepted BASE_MAINNET_NETWORK: str = "eip155:8453" @@ -41,9 +39,6 @@ # ERC-20 token contract addresses # --------------------------------------------------------------------------- -# OpenGradient token (OPG) on Base Testnet -BASE_TESTNET_OPG_ADDRESS: str = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F" - # OpenGradient token (OPG) on Base Mainnet BASE_MAINNET_OPG_ADDRESS: str = "0xFbC2051AE2265686a469421b2C5A2D5462FbF5eB" @@ -53,7 +48,6 @@ # Maps lowercase contract address → number of decimals for unit conversion. ASSET_DECIMALS_BY_ADDRESS: dict[str, int] = { - BASE_TESTNET_OPG_ADDRESS.lower(): 18, # OPG: 18 decimals (ERC-20 standard) BASE_MAINNET_OPG_ADDRESS.lower(): 18, # OPG: 18 decimals (ERC-20 standard) } diff --git a/tests/test_pricing.py b/tests/test_pricing.py index 088a849..d1b5f25 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -11,7 +11,7 @@ import unittest from decimal import Decimal -from tee_gateway.definitions import BASE_TESTNET_OPG_ADDRESS +from tee_gateway.definitions import BASE_MAINNET_OPG_ADDRESS from tee_gateway.model_registry import ( _MODEL_LOOKUP, get_model_config, @@ -26,7 +26,7 @@ def _opg_requirements() -> dict: """Fake PaymentRequirements dict for OPG (18 decimals).""" - return {"asset": BASE_TESTNET_OPG_ADDRESS, "amount": "50000000000000000"} + return {"asset": BASE_MAINNET_OPG_ADDRESS, "amount": "50000000000000000"} def _ctx(model: str, input_tokens: int, output_tokens: int, requirements=None) -> dict: From f2d63631c6492c445a276962b44abc6ff5881d8e Mon Sep 17 00:00:00 2001 From: kylexqian Date: Thu, 16 Apr 2026 02:20:29 -0700 Subject: [PATCH 10/25] feat: fetch live OPG/ETH price from CoinGecko for dynamic cost calculation Replaces the hardcoded Decimal("1") mock with a real CoinGecko ETH/USD price fetch (ETH used as OPG proxy until OPG is listed). Caches the price for 2 minutes so at most one network call is made per TTL window. Falls back to last-known-good price on refresh failure, or a hard-coded $2000 floor if no price has ever been fetched. Config constants (TTL, CoinGecko ID, fallback price) are centralised in config.py so switching to real OPG requires only a one-line change. Adds test_util.py with 18 tests covering the fetch, cache, fallback, and full dynamic_session_cost_calculator pipeline. Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/config.py | 20 +++ tee_gateway/test/test_util.py | 264 ++++++++++++++++++++++++++++++++++ tee_gateway/util.py | 85 ++++++++--- 3 files changed, 349 insertions(+), 20 deletions(-) create mode 100644 tee_gateway/test/test_util.py diff --git a/tee_gateway/config.py b/tee_gateway/config.py index f6a842d..ab5d576 100644 --- a/tee_gateway/config.py +++ b/tee_gateway/config.py @@ -9,7 +9,27 @@ from dataclasses import dataclass from typing import Optional +# --------------------------------------------------------------------------- +# OPG / token price feed +# --------------------------------------------------------------------------- + +# How long (seconds) to reuse a cached price before fetching a fresh one. +# At 120 s the gateway makes at most 30 CoinGecko calls/hour — well within +# the free-tier limit (30/min). +OPG_PRICE_CACHE_TTL_SECONDS: int = 120 + +# CoinGecko coin ID used as the OPG price proxy until OPG is listed. +# Switch this to the CoinGecko slug for OPG once the token launches. +OPG_PRICE_COINGECKO_ID: str = "ethereum" + +# Fallback OPG/USD price used only when no successful fetch has ever been +# made (e.g. network unavailable on the very first request). +# Set to a conservative ETH ballpark; update when switching to real OPG. +OPG_PRICE_HARD_FALLBACK_USD: str = "2000" + +# --------------------------------------------------------------------------- # Heartbeat defaults +# --------------------------------------------------------------------------- DEFAULT_HEARTBEAT_INTERVAL = 900 # 15 minutes DEFAULT_HEARTBEAT_BUFFER = ( 300 # 5 minutes — subtracted from time.time() to compensate for enclave clock drift diff --git a/tee_gateway/test/test_util.py b/tee_gateway/test/test_util.py new file mode 100644 index 0000000..fa47e8d --- /dev/null +++ b/tee_gateway/test/test_util.py @@ -0,0 +1,264 @@ +""" +Tests for tee_gateway.util — OPG price fetching, caching, and dynamic cost calculation. + +All tests are fully offline: urllib.request.urlopen is patched so no real +network call is ever made. +""" + +import json +import unittest +from decimal import Decimal +from io import BytesIO +from unittest.mock import MagicMock, patch + +from tee_gateway import util +from tee_gateway.util import ( + _fetch_opg_price_usd, + _token_price_cache, + dynamic_session_cost_calculator, + get_token_a_price_usd, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_urlopen_response(price: float) -> MagicMock: + """Return a mock context-manager that urlopen returns with a CoinGecko payload.""" + body = json.dumps({"ethereum": {"usd": price}}).encode() + mock_resp = MagicMock() + mock_resp.read.return_value = body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + return mock_resp + + +def _reset_price_cache() -> None: + """Reset module-level price cache to pristine state between tests.""" + _token_price_cache["last_good"] = None + _token_price_cache["updated_at"] = 0.0 + + +# --------------------------------------------------------------------------- +# _fetch_opg_price_usd +# --------------------------------------------------------------------------- + + +class TestFetchOPGPrice(unittest.TestCase): + """_fetch_opg_price_usd makes one HTTP call and returns a Decimal price.""" + + def setUp(self): + _reset_price_cache() + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_returns_decimal_price(self, mock_urlopen): + mock_urlopen.return_value = _make_urlopen_response(3000.50) + price = _fetch_opg_price_usd() + self.assertIsInstance(price, Decimal) + self.assertEqual(price, Decimal("3000.5")) + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_uses_configured_coingecko_id_in_url(self, mock_urlopen): + mock_urlopen.return_value = _make_urlopen_response(3000.0) + _fetch_opg_price_usd() + call_args = mock_urlopen.call_args + # First positional arg is the Request object + req = call_args[0][0] + self.assertIn("ethereum", req.full_url) + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_raises_on_non_positive_price(self, mock_urlopen): + mock_urlopen.return_value = _make_urlopen_response(0.0) + with self.assertRaises(ValueError): + _fetch_opg_price_usd() + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_raises_on_network_error(self, mock_urlopen): + mock_urlopen.side_effect = OSError("connection refused") + with self.assertRaises(OSError): + _fetch_opg_price_usd() + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_raises_on_malformed_json(self, mock_urlopen): + mock_resp = MagicMock() + mock_resp.read.return_value = b"not-json" + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + with self.assertRaises(Exception): + _fetch_opg_price_usd() + + +# --------------------------------------------------------------------------- +# get_token_a_price_usd — caching behaviour +# --------------------------------------------------------------------------- + + +class TestGetTokenAPriceUSD(unittest.TestCase): + """get_token_a_price_usd must respect the TTL and fallback gracefully.""" + + def setUp(self): + _reset_price_cache() + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_returns_fetched_price_on_cold_cache(self, mock_urlopen): + mock_urlopen.return_value = _make_urlopen_response(3000.0) + price = get_token_a_price_usd() + self.assertEqual(price, Decimal("3000.0")) + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_cache_hit_skips_second_network_call(self, mock_urlopen): + mock_urlopen.return_value = _make_urlopen_response(3000.0) + get_token_a_price_usd() # populates cache + get_token_a_price_usd() # should hit cache + self.assertEqual(mock_urlopen.call_count, 1) + + @patch("tee_gateway.util.urllib.request.urlopen") + @patch("tee_gateway.util.time") + def test_cache_expires_after_ttl(self, mock_time, mock_urlopen): + mock_urlopen.return_value = _make_urlopen_response(3000.0) + mock_time.time.return_value = 1_000_000.0 + get_token_a_price_usd() + + # Advance time past TTL + from tee_gateway.config import OPG_PRICE_CACHE_TTL_SECONDS + mock_time.time.return_value = 1_000_000.0 + OPG_PRICE_CACHE_TTL_SECONDS + 1 + mock_urlopen.return_value = _make_urlopen_response(3500.0) + price = get_token_a_price_usd() + + self.assertEqual(mock_urlopen.call_count, 2) + self.assertEqual(price, Decimal("3500.0")) + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_stale_cache_used_on_refresh_failure(self, mock_urlopen): + """If the cache is populated but a refresh fails, the last good price is returned.""" + mock_urlopen.return_value = _make_urlopen_response(3000.0) + first = get_token_a_price_usd() + self.assertEqual(first, Decimal("3000.0")) + + # Force cache to appear expired then make the refresh fail + _token_price_cache["updated_at"] = 0.0 + mock_urlopen.side_effect = OSError("network down") + second = get_token_a_price_usd() + + self.assertEqual(second, Decimal("3000.0")) # stale value returned + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_hard_fallback_used_when_never_fetched_and_network_fails(self, mock_urlopen): + """With empty cache and a failing network, the hard fallback price is returned.""" + mock_urlopen.side_effect = OSError("network down") + price = get_token_a_price_usd() + self.assertEqual(price, util._PRICE_HARD_FALLBACK_USD) + self.assertGreater(price, 0) + + +# --------------------------------------------------------------------------- +# dynamic_session_cost_calculator — end-to-end with mocked price +# --------------------------------------------------------------------------- + + +class TestDynamicSessionCostCalculator(unittest.TestCase): + """Full pipeline: token counts + model pricing + OPG price → on-chain units.""" + + def setUp(self): + _reset_price_cache() + + def _make_context( + self, + model: str = "gpt-4.1", + prompt_tokens: int = 100, + completion_tokens: int = 50, + asset: str = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F", # OPG testnet + ) -> dict: + return { + "request_json": {"model": model}, + "response_json": { + "usage": { + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + } + }, + "payment_requirements": {"asset": asset}, + } + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_returns_positive_integer(self, mock_urlopen): + mock_urlopen.return_value = _make_urlopen_response(3000.0) + cost = dynamic_session_cost_calculator(self._make_context()) + self.assertIsInstance(cost, int) + self.assertGreater(cost, 0) + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_higher_token_price_reduces_cost(self, mock_urlopen): + """When OPG is worth more ($/token), fewer tokens are charged.""" + mock_urlopen.return_value = _make_urlopen_response(1000.0) + _reset_price_cache() + cost_cheap = dynamic_session_cost_calculator(self._make_context()) + + mock_urlopen.return_value = _make_urlopen_response(5000.0) + _reset_price_cache() + cost_expensive = dynamic_session_cost_calculator(self._make_context()) + + self.assertGreater(cost_cheap, cost_expensive) + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_more_tokens_increases_cost(self, mock_urlopen): + mock_urlopen.return_value = _make_urlopen_response(3000.0) + cost_small = dynamic_session_cost_calculator( + self._make_context(prompt_tokens=10, completion_tokens=5) + ) + _reset_price_cache() + mock_urlopen.return_value = _make_urlopen_response(3000.0) + cost_large = dynamic_session_cost_calculator( + self._make_context(prompt_tokens=1000, completion_tokens=500) + ) + self.assertGreater(cost_large, cost_small) + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_cost_scales_correctly(self, mock_urlopen): + """Spot-check the math for gpt-4.1 at a known ETH price. + + gpt-4.1 input: $0.000002/token, output: $0.000008/token + 100 input + 50 output = $0.0002 + $0.0004 = $0.0006 USD + At ETH = $3 000: 0.0006 / 3000 = 0.0000002 ETH + In wei (10^18): 200 000 000 000 = 200_000_000_000 + """ + mock_urlopen.return_value = _make_urlopen_response(3000.0) + cost = dynamic_session_cost_calculator( + self._make_context(model="gpt-4.1", prompt_tokens=100, completion_tokens=50) + ) + self.assertEqual(cost, 200_000_000_000) + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_zero_tokens_returns_zero(self, mock_urlopen): + mock_urlopen.return_value = _make_urlopen_response(3000.0) + cost = dynamic_session_cost_calculator( + self._make_context(prompt_tokens=0, completion_tokens=0) + ) + self.assertEqual(cost, 0) + + def test_raises_on_unknown_model(self): + ctx = self._make_context(model="not-a-real-model") + with self.assertRaises(ValueError): + dynamic_session_cost_calculator(ctx) + + def test_raises_when_usage_missing(self): + ctx = { + "request_json": {"model": "gpt-4.1"}, + "response_json": {}, + "payment_requirements": {"asset": "0x240b09731D96979f50B2C649C9CE10FcF9C7987F"}, + } + with self.assertRaises(ValueError): + dynamic_session_cost_calculator(ctx) + + def test_raises_when_request_json_missing(self): + with self.assertRaises(ValueError): + dynamic_session_cost_calculator( + {"response_json": {"usage": {"prompt_tokens": 1, "completion_tokens": 1}}} + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tee_gateway/util.py b/tee_gateway/util.py index 47559d9..74a325f 100644 --- a/tee_gateway/util.py +++ b/tee_gateway/util.py @@ -155,47 +155,92 @@ def _deserialize_dict(data, boxed_type): return {k: _deserialize(v, boxed_type) for k, v in data.items()} +import json # noqa: E402 +import urllib.request # noqa: E402 + +from tee_gateway.config import ( # noqa: E402 + OPG_PRICE_CACHE_TTL_SECONDS, + OPG_PRICE_COINGECKO_ID, + OPG_PRICE_HARD_FALLBACK_USD, +) from tee_gateway.definitions import ( # noqa: E402 ASSET_DECIMALS_BY_ADDRESS, ) from tee_gateway.model_registry import get_model_config # noqa: E402 -TOKEN_A_PRICE_CACHE_TTL_SECONDS = 60 +_PRICE_HARD_FALLBACK_USD = Decimal(OPG_PRICE_HARD_FALLBACK_USD) +# Cache layout: +# "last_good" – most recent successfully fetched price (Decimal | None) +# "updated_at" – epoch seconds of last successful fetch (float) _token_price_cache: dict[str, Any] = { - "value": Decimal("1"), + "last_good": None, "updated_at": 0.0, } _token_price_lock = threading.Lock() -def _fetch_token_a_price_usd_mock() -> Decimal: - """Return the USD price of the payment token used for cost calculation. +def _fetch_opg_price_usd() -> Decimal: + """Fetch the OPG/USD price from CoinGecko. - Currently returns a fixed 1:1 ratio, which is correct for USDC-denominated - payments (1 USDC ≈ $1 USD). For OPG-denominated payments, replace this - with a live price feed (e.g. a DEX oracle or CoinGecko API call) that - returns the current OPG/USD exchange rate so that token amounts are - calculated correctly against the model's USD pricing. + OPG has not launched yet, so we proxy it with ETH/USD until OPG is listed. + Swap the CoinGecko coin ID to ``opg-token`` (or whatever slug CoinGecko + assigns) once OPG is live. """ - return Decimal("1") + url = ( + f"https://api.coingecko.com/api/v3/simple/price" + f"?ids={OPG_PRICE_COINGECKO_ID}&vs_currencies=usd" + ) + req = urllib.request.Request(url, headers={"User-Agent": "tee-gateway/1.0"}) + with urllib.request.urlopen(req, timeout=5) as resp: + data: dict[str, Any] = json.loads(resp.read()) + price = Decimal(str(data[OPG_PRICE_COINGECKO_ID]["usd"])) + if price <= 0: + raise ValueError(f"CoinGecko returned non-positive ETH price: {price}") + return price def get_token_a_price_usd() -> Decimal: + """Return the current OPG/USD price, refreshing at most once per TTL window. + + Strategy: + - Return cached price immediately if it was fetched within the TTL. + - On TTL expiry, attempt a fresh CoinGecko fetch. + - Success → update cache, return new price. + - Failure → log a warning, return the last known-good price (stale-cache + fallback), or the hard-coded fallback if no price has ever been fetched. + This means at most one network call every 5 minutes regardless of request + volume, and inference is never blocked by a network timeout beyond the first + call after a cache miss. + """ now = time.time() with _token_price_lock: - cached_value = _token_price_cache.get("value") + last_good: Decimal | None = _token_price_cache.get("last_good") # type: ignore[assignment] cached_at = float(_token_price_cache.get("updated_at") or 0.0) - if ( - isinstance(cached_value, Decimal) - and (now - cached_at) < TOKEN_A_PRICE_CACHE_TTL_SECONDS - ): - return cached_value - value = _fetch_token_a_price_usd_mock() - _token_price_cache["value"] = value - _token_price_cache["updated_at"] = now - return value + if last_good is not None and (now - cached_at) < OPG_PRICE_CACHE_TTL_SECONDS: + return last_good + + try: + value = _fetch_opg_price_usd() + _token_price_cache["last_good"] = value + _token_price_cache["updated_at"] = now + logger.info("OPG price refreshed: $%s (ETH proxy)", value) + return value + except Exception as exc: + if last_good is not None: + logger.warning( + "Failed to refresh OPG price (%s); using last known value $%s", + exc, + last_good, + ) + return last_good + logger.warning( + "Failed to fetch OPG price (%s); using hard fallback $%s", + exc, + _PRICE_HARD_FALLBACK_USD, + ) + return _PRICE_HARD_FALLBACK_USD def _as_dict(value: Any) -> dict[str, Any] | None: From 1522c237e3f9657fe393576bf2ac5ee34c523a64 Mon Sep 17 00:00:00 2001 From: kylexqian Date: Thu, 16 Apr 2026 02:26:34 -0700 Subject: [PATCH 11/25] test: add live CoinGecko integration test and fix test_pricing.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_pricing.py was relying on the old Decimal("1") mock price; patch get_token_a_price_usd to $1 via setUpModule/tearDownModule so the hardcoded expected wei values remain valid as pure USD pricing unit tests. Add tests/test_integration.py (pytest -m integration) with 4 live tests: - _fetch_opg_price_usd returns a positive Decimal from real CoinGecko - ETH price falls within a sanity range ($100–$100 000) - Second call within TTL returns cached value (no second network call) - Full dynamic_session_cost_calculator pipeline with live price Add a separate integration-tests CI job in test.yml and register the integration marker in pyproject.toml. Also add pythonpath = ["."] to pytest config so tests/ can import tee_gateway when run standalone. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yml | 10 ++++- pyproject.toml | 6 +++ tests/test_integration.py | 85 ++++++++++++++++++++++++++++++++++++++ tests/test_pricing.py | 19 +++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 tests/test_integration.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 835c886..ee74830 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,4 +15,12 @@ jobs: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - name: Run unit tests - run: uv run --group test pytest tee_gateway/test/test_tool_forwarding.py tee_gateway/test/test_tee_core.py tests/test_pricing.py -v --import-mode=importlib + run: uv run --group test pytest tee_gateway/test/test_tool_forwarding.py tee_gateway/test/test_tee_core.py tee_gateway/test/test_util.py tests/test_pricing.py -v --import-mode=importlib + + integration-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Run integration tests (live CoinGecko API) + run: uv run --group test pytest tests/test_integration.py -v -m integration --import-mode=importlib diff --git a/pyproject.toml b/pyproject.toml index b0dcfe7..b549ccf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,12 @@ exclude = [ "**/site-packages", ] +[tool.pytest.ini_options] +pythonpath = ["."] +markers = [ + "integration: tests that require live network access (deselect with '-m not integration')", +] + [tool.uv] # Pre-release needed for og-test-v2-x402==0.0.11.dev5 prerelease = "allow" diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..eeab1a4 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,85 @@ +""" +Integration tests — require live network access. + +These tests hit the real CoinGecko API and are intentionally excluded from +the standard unit-test run. Opt in with: + + pytest -m integration tests/test_integration.py + +In CI, these run in a separate job (see .github/workflows/test.yml). +""" + +import pytest +from decimal import Decimal + + +@pytest.mark.integration +class TestCoinGeckoPriceFeed: + """Verify the live OPG/ETH price fetch end-to-end.""" + + def test_fetch_returns_positive_decimal(self): + """_fetch_opg_price_usd must return a positive Decimal from CoinGecko.""" + from tee_gateway.util import _fetch_opg_price_usd + + price = _fetch_opg_price_usd() + assert isinstance(price, Decimal) + assert price > 0, f"Expected positive price, got {price}" + + def test_price_is_plausible_eth_range(self): + """ETH price should be within a sanity range ($100–$100 000). + + This is a loose check that guards against obviously wrong responses + (e.g. CoinGecko returning a different currency or a zero value). + Update bounds if ETH moves outside this range for an extended period. + """ + from tee_gateway.util import _fetch_opg_price_usd + + price = _fetch_opg_price_usd() + assert Decimal("100") < price < Decimal("100000"), ( + f"ETH price ${price} is outside the expected sanity range $100–$100 000" + ) + + def test_get_token_a_price_usd_returns_cached_value(self): + """get_token_a_price_usd must return the same value on two rapid calls + (second call must hit the cache, not make a second network request).""" + from tee_gateway.util import _token_price_cache, get_token_a_price_usd + + # Reset cache so first call is a fresh fetch + _token_price_cache["last_good"] = None + _token_price_cache["updated_at"] = 0.0 + + first = get_token_a_price_usd() + second = get_token_a_price_usd() + + assert first == second, "Cache should return the same price on the second call" + assert first > 0 + + def test_dynamic_cost_uses_live_price(self): + """Full pipeline: token counts + live ETH price → positive on-chain units.""" + from tee_gateway.definitions import BASE_TESTNET_OPG_ADDRESS + from tee_gateway.util import _token_price_cache, dynamic_session_cost_calculator, get_token_a_price_usd + + # Reset cache to force a live fetch + _token_price_cache["last_good"] = None + _token_price_cache["updated_at"] = 0.0 + + ctx = { + "request_json": {"model": "gpt-4.1"}, + "response_json": { + "usage": {"prompt_tokens": 1000, "completion_tokens": 500} + }, + "payment_requirements": {"asset": BASE_TESTNET_OPG_ADDRESS}, + } + + cost = dynamic_session_cost_calculator(ctx) + live_price = get_token_a_price_usd() + + assert isinstance(cost, int) + assert cost > 0 + + # Sanity: cost should be far less than 1 full OPG (10^18 units) + # for a small request at any plausible ETH price + assert cost < 10 ** 18, f"Cost {cost} seems too large for a small request" + + print(f"\nLive ETH price: ${live_price}") + print(f"Cost for gpt-4.1 (1000 input + 500 output tokens): {cost} OPG units") diff --git a/tests/test_pricing.py b/tests/test_pricing.py index d1b5f25..98353e0 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -10,6 +10,7 @@ import unittest from decimal import Decimal +from unittest.mock import patch from tee_gateway.definitions import BASE_MAINNET_OPG_ADDRESS from tee_gateway.model_registry import ( @@ -18,6 +19,24 @@ ) from tee_gateway.util import dynamic_session_cost_calculator +# --------------------------------------------------------------------------- +# Pin token price to $1 for all unit tests in this file. +# +# test_pricing.py exists to verify the USD-per-token math for each model. +# It should not depend on a live price feed — that is tested separately in +# test_integration.py. A $1 pin keeps the hardcoded expected wei values valid +# and makes the test suite fully deterministic and offline. +# --------------------------------------------------------------------------- +_price_patcher = patch("tee_gateway.util.get_token_a_price_usd", return_value=Decimal("1")) + + +def setUpModule(): + _price_patcher.start() + + +def tearDownModule(): + _price_patcher.stop() + # --------------------------------------------------------------------------- # Helpers From 3b5d9d0442bc5a8137e29918b79290ef23edd74d Mon Sep 17 00:00:00 2001 From: kylexqian Date: Thu, 16 Apr 2026 02:30:38 -0700 Subject: [PATCH 12/25] test: pull price sanity bounds from config and widen range for OPG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OPG_PRICE_SANITY_MIN_USD / OPG_PRICE_SANITY_MAX_USD to config.py ($0.000001–$1 000 000) so the integration test stays in sync when switching from the ETH proxy to real OPG without touching test code. Widen lower bound from $100 to $0.000001 to accommodate a potentially low OPG token price. Rename test to test_price_is_within_sanity_bounds. Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/config.py | 8 ++++++++ tests/test_integration.py | 17 ++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/tee_gateway/config.py b/tee_gateway/config.py index ab5d576..c093591 100644 --- a/tee_gateway/config.py +++ b/tee_gateway/config.py @@ -27,6 +27,14 @@ # Set to a conservative ETH ballpark; update when switching to real OPG. OPG_PRICE_HARD_FALLBACK_USD: str = "2000" +# Sanity bounds for the fetched token price. +# These are used in integration tests to catch obviously wrong API responses +# (wrong currency, zero, implausibly large value). +# OPG may be worth very little per token, so the lower bound is intentionally +# very small. Update both bounds when switching from ETH proxy to real OPG. +OPG_PRICE_SANITY_MIN_USD: str = "0.000001" # $0.000001 — rules out zero/negative +OPG_PRICE_SANITY_MAX_USD: str = "1000000" # $1 000 000 — rules out obviously corrupt data + # --------------------------------------------------------------------------- # Heartbeat defaults # --------------------------------------------------------------------------- diff --git a/tests/test_integration.py b/tests/test_integration.py index eeab1a4..5a75bca 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -25,18 +25,21 @@ def test_fetch_returns_positive_decimal(self): assert isinstance(price, Decimal) assert price > 0, f"Expected positive price, got {price}" - def test_price_is_plausible_eth_range(self): - """ETH price should be within a sanity range ($100–$100 000). + def test_price_is_within_sanity_bounds(self): + """Fetched price must fall within the configured sanity bounds. - This is a loose check that guards against obviously wrong responses - (e.g. CoinGecko returning a different currency or a zero value). - Update bounds if ETH moves outside this range for an extended period. + Bounds live in config.py (OPG_PRICE_SANITY_MIN_USD / MAX_USD) so they + stay in sync automatically when switching from the ETH proxy to real OPG. """ + from tee_gateway.config import OPG_PRICE_SANITY_MAX_USD, OPG_PRICE_SANITY_MIN_USD from tee_gateway.util import _fetch_opg_price_usd price = _fetch_opg_price_usd() - assert Decimal("100") < price < Decimal("100000"), ( - f"ETH price ${price} is outside the expected sanity range $100–$100 000" + min_price = Decimal(OPG_PRICE_SANITY_MIN_USD) + max_price = Decimal(OPG_PRICE_SANITY_MAX_USD) + assert min_price < price < max_price, ( + f"Price ${price} is outside the configured sanity range " + f"${OPG_PRICE_SANITY_MIN_USD}–${OPG_PRICE_SANITY_MAX_USD}" ) def test_get_token_a_price_usd_returns_cached_value(self): From e0a4b30063fa5cd85ef93c7259bf1d0e71741e51 Mon Sep 17 00:00:00 2001 From: kylexqian Date: Thu, 16 Apr 2026 02:41:40 -0700 Subject: [PATCH 13/25] refactor: replace hardcoded ETH references with OPG_PRICE_COINGECKO_ID All mentions of ETH/ethereum in comments, log messages, error strings, and test helpers now refer to OPG_PRICE_COINGECKO_ID so that switching to the real OPG token requires only a one-line change in config.py. - util.py: docstring, error message, and log line use the coin ID var - test_util.py: mock response body and URL assertion key off the config - test_integration.py: comments and print output use the coin ID var - config.py: comments no longer mention ETH specifically Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/config.py | 6 +++--- tee_gateway/test/test_util.py | 11 ++++++----- tee_gateway/util.py | 9 ++++----- tests/test_integration.py | 13 +++++++------ 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/tee_gateway/config.py b/tee_gateway/config.py index c093591..aaa9e1d 100644 --- a/tee_gateway/config.py +++ b/tee_gateway/config.py @@ -24,14 +24,14 @@ # Fallback OPG/USD price used only when no successful fetch has ever been # made (e.g. network unavailable on the very first request). -# Set to a conservative ETH ballpark; update when switching to real OPG. +# Should be a rough USD value for the token identified by OPG_PRICE_COINGECKO_ID. OPG_PRICE_HARD_FALLBACK_USD: str = "2000" # Sanity bounds for the fetched token price. # These are used in integration tests to catch obviously wrong API responses # (wrong currency, zero, implausibly large value). -# OPG may be worth very little per token, so the lower bound is intentionally -# very small. Update both bounds when switching from ETH proxy to real OPG. +# Set these to a plausible range for whatever token OPG_PRICE_COINGECKO_ID points to. +# The lower bound is intentionally very small to accommodate a low-value token. OPG_PRICE_SANITY_MIN_USD: str = "0.000001" # $0.000001 — rules out zero/negative OPG_PRICE_SANITY_MAX_USD: str = "1000000" # $1 000 000 — rules out obviously corrupt data diff --git a/tee_gateway/test/test_util.py b/tee_gateway/test/test_util.py index fa47e8d..8dd827d 100644 --- a/tee_gateway/test/test_util.py +++ b/tee_gateway/test/test_util.py @@ -12,6 +12,7 @@ from unittest.mock import MagicMock, patch from tee_gateway import util +from tee_gateway.config import OPG_PRICE_COINGECKO_ID from tee_gateway.util import ( _fetch_opg_price_usd, _token_price_cache, @@ -27,7 +28,7 @@ def _make_urlopen_response(price: float) -> MagicMock: """Return a mock context-manager that urlopen returns with a CoinGecko payload.""" - body = json.dumps({"ethereum": {"usd": price}}).encode() + body = json.dumps({OPG_PRICE_COINGECKO_ID: {"usd": price}}).encode() mock_resp = MagicMock() mock_resp.read.return_value = body mock_resp.__enter__ = lambda s: s @@ -66,7 +67,7 @@ def test_uses_configured_coingecko_id_in_url(self, mock_urlopen): call_args = mock_urlopen.call_args # First positional arg is the Request object req = call_args[0][0] - self.assertIn("ethereum", req.full_url) + self.assertIn(OPG_PRICE_COINGECKO_ID, req.full_url) @patch("tee_gateway.util.urllib.request.urlopen") def test_raises_on_non_positive_price(self, mock_urlopen): @@ -218,12 +219,12 @@ def test_more_tokens_increases_cost(self, mock_urlopen): @patch("tee_gateway.util.urllib.request.urlopen") def test_cost_scales_correctly(self, mock_urlopen): - """Spot-check the math for gpt-4.1 at a known ETH price. + """Spot-check the math for gpt-4.1 at a known token price of $3 000. gpt-4.1 input: $0.000002/token, output: $0.000008/token 100 input + 50 output = $0.0002 + $0.0004 = $0.0006 USD - At ETH = $3 000: 0.0006 / 3000 = 0.0000002 ETH - In wei (10^18): 200 000 000 000 = 200_000_000_000 + At token price = $3 000: 0.0006 / 3000 = 0.0000002 tokens + In smallest units (10^18 decimals): 200_000_000_000 """ mock_urlopen.return_value = _make_urlopen_response(3000.0) cost = dynamic_session_cost_calculator( diff --git a/tee_gateway/util.py b/tee_gateway/util.py index 74a325f..10878cd 100644 --- a/tee_gateway/util.py +++ b/tee_gateway/util.py @@ -183,9 +183,8 @@ def _deserialize_dict(data, boxed_type): def _fetch_opg_price_usd() -> Decimal: """Fetch the OPG/USD price from CoinGecko. - OPG has not launched yet, so we proxy it with ETH/USD until OPG is listed. - Swap the CoinGecko coin ID to ``opg-token`` (or whatever slug CoinGecko - assigns) once OPG is live. + The token queried is controlled by OPG_PRICE_COINGECKO_ID in config.py. + Update that value to the CoinGecko slug for OPG once the token is listed. """ url = ( f"https://api.coingecko.com/api/v3/simple/price" @@ -196,7 +195,7 @@ def _fetch_opg_price_usd() -> Decimal: data: dict[str, Any] = json.loads(resp.read()) price = Decimal(str(data[OPG_PRICE_COINGECKO_ID]["usd"])) if price <= 0: - raise ValueError(f"CoinGecko returned non-positive ETH price: {price}") + raise ValueError(f"CoinGecko returned non-positive price for '{OPG_PRICE_COINGECKO_ID}': {price}") return price @@ -225,7 +224,7 @@ def get_token_a_price_usd() -> Decimal: value = _fetch_opg_price_usd() _token_price_cache["last_good"] = value _token_price_cache["updated_at"] = now - logger.info("OPG price refreshed: $%s (ETH proxy)", value) + logger.info("OPG price refreshed: $%s (via CoinGecko '%s')", value, OPG_PRICE_COINGECKO_ID) return value except Exception as exc: if last_good is not None: diff --git a/tests/test_integration.py b/tests/test_integration.py index 5a75bca..9f34f5c 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -15,7 +15,7 @@ @pytest.mark.integration class TestCoinGeckoPriceFeed: - """Verify the live OPG/ETH price fetch end-to-end.""" + """Verify the live OPG price fetch end-to-end via the configured CoinGecko token.""" def test_fetch_returns_positive_decimal(self): """_fetch_opg_price_usd must return a positive Decimal from CoinGecko.""" @@ -28,8 +28,8 @@ def test_fetch_returns_positive_decimal(self): def test_price_is_within_sanity_bounds(self): """Fetched price must fall within the configured sanity bounds. - Bounds live in config.py (OPG_PRICE_SANITY_MIN_USD / MAX_USD) so they - stay in sync automatically when switching from the ETH proxy to real OPG. + Bounds live in config.py (OPG_PRICE_SANITY_MIN_USD / MAX_USD) alongside + OPG_PRICE_COINGECKO_ID, so all three values stay in sync when the token changes. """ from tee_gateway.config import OPG_PRICE_SANITY_MAX_USD, OPG_PRICE_SANITY_MIN_USD from tee_gateway.util import _fetch_opg_price_usd @@ -58,7 +58,7 @@ def test_get_token_a_price_usd_returns_cached_value(self): assert first > 0 def test_dynamic_cost_uses_live_price(self): - """Full pipeline: token counts + live ETH price → positive on-chain units.""" + """Full pipeline: token counts + live token price → positive on-chain units.""" from tee_gateway.definitions import BASE_TESTNET_OPG_ADDRESS from tee_gateway.util import _token_price_cache, dynamic_session_cost_calculator, get_token_a_price_usd @@ -81,8 +81,9 @@ def test_dynamic_cost_uses_live_price(self): assert cost > 0 # Sanity: cost should be far less than 1 full OPG (10^18 units) - # for a small request at any plausible ETH price + # for a small request at any plausible token price assert cost < 10 ** 18, f"Cost {cost} seems too large for a small request" - print(f"\nLive ETH price: ${live_price}") + from tee_gateway.config import OPG_PRICE_COINGECKO_ID + print(f"\nLive price ({OPG_PRICE_COINGECKO_ID}): ${live_price}") print(f"Cost for gpt-4.1 (1000 input + 500 output tokens): {cost} OPG units") From e9f0d27216c7dd72c9a505a037cdc93e3061ef4b Mon Sep 17 00:00:00 2001 From: kylexqian Date: Thu, 16 Apr 2026 02:43:17 -0700 Subject: [PATCH 14/25] Lint --- tee_gateway/config.py | 6 ++++-- tee_gateway/test/test_util.py | 16 ++++++++++++---- tee_gateway/util.py | 10 ++++++++-- tests/test_integration.py | 14 +++++++++++--- tests/test_pricing.py | 4 +++- 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/tee_gateway/config.py b/tee_gateway/config.py index aaa9e1d..153c575 100644 --- a/tee_gateway/config.py +++ b/tee_gateway/config.py @@ -32,8 +32,10 @@ # (wrong currency, zero, implausibly large value). # Set these to a plausible range for whatever token OPG_PRICE_COINGECKO_ID points to. # The lower bound is intentionally very small to accommodate a low-value token. -OPG_PRICE_SANITY_MIN_USD: str = "0.000001" # $0.000001 — rules out zero/negative -OPG_PRICE_SANITY_MAX_USD: str = "1000000" # $1 000 000 — rules out obviously corrupt data +OPG_PRICE_SANITY_MIN_USD: str = "0.000001" # $0.000001 — rules out zero/negative +OPG_PRICE_SANITY_MAX_USD: str = ( + "1000000" # $1 000 000 — rules out obviously corrupt data +) # --------------------------------------------------------------------------- # Heartbeat defaults diff --git a/tee_gateway/test/test_util.py b/tee_gateway/test/test_util.py index 8dd827d..557e635 100644 --- a/tee_gateway/test/test_util.py +++ b/tee_gateway/test/test_util.py @@ -8,7 +8,6 @@ import json import unittest from decimal import Decimal -from io import BytesIO from unittest.mock import MagicMock, patch from tee_gateway import util @@ -125,6 +124,7 @@ def test_cache_expires_after_ttl(self, mock_time, mock_urlopen): # Advance time past TTL from tee_gateway.config import OPG_PRICE_CACHE_TTL_SECONDS + mock_time.time.return_value = 1_000_000.0 + OPG_PRICE_CACHE_TTL_SECONDS + 1 mock_urlopen.return_value = _make_urlopen_response(3500.0) price = get_token_a_price_usd() @@ -147,7 +147,9 @@ def test_stale_cache_used_on_refresh_failure(self, mock_urlopen): self.assertEqual(second, Decimal("3000.0")) # stale value returned @patch("tee_gateway.util.urllib.request.urlopen") - def test_hard_fallback_used_when_never_fetched_and_network_fails(self, mock_urlopen): + def test_hard_fallback_used_when_never_fetched_and_network_fails( + self, mock_urlopen + ): """With empty cache and a failing network, the hard fallback price is returned.""" mock_urlopen.side_effect = OSError("network down") price = get_token_a_price_usd() @@ -249,7 +251,9 @@ def test_raises_when_usage_missing(self): ctx = { "request_json": {"model": "gpt-4.1"}, "response_json": {}, - "payment_requirements": {"asset": "0x240b09731D96979f50B2C649C9CE10FcF9C7987F"}, + "payment_requirements": { + "asset": "0x240b09731D96979f50B2C649C9CE10FcF9C7987F" + }, } with self.assertRaises(ValueError): dynamic_session_cost_calculator(ctx) @@ -257,7 +261,11 @@ def test_raises_when_usage_missing(self): def test_raises_when_request_json_missing(self): with self.assertRaises(ValueError): dynamic_session_cost_calculator( - {"response_json": {"usage": {"prompt_tokens": 1, "completion_tokens": 1}}} + { + "response_json": { + "usage": {"prompt_tokens": 1, "completion_tokens": 1} + } + } ) diff --git a/tee_gateway/util.py b/tee_gateway/util.py index 10878cd..214c6bd 100644 --- a/tee_gateway/util.py +++ b/tee_gateway/util.py @@ -195,7 +195,9 @@ def _fetch_opg_price_usd() -> Decimal: data: dict[str, Any] = json.loads(resp.read()) price = Decimal(str(data[OPG_PRICE_COINGECKO_ID]["usd"])) if price <= 0: - raise ValueError(f"CoinGecko returned non-positive price for '{OPG_PRICE_COINGECKO_ID}': {price}") + raise ValueError( + f"CoinGecko returned non-positive price for '{OPG_PRICE_COINGECKO_ID}': {price}" + ) return price @@ -224,7 +226,11 @@ def get_token_a_price_usd() -> Decimal: value = _fetch_opg_price_usd() _token_price_cache["last_good"] = value _token_price_cache["updated_at"] = now - logger.info("OPG price refreshed: $%s (via CoinGecko '%s')", value, OPG_PRICE_COINGECKO_ID) + logger.info( + "OPG price refreshed: $%s (via CoinGecko '%s')", + value, + OPG_PRICE_COINGECKO_ID, + ) return value except Exception as exc: if last_good is not None: diff --git a/tests/test_integration.py b/tests/test_integration.py index 9f34f5c..daeae5a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -31,7 +31,10 @@ def test_price_is_within_sanity_bounds(self): Bounds live in config.py (OPG_PRICE_SANITY_MIN_USD / MAX_USD) alongside OPG_PRICE_COINGECKO_ID, so all three values stay in sync when the token changes. """ - from tee_gateway.config import OPG_PRICE_SANITY_MAX_USD, OPG_PRICE_SANITY_MIN_USD + from tee_gateway.config import ( + OPG_PRICE_SANITY_MAX_USD, + OPG_PRICE_SANITY_MIN_USD, + ) from tee_gateway.util import _fetch_opg_price_usd price = _fetch_opg_price_usd() @@ -60,7 +63,11 @@ def test_get_token_a_price_usd_returns_cached_value(self): def test_dynamic_cost_uses_live_price(self): """Full pipeline: token counts + live token price → positive on-chain units.""" from tee_gateway.definitions import BASE_TESTNET_OPG_ADDRESS - from tee_gateway.util import _token_price_cache, dynamic_session_cost_calculator, get_token_a_price_usd + from tee_gateway.util import ( + _token_price_cache, + dynamic_session_cost_calculator, + get_token_a_price_usd, + ) # Reset cache to force a live fetch _token_price_cache["last_good"] = None @@ -82,8 +89,9 @@ def test_dynamic_cost_uses_live_price(self): # Sanity: cost should be far less than 1 full OPG (10^18 units) # for a small request at any plausible token price - assert cost < 10 ** 18, f"Cost {cost} seems too large for a small request" + assert cost < 10**18, f"Cost {cost} seems too large for a small request" from tee_gateway.config import OPG_PRICE_COINGECKO_ID + print(f"\nLive price ({OPG_PRICE_COINGECKO_ID}): ${live_price}") print(f"Cost for gpt-4.1 (1000 input + 500 output tokens): {cost} OPG units") diff --git a/tests/test_pricing.py b/tests/test_pricing.py index 98353e0..2f1ef50 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -27,7 +27,9 @@ # test_integration.py. A $1 pin keeps the hardcoded expected wei values valid # and makes the test suite fully deterministic and offline. # --------------------------------------------------------------------------- -_price_patcher = patch("tee_gateway.util.get_token_a_price_usd", return_value=Decimal("1")) +_price_patcher = patch( + "tee_gateway.util.get_token_a_price_usd", return_value=Decimal("1") +) def setUpModule(): From 8f9f9ebab334de5a8cfb6230dddbaaeb05bd85cd Mon Sep 17 00:00:00 2001 From: kylexqian Date: Thu, 16 Apr 2026 02:53:05 -0700 Subject: [PATCH 15/25] fix: remove dead _resolve_session_request_cost monkey-patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That method no longer exists in the current og-x402 library — the patch was a no-op and mypy flagged it as attr-defined on PaymentMiddleware. In the current library the session_cost_calculator is called inside _accumulate_session_cost(), which is wrapped by a broad try/except in StreamingSessionResponse.close(). Replace the dead code with a comment explaining the current behaviour and where to patch if stricter error propagation is needed in future. Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/__main__.py | 100 +++++----------------------------------- 1 file changed, 11 insertions(+), 89 deletions(-) diff --git a/tee_gateway/__main__.py b/tee_gateway/__main__.py index 1ba44c2..ba3f83b 100644 --- a/tee_gateway/__main__.py +++ b/tee_gateway/__main__.py @@ -384,99 +384,21 @@ def _patched_read_body_bytes(environ): ) # --------------------------------------------------------------------------- -# Strict cost-resolution patch +# Cost-resolution behaviour note # -# Why this exists -# --------------- -# The upstream x402 PaymentMiddleware._resolve_session_request_cost wraps the -# call to the session_cost_calculator in a broad try/except. If the calculator -# raises (e.g. ValueError for an unrecognised model name, KeyError for missing -# usage data), the exception is swallowed and the middleware silently falls back -# to the static session maximum (CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND / -# CHAT_COMPLETIONS_USDC_AMOUNT). That silent fallback means: -# • The client is charged the full pre-check cap instead of actual usage. -# • The server has no visible indication that pricing failed. +# dynamic_session_cost_calculator() is invoked by PaymentMiddleware via +# _accumulate_session_cost(), which is itself called from +# StreamingSessionResponse.close(). That close() wraps the call in a broad +# try/except, so any exception raised by the calculator (e.g. ValueError for +# an unrecognised model or missing usage data) is logged but otherwise +# swallowed — the session cost is simply not incremented for that request. # -# The fix -# ------- -# We replace _resolve_session_request_cost with our own implementation that is -# identical to upstream, except the cost-calculator call is NOT wrapped in a -# try/except. Any exception from dynamic_session_cost_calculator() therefore -# propagates up through the middleware and Flask, producing a proper HTTP 500 -# response to the client instead of an incorrect silent charge. +# A previous version of this file monkey-patched _resolve_session_request_cost +# to let exceptions propagate, but that method no longer exists in the library. +# If stricter error propagation is needed in future, patch _accumulate_session_cost +# or StreamingSessionResponse.close() instead. # --------------------------------------------------------------------------- - -def _strict_resolve_session_request_cost( - self, - *, - method: str, - path: str, - request_body_bytes: bytes, - response_body_bytes: bytes, - payment_payload: object, - payment_requirements: object, - status_code: int | None, - output_object: object = None, - is_streaming: bool = False, -) -> int: - """Replacement for PaymentMiddleware._resolve_session_request_cost. - - Identical to the upstream implementation except that exceptions raised by - the dynamic cost calculator are NOT caught. This means a request whose - cost cannot be determined (unknown model, missing usage data, etc.) will - result in a 500 error rather than silently falling back to the static cap - amount and charging the user an incorrect amount. - """ - from x402.http.middleware.flask import _parse_json_bytes as _x402_parse_json # noqa: PLC0415 - - default_cost = self._get_session_cost(payment_requirements) - if not self._should_charge_response(status_code): - return default_cost - if not callable(self._session_cost_calculator): - return default_cost - - request_object = _x402_parse_json(request_body_bytes) - response_object = ( - output_object - if output_object is not None - else _x402_parse_json(response_body_bytes) - ) - - callback_context = { - "method": method, - "path": path, - "status_code": status_code, - "is_streaming": is_streaming, - "request_body_bytes": request_body_bytes, - "response_body_bytes": response_body_bytes, - "request_json": request_object - if isinstance(request_object, (dict, list)) - else None, - "response_json": response_object - if isinstance(response_object, (dict, list)) - else None, - "response_object": response_object, - "payment_payload": payment_payload, - "payment_requirements": payment_requirements, - "default_cost": default_cost, - } - - # Do NOT catch exceptions here — let them propagate so the request fails - # with a 500 rather than silently charging the static fallback amount. - dynamic_cost = self._session_cost_calculator(callback_context) - if dynamic_cost is None: - raise ValueError( - f"dynamic_session_cost_calculator returned None for {method} {path}; " - "cannot determine request cost" - ) - return self._coerce_non_negative_int(dynamic_cost) - - -_payment_mw._resolve_session_request_cost = _types.MethodType( # type: ignore[method-assign] - _strict_resolve_session_request_cost, _payment_mw -) - logger.info("x402 payment middleware initialized") if __name__ == "__main__": From edda294f1696be5936db9280ec39109a920a6801 Mon Sep 17 00:00:00 2001 From: kylexqian Date: Thu, 16 Apr 2026 02:55:40 -0700 Subject: [PATCH 16/25] More lint changes --- tee_gateway/__main__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tee_gateway/__main__.py b/tee_gateway/__main__.py index ba3f83b..7d6b63a 100644 --- a/tee_gateway/__main__.py +++ b/tee_gateway/__main__.py @@ -34,7 +34,6 @@ from x402.server import x402ResourceServerSync from x402.session import SessionStore import x402.http.middleware.flask as x402_flask -import types as _types from .util import dynamic_session_cost_calculator from .definitions import ( From 3ca5afa5a3de64844b5472613260e2eb98e688ec Mon Sep 17 00:00:00 2001 From: kylexqian Date: Thu, 16 Apr 2026 11:47:06 -0700 Subject: [PATCH 17/25] feat: retry CoinGecko price fetch up to OPG_PRICE_FETCH_RETRIES times Add OPG_PRICE_FETCH_RETRIES = 3 to config.py. _fetch_opg_price_usd now loops up to that many attempts, logging a warning on each failure and re-raising the last exception if all attempts are exhausted. Add two tests: succeeds-on-last-attempt and raises-after-all-retries, both keyed off the config constant so they stay correct if the default changes. Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/config.py | 4 ++++ tee_gateway/test/test_util.py | 23 +++++++++++++++++++++++ tee_gateway/util.py | 34 ++++++++++++++++++++++++---------- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/tee_gateway/config.py b/tee_gateway/config.py index 153c575..59eed39 100644 --- a/tee_gateway/config.py +++ b/tee_gateway/config.py @@ -18,6 +18,10 @@ # the free-tier limit (30/min). OPG_PRICE_CACHE_TTL_SECONDS: int = 120 +# Number of times to retry a failed CoinGecko fetch before giving up. +# Each attempt uses the same 5-second timeout; retries are immediate (no backoff). +OPG_PRICE_FETCH_RETRIES: int = 3 + # CoinGecko coin ID used as the OPG price proxy until OPG is listed. # Switch this to the CoinGecko slug for OPG once the token launches. OPG_PRICE_COINGECKO_ID: str = "ethereum" diff --git a/tee_gateway/test/test_util.py b/tee_gateway/test/test_util.py index 557e635..9e039e5 100644 --- a/tee_gateway/test/test_util.py +++ b/tee_gateway/test/test_util.py @@ -90,6 +90,29 @@ def test_raises_on_malformed_json(self, mock_urlopen): with self.assertRaises(Exception): _fetch_opg_price_usd() + @patch("tee_gateway.util.urllib.request.urlopen") + def test_retries_on_failure_then_succeeds(self, mock_urlopen): + """Succeeds on the final attempt after earlier failures.""" + from tee_gateway.config import OPG_PRICE_FETCH_RETRIES + + mock_urlopen.side_effect = ( + [OSError("timeout")] * (OPG_PRICE_FETCH_RETRIES - 1) + + [_make_urlopen_response(3000.0)] + ) + price = _fetch_opg_price_usd() + self.assertEqual(price, Decimal("3000.0")) + self.assertEqual(mock_urlopen.call_count, OPG_PRICE_FETCH_RETRIES) + + @patch("tee_gateway.util.urllib.request.urlopen") + def test_raises_after_all_retries_exhausted(self, mock_urlopen): + """Raises the last exception once all retry attempts are used up.""" + from tee_gateway.config import OPG_PRICE_FETCH_RETRIES + + mock_urlopen.side_effect = OSError("connection refused") + with self.assertRaises(OSError): + _fetch_opg_price_usd() + self.assertEqual(mock_urlopen.call_count, OPG_PRICE_FETCH_RETRIES) + # --------------------------------------------------------------------------- # get_token_a_price_usd — caching behaviour diff --git a/tee_gateway/util.py b/tee_gateway/util.py index 214c6bd..cc94f22 100644 --- a/tee_gateway/util.py +++ b/tee_gateway/util.py @@ -161,6 +161,7 @@ def _deserialize_dict(data, boxed_type): from tee_gateway.config import ( # noqa: E402 OPG_PRICE_CACHE_TTL_SECONDS, OPG_PRICE_COINGECKO_ID, + OPG_PRICE_FETCH_RETRIES, OPG_PRICE_HARD_FALLBACK_USD, ) from tee_gateway.definitions import ( # noqa: E402 @@ -181,24 +182,37 @@ def _deserialize_dict(data, boxed_type): def _fetch_opg_price_usd() -> Decimal: - """Fetch the OPG/USD price from CoinGecko. + """Fetch the OPG/USD price from CoinGecko, retrying up to OPG_PRICE_FETCH_RETRIES times. The token queried is controlled by OPG_PRICE_COINGECKO_ID in config.py. Update that value to the CoinGecko slug for OPG once the token is listed. + Raises the last exception if all attempts fail. """ url = ( f"https://api.coingecko.com/api/v3/simple/price" f"?ids={OPG_PRICE_COINGECKO_ID}&vs_currencies=usd" ) - req = urllib.request.Request(url, headers={"User-Agent": "tee-gateway/1.0"}) - with urllib.request.urlopen(req, timeout=5) as resp: - data: dict[str, Any] = json.loads(resp.read()) - price = Decimal(str(data[OPG_PRICE_COINGECKO_ID]["usd"])) - if price <= 0: - raise ValueError( - f"CoinGecko returned non-positive price for '{OPG_PRICE_COINGECKO_ID}': {price}" - ) - return price + last_exc: Exception = RuntimeError("no attempts made") + for attempt in range(1, OPG_PRICE_FETCH_RETRIES + 1): + try: + req = urllib.request.Request(url, headers={"User-Agent": "tee-gateway/1.0"}) + with urllib.request.urlopen(req, timeout=5) as resp: + data: dict[str, Any] = json.loads(resp.read()) + price = Decimal(str(data[OPG_PRICE_COINGECKO_ID]["usd"])) + if price <= 0: + raise ValueError( + f"CoinGecko returned non-positive price for '{OPG_PRICE_COINGECKO_ID}': {price}" + ) + return price + except Exception as exc: + last_exc = exc + logger.warning( + "CoinGecko price fetch attempt %d/%d failed: %s", + attempt, + OPG_PRICE_FETCH_RETRIES, + exc, + ) + raise last_exc def get_token_a_price_usd() -> Decimal: From bada03eacdba8046407cfb0490e6566b3431c7e6 Mon Sep 17 00:00:00 2001 From: kylexqian Date: Thu, 16 Apr 2026 11:59:39 -0700 Subject: [PATCH 18/25] Fix lint --- tee_gateway/test/test_util.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tee_gateway/test/test_util.py b/tee_gateway/test/test_util.py index 9e039e5..099d156 100644 --- a/tee_gateway/test/test_util.py +++ b/tee_gateway/test/test_util.py @@ -95,10 +95,9 @@ def test_retries_on_failure_then_succeeds(self, mock_urlopen): """Succeeds on the final attempt after earlier failures.""" from tee_gateway.config import OPG_PRICE_FETCH_RETRIES - mock_urlopen.side_effect = ( - [OSError("timeout")] * (OPG_PRICE_FETCH_RETRIES - 1) - + [_make_urlopen_response(3000.0)] - ) + mock_urlopen.side_effect = [OSError("timeout")] * ( + OPG_PRICE_FETCH_RETRIES - 1 + ) + [_make_urlopen_response(3000.0)] price = _fetch_opg_price_usd() self.assertEqual(price, Decimal("3000.0")) self.assertEqual(mock_urlopen.call_count, OPG_PRICE_FETCH_RETRIES) From 355f4dd41192a91cb3ee41376dfe90a485e6387e Mon Sep 17 00:00:00 2001 From: kylexqian Date: Fri, 17 Apr 2026 01:58:01 -0700 Subject: [PATCH 19/25] feat: reject inference requests pre-flight if pricing would fail Add validate_pricing_preflight(model) to util.py. It checks two things before any LLM call is made: 1. model is in the registry (get_model_config raises on unknown models) 2. token price is positive (guards against a degenerate price feed state) Both controllers call it immediately after parsing the request and return 400 Bad Request if it raises, so an unrecognised model or broken price feed is rejected before inference rather than silently producing free inference after the response has already been sent. Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/controllers/chat_controller.py | 6 ++++++ .../controllers/completions_controller.py | 6 ++++++ tee_gateway/util.py | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/tee_gateway/controllers/chat_controller.py b/tee_gateway/controllers/chat_controller.py index 49fb406..356dda4 100644 --- a/tee_gateway/controllers/chat_controller.py +++ b/tee_gateway/controllers/chat_controller.py @@ -30,6 +30,7 @@ convert_messages, extract_usage, ) +from tee_gateway.util import validate_pricing_preflight logger = logging.getLogger(__name__) @@ -46,6 +47,11 @@ def create_chat_completion(body): connexion.request.get_json() ) + try: + validate_pricing_preflight(chat_request.model) + except ValueError as exc: + return {"error": "Bad Request", "message": str(exc)}, 400 + if chat_request.stream: return _create_streaming_response(chat_request) else: diff --git a/tee_gateway/controllers/completions_controller.py b/tee_gateway/controllers/completions_controller.py index ba941fe..2ed7ccf 100644 --- a/tee_gateway/controllers/completions_controller.py +++ b/tee_gateway/controllers/completions_controller.py @@ -10,6 +10,7 @@ from tee_gateway.tee_manager import get_tee_keys, compute_tee_msg_hash from tee_gateway.llm_backend import get_chat_model_cached, extract_usage +from tee_gateway.util import validate_pricing_preflight logger = logging.getLogger(__name__) @@ -21,6 +22,11 @@ def create_completion(body): else: return {"error": "Request must be application/json"}, 415 + try: + validate_pricing_preflight(body.model) + except ValueError as exc: + return {"error": "Bad Request", "message": str(exc)}, 400 + try: request_dict = { "model": body.model, diff --git a/tee_gateway/util.py b/tee_gateway/util.py index cc94f22..395fc5e 100644 --- a/tee_gateway/util.py +++ b/tee_gateway/util.py @@ -368,6 +368,25 @@ def _extract_asset_decimals_from_requirements(payment_requirements: Any) -> int: return ASSET_DECIMALS_BY_ADDRESS[asset_lower] +def validate_pricing_preflight(model: str) -> None: + """Validate that this request can be priced before any LLM call is made. + + Raises ValueError if the model is not in the registry, or if no token + price is available (price feed down and hard fallback somehow non-positive). + + Call this at the top of each request handler so that a pricing failure + returns a proper error to the client rather than silently producing free + inference after the response has already been sent. + """ + get_model_config(model) # raises ValueError for unknown models + + price = get_token_a_price_usd() + if price <= 0: + raise ValueError( + f"Token price is non-positive ({price}); cannot price inference request" + ) + + def dynamic_session_cost_calculator(context: dict[str, Any]) -> int: """Compute UPTO per-request cost in token smallest units from actual usage. From d02d590e896b1f0071cd05ca80c969f78079231c Mon Sep 17 00:00:00 2001 From: kylexqian Date: Fri, 17 Apr 2026 02:05:09 -0700 Subject: [PATCH 20/25] Switch OPG price feed to real CoinGecko slug; remove silent fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.py: use "opengradient" as OPG_PRICE_COINGECKO_ID; drop OPG_PRICE_HARD_FALLBACK_USD and OPG_PRICE_SANITY_MIN_USD - util.py: raise clear ValueError when CoinGecko returns no price for the coin (token listed but no trading price yet); remove stale-cache and hard-fallback branches in get_token_a_price_usd — failures now propagate so inference is blocked with a 400 rather than silently using a made-up price - test_util.py: replace hard-fallback test with a "no usd key" test; update stale-cache test to assert the error propagates - test_integration.py: restructure around OPG having no price yet — verify the slug is recognised, verify the correct error is raised, skip price-dependent tests until OPG begins trading Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/config.py | 19 +++------ tee_gateway/test/test_util.py | 37 ++++++++++------- tee_gateway/util.py | 56 ++++++++++---------------- tests/test_integration.py | 75 +++++++++++++++++++++++++++++------ 4 files changed, 111 insertions(+), 76 deletions(-) diff --git a/tee_gateway/config.py b/tee_gateway/config.py index 59eed39..8fb25c1 100644 --- a/tee_gateway/config.py +++ b/tee_gateway/config.py @@ -22,21 +22,14 @@ # Each attempt uses the same 5-second timeout; retries are immediate (no backoff). OPG_PRICE_FETCH_RETRIES: int = 3 -# CoinGecko coin ID used as the OPG price proxy until OPG is listed. -# Switch this to the CoinGecko slug for OPG once the token launches. -OPG_PRICE_COINGECKO_ID: str = "ethereum" - -# Fallback OPG/USD price used only when no successful fetch has ever been -# made (e.g. network unavailable on the very first request). -# Should be a rough USD value for the token identified by OPG_PRICE_COINGECKO_ID. -OPG_PRICE_HARD_FALLBACK_USD: str = "2000" +# CoinGecko coin ID for the OPG token. +# https://www.coingecko.com/en/coins/opengradient +OPG_PRICE_COINGECKO_ID: str = "opengradient" # Sanity bounds for the fetched token price. -# These are used in integration tests to catch obviously wrong API responses -# (wrong currency, zero, implausibly large value). -# Set these to a plausible range for whatever token OPG_PRICE_COINGECKO_ID points to. -# The lower bound is intentionally very small to accommodate a low-value token. -OPG_PRICE_SANITY_MIN_USD: str = "0.000001" # $0.000001 — rules out zero/negative +# Used in integration tests to catch obviously wrong API responses +# (wrong currency, implausibly large value). +# Update when OPG establishes a trading range. OPG_PRICE_SANITY_MAX_USD: str = ( "1000000" # $1 000 000 — rules out obviously corrupt data ) diff --git a/tee_gateway/test/test_util.py b/tee_gateway/test/test_util.py index 099d156..94d9de3 100644 --- a/tee_gateway/test/test_util.py +++ b/tee_gateway/test/test_util.py @@ -10,7 +10,6 @@ from decimal import Decimal from unittest.mock import MagicMock, patch -from tee_gateway import util from tee_gateway.config import OPG_PRICE_COINGECKO_ID from tee_gateway.util import ( _fetch_opg_price_usd, @@ -90,6 +89,19 @@ def test_raises_on_malformed_json(self, mock_urlopen): with self.assertRaises(Exception): _fetch_opg_price_usd() + @patch("tee_gateway.util.urllib.request.urlopen") + def test_raises_when_coin_has_no_price(self, mock_urlopen): + """CoinGecko returns the coin but without a 'usd' key (no trading price yet).""" + body = json.dumps({OPG_PRICE_COINGECKO_ID: {}}).encode() + mock_resp = MagicMock() + mock_resp.read.return_value = body + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + mock_urlopen.return_value = mock_resp + with self.assertRaises(ValueError) as ctx: + _fetch_opg_price_usd() + self.assertIn("no price", str(ctx.exception)) + @patch("tee_gateway.util.urllib.request.urlopen") def test_retries_on_failure_then_succeeds(self, mock_urlopen): """Succeeds on the final attempt after earlier failures.""" @@ -155,28 +167,23 @@ def test_cache_expires_after_ttl(self, mock_time, mock_urlopen): self.assertEqual(price, Decimal("3500.0")) @patch("tee_gateway.util.urllib.request.urlopen") - def test_stale_cache_used_on_refresh_failure(self, mock_urlopen): - """If the cache is populated but a refresh fails, the last good price is returned.""" + def test_raises_on_refresh_failure(self, mock_urlopen): + """If the cache is expired and a refresh fails, the error is raised immediately.""" mock_urlopen.return_value = _make_urlopen_response(3000.0) - first = get_token_a_price_usd() - self.assertEqual(first, Decimal("3000.0")) + get_token_a_price_usd() # populate cache # Force cache to appear expired then make the refresh fail _token_price_cache["updated_at"] = 0.0 mock_urlopen.side_effect = OSError("network down") - second = get_token_a_price_usd() - - self.assertEqual(second, Decimal("3000.0")) # stale value returned + with self.assertRaises(OSError): + get_token_a_price_usd() @patch("tee_gateway.util.urllib.request.urlopen") - def test_hard_fallback_used_when_never_fetched_and_network_fails( - self, mock_urlopen - ): - """With empty cache and a failing network, the hard fallback price is returned.""" + def test_raises_when_never_fetched_and_network_fails(self, mock_urlopen): + """With empty cache and a failing network, the exception propagates.""" mock_urlopen.side_effect = OSError("network down") - price = get_token_a_price_usd() - self.assertEqual(price, util._PRICE_HARD_FALLBACK_USD) - self.assertGreater(price, 0) + with self.assertRaises(OSError): + get_token_a_price_usd() # --------------------------------------------------------------------------- diff --git a/tee_gateway/util.py b/tee_gateway/util.py index 395fc5e..910a0d4 100644 --- a/tee_gateway/util.py +++ b/tee_gateway/util.py @@ -162,15 +162,12 @@ def _deserialize_dict(data, boxed_type): OPG_PRICE_CACHE_TTL_SECONDS, OPG_PRICE_COINGECKO_ID, OPG_PRICE_FETCH_RETRIES, - OPG_PRICE_HARD_FALLBACK_USD, ) from tee_gateway.definitions import ( # noqa: E402 ASSET_DECIMALS_BY_ADDRESS, ) from tee_gateway.model_registry import get_model_config # noqa: E402 -_PRICE_HARD_FALLBACK_USD = Decimal(OPG_PRICE_HARD_FALLBACK_USD) - # Cache layout: # "last_good" – most recent successfully fetched price (Decimal | None) # "updated_at" – epoch seconds of last successful fetch (float) @@ -185,8 +182,8 @@ def _fetch_opg_price_usd() -> Decimal: """Fetch the OPG/USD price from CoinGecko, retrying up to OPG_PRICE_FETCH_RETRIES times. The token queried is controlled by OPG_PRICE_COINGECKO_ID in config.py. - Update that value to the CoinGecko slug for OPG once the token is listed. - Raises the last exception if all attempts fail. + Raises ValueError if the token is listed but has no price data yet. + Raises the last exception if all network attempts fail. """ url = ( f"https://api.coingecko.com/api/v3/simple/price" @@ -198,7 +195,13 @@ def _fetch_opg_price_usd() -> Decimal: req = urllib.request.Request(url, headers={"User-Agent": "tee-gateway/1.0"}) with urllib.request.urlopen(req, timeout=5) as resp: data: dict[str, Any] = json.loads(resp.read()) - price = Decimal(str(data[OPG_PRICE_COINGECKO_ID]["usd"])) + coin_data = data.get(OPG_PRICE_COINGECKO_ID) + if not isinstance(coin_data, dict) or "usd" not in coin_data: + raise ValueError( + f"CoinGecko returned no price for '{OPG_PRICE_COINGECKO_ID}' — " + f"token may not have a trading price yet: {data!r}" + ) + price = Decimal(str(coin_data["usd"])) if price <= 0: raise ValueError( f"CoinGecko returned non-positive price for '{OPG_PRICE_COINGECKO_ID}': {price}" @@ -222,11 +225,9 @@ def get_token_a_price_usd() -> Decimal: - Return cached price immediately if it was fetched within the TTL. - On TTL expiry, attempt a fresh CoinGecko fetch. - Success → update cache, return new price. - - Failure → log a warning, return the last known-good price (stale-cache - fallback), or the hard-coded fallback if no price has ever been fetched. - This means at most one network call every 5 minutes regardless of request - volume, and inference is never blocked by a network timeout beyond the first - call after a cache miss. + - Failure → raise immediately; no silent fallback. + This means at most one network call every TTL window regardless of request + volume, and inference is blocked (400 returned) if the price cannot be fetched. """ now = time.time() with _token_price_lock: @@ -236,30 +237,15 @@ def get_token_a_price_usd() -> Decimal: if last_good is not None and (now - cached_at) < OPG_PRICE_CACHE_TTL_SECONDS: return last_good - try: - value = _fetch_opg_price_usd() - _token_price_cache["last_good"] = value - _token_price_cache["updated_at"] = now - logger.info( - "OPG price refreshed: $%s (via CoinGecko '%s')", - value, - OPG_PRICE_COINGECKO_ID, - ) - return value - except Exception as exc: - if last_good is not None: - logger.warning( - "Failed to refresh OPG price (%s); using last known value $%s", - exc, - last_good, - ) - return last_good - logger.warning( - "Failed to fetch OPG price (%s); using hard fallback $%s", - exc, - _PRICE_HARD_FALLBACK_USD, - ) - return _PRICE_HARD_FALLBACK_USD + value = _fetch_opg_price_usd() + _token_price_cache["last_good"] = value + _token_price_cache["updated_at"] = now + logger.info( + "OPG price refreshed: $%s (via CoinGecko '%s')", + value, + OPG_PRICE_COINGECKO_ID, + ) + return value def _as_dict(value: Any) -> dict[str, Any] | None: diff --git a/tests/test_integration.py b/tests/test_integration.py index daeae5a..96099c5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -7,16 +7,71 @@ pytest -m integration tests/test_integration.py In CI, these run in a separate job (see .github/workflows/test.yml). + +NOTE: OPG (opengradient) is listed on CoinGecko but currently has no trading +price. The fetch tests below verify the correct error behaviour until a price +becomes available. Tests that require a live price are skipped automatically +when OPG has no price data. """ import pytest from decimal import Decimal +def _opg_has_price() -> bool: + """Return True if CoinGecko currently reports a price for OPG.""" + try: + from tee_gateway.util import _fetch_opg_price_usd + + _fetch_opg_price_usd() + return True + except Exception: + return False + + +requires_opg_price = pytest.mark.skipif( + not _opg_has_price(), + reason="OPG has no trading price on CoinGecko yet", +) + + @pytest.mark.integration class TestCoinGeckoPriceFeed: """Verify the live OPG price fetch end-to-end via the configured CoinGecko token.""" + def test_coingecko_slug_is_recognised(self): + """CoinGecko must recognise the OPG slug (i.e. return a dict for the coin, + even if price data is absent). A completely unknown slug returns an empty dict.""" + import json + import urllib.request + + from tee_gateway.config import OPG_PRICE_COINGECKO_ID + + url = ( + f"https://api.coingecko.com/api/v3/simple/price" + f"?ids={OPG_PRICE_COINGECKO_ID}&vs_currencies=usd" + ) + req = urllib.request.Request(url, headers={"User-Agent": "tee-gateway/1.0"}) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + + assert OPG_PRICE_COINGECKO_ID in data, ( + f"CoinGecko did not recognise slug '{OPG_PRICE_COINGECKO_ID}'. " + f"Response: {data!r}" + ) + + def test_fetch_raises_clear_error_when_no_price(self): + """When OPG has no trading price, _fetch_opg_price_usd must raise ValueError + with a message indicating the price is unavailable — not a bare KeyError.""" + if _opg_has_price(): + pytest.skip("OPG now has a price — this test is no longer applicable") + + from tee_gateway.util import _fetch_opg_price_usd + + with pytest.raises(ValueError, match="no price"): + _fetch_opg_price_usd() + + @requires_opg_price def test_fetch_returns_positive_decimal(self): """_fetch_opg_price_usd must return a positive Decimal from CoinGecko.""" from tee_gateway.util import _fetch_opg_price_usd @@ -25,26 +80,19 @@ def test_fetch_returns_positive_decimal(self): assert isinstance(price, Decimal) assert price > 0, f"Expected positive price, got {price}" + @requires_opg_price def test_price_is_within_sanity_bounds(self): - """Fetched price must fall within the configured sanity bounds. - - Bounds live in config.py (OPG_PRICE_SANITY_MIN_USD / MAX_USD) alongside - OPG_PRICE_COINGECKO_ID, so all three values stay in sync when the token changes. - """ - from tee_gateway.config import ( - OPG_PRICE_SANITY_MAX_USD, - OPG_PRICE_SANITY_MIN_USD, - ) + """Fetched price must not exceed the configured sanity ceiling.""" + from tee_gateway.config import OPG_PRICE_SANITY_MAX_USD from tee_gateway.util import _fetch_opg_price_usd price = _fetch_opg_price_usd() - min_price = Decimal(OPG_PRICE_SANITY_MIN_USD) max_price = Decimal(OPG_PRICE_SANITY_MAX_USD) - assert min_price < price < max_price, ( - f"Price ${price} is outside the configured sanity range " - f"${OPG_PRICE_SANITY_MIN_USD}–${OPG_PRICE_SANITY_MAX_USD}" + assert 0 < price < max_price, ( + f"Price ${price} is outside the expected range (0, ${OPG_PRICE_SANITY_MAX_USD})" ) + @requires_opg_price def test_get_token_a_price_usd_returns_cached_value(self): """get_token_a_price_usd must return the same value on two rapid calls (second call must hit the cache, not make a second network request).""" @@ -60,6 +108,7 @@ def test_get_token_a_price_usd_returns_cached_value(self): assert first == second, "Cache should return the same price on the second call" assert first > 0 + @requires_opg_price def test_dynamic_cost_uses_live_price(self): """Full pipeline: token counts + live token price → positive on-chain units.""" from tee_gateway.definitions import BASE_TESTNET_OPG_ADDRESS From 6589cedb2eee379f39d96c3929cb61003166fdf2 Mon Sep 17 00:00:00 2001 From: kylexqian Date: Fri, 17 Apr 2026 02:08:48 -0700 Subject: [PATCH 21/25] Fix retry logic and stale docstring in price feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _fetch_opg_price_usd: don't retry ValueError — no-price and non-positive-price are deterministic CoinGecko responses that won't change on retry; only network/transport exceptions get retried now - validate_pricing_preflight: remove dead price <= 0 guard (impossible since get_token_a_price_usd() now raises instead of returning a fallback) and fix stale docstring that still mentioned the removed hard fallback - test_raises_when_coin_has_no_price: assert call_count == 1 to lock in the no-retry behaviour Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/test/test_util.py | 7 ++++++- tee_gateway/util.py | 17 +++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/tee_gateway/test/test_util.py b/tee_gateway/test/test_util.py index 94d9de3..6bb68d6 100644 --- a/tee_gateway/test/test_util.py +++ b/tee_gateway/test/test_util.py @@ -91,7 +91,11 @@ def test_raises_on_malformed_json(self, mock_urlopen): @patch("tee_gateway.util.urllib.request.urlopen") def test_raises_when_coin_has_no_price(self, mock_urlopen): - """CoinGecko returns the coin but without a 'usd' key (no trading price yet).""" + """CoinGecko returns the coin but without a 'usd' key (no trading price yet). + + This is a deterministic failure — retrying won't help, so it must raise + immediately after the first call without consuming the retry budget. + """ body = json.dumps({OPG_PRICE_COINGECKO_ID: {}}).encode() mock_resp = MagicMock() mock_resp.read.return_value = body @@ -101,6 +105,7 @@ def test_raises_when_coin_has_no_price(self, mock_urlopen): with self.assertRaises(ValueError) as ctx: _fetch_opg_price_usd() self.assertIn("no price", str(ctx.exception)) + self.assertEqual(mock_urlopen.call_count, 1) # no retries for deterministic error @patch("tee_gateway.util.urllib.request.urlopen") def test_retries_on_failure_then_succeeds(self, mock_urlopen): diff --git a/tee_gateway/util.py b/tee_gateway/util.py index 910a0d4..875b795 100644 --- a/tee_gateway/util.py +++ b/tee_gateway/util.py @@ -197,6 +197,9 @@ def _fetch_opg_price_usd() -> Decimal: data: dict[str, Any] = json.loads(resp.read()) coin_data = data.get(OPG_PRICE_COINGECKO_ID) if not isinstance(coin_data, dict) or "usd" not in coin_data: + # Deterministic failure — the coin is listed but has no price. + # Retrying won't help, so raise immediately without consuming + # the remaining retry budget. raise ValueError( f"CoinGecko returned no price for '{OPG_PRICE_COINGECKO_ID}' — " f"token may not have a trading price yet: {data!r}" @@ -207,6 +210,8 @@ def _fetch_opg_price_usd() -> Decimal: f"CoinGecko returned non-positive price for '{OPG_PRICE_COINGECKO_ID}': {price}" ) return price + except ValueError: + raise except Exception as exc: last_exc = exc logger.warning( @@ -357,20 +362,16 @@ def _extract_asset_decimals_from_requirements(payment_requirements: Any) -> int: def validate_pricing_preflight(model: str) -> None: """Validate that this request can be priced before any LLM call is made. - Raises ValueError if the model is not in the registry, or if no token - price is available (price feed down and hard fallback somehow non-positive). + Raises ValueError if the model is not in the registry. + Raises (propagates) whatever get_token_a_price_usd raises if the price + feed is unavailable — e.g. network down or token has no trading price yet. Call this at the top of each request handler so that a pricing failure returns a proper error to the client rather than silently producing free inference after the response has already been sent. """ get_model_config(model) # raises ValueError for unknown models - - price = get_token_a_price_usd() - if price <= 0: - raise ValueError( - f"Token price is non-positive ({price}); cannot price inference request" - ) + get_token_a_price_usd() # raises if price is unavailable def dynamic_session_cost_calculator(context: dict[str, Any]) -> int: From 5207da929f18a5d552e1672fc438fc7ffcc536da Mon Sep 17 00:00:00 2001 From: kylexqian Date: Sat, 18 Apr 2026 09:31:18 -0700 Subject: [PATCH 22/25] Fix lint --- tee_gateway/test/test_util.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tee_gateway/test/test_util.py b/tee_gateway/test/test_util.py index 6bb68d6..96df72b 100644 --- a/tee_gateway/test/test_util.py +++ b/tee_gateway/test/test_util.py @@ -105,7 +105,9 @@ def test_raises_when_coin_has_no_price(self, mock_urlopen): with self.assertRaises(ValueError) as ctx: _fetch_opg_price_usd() self.assertIn("no price", str(ctx.exception)) - self.assertEqual(mock_urlopen.call_count, 1) # no retries for deterministic error + self.assertEqual( + mock_urlopen.call_count, 1 + ) # no retries for deterministic error @patch("tee_gateway.util.urllib.request.urlopen") def test_retries_on_failure_then_succeeds(self, mock_urlopen): From 2877fb35eed46d2c8d6272ec0ad195ab9b8471b5 Mon Sep 17 00:00:00 2001 From: kylexqian Date: Sat, 18 Apr 2026 23:18:37 -0700 Subject: [PATCH 23/25] Fix test failures caused by unguarded CoinGecko calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_tool_forwarding.py: the chat controller now runs validate_pricing_preflight before every request, which hits CoinGecko for real. These tests mock the model and TEE keys but not the price feed, so they were getting 400s or 429s. Added the same module-level get_token_a_price_usd patch used in test_pricing.py. test_integration.py: _opg_has_price() makes one CoinGecko call at collection time; test_fetch_raises_clear_error_when_no_price then made a second immediate call, triggering a 429. The test now handles HTTPError 429 by skipping rather than failing — rate-limiting is transient and not the thing being tested. Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/test/test_tool_forwarding.py | 16 ++++++++++++++++ tests/test_integration.py | 11 ++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/tee_gateway/test/test_tool_forwarding.py b/tee_gateway/test/test_tool_forwarding.py index f4ca16f..ac2c250 100644 --- a/tee_gateway/test/test_tool_forwarding.py +++ b/tee_gateway/test/test_tool_forwarding.py @@ -1,4 +1,5 @@ import unittest +from decimal import Decimal from unittest.mock import patch, Mock from tee_gateway.controllers.chat_controller import ( @@ -11,6 +12,21 @@ ChatCompletionRequestFunctionMessage, ) +# Pin the token price for all integration-style tests in this file so they +# never hit the real CoinGecko API. The price feed is tested separately in +# test_util.py and test_integration.py. +_price_patcher = patch( + "tee_gateway.util.get_token_a_price_usd", return_value=Decimal("1") +) + + +def setUpModule(): + _price_patcher.start() + + +def tearDownModule(): + _price_patcher.stop() + # --------------------------------------------------------------------------- # Shared helpers diff --git a/tests/test_integration.py b/tests/test_integration.py index 96099c5..67a7211 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -63,13 +63,22 @@ def test_coingecko_slug_is_recognised(self): def test_fetch_raises_clear_error_when_no_price(self): """When OPG has no trading price, _fetch_opg_price_usd must raise ValueError with a message indicating the price is unavailable — not a bare KeyError.""" + import urllib.error + if _opg_has_price(): pytest.skip("OPG now has a price — this test is no longer applicable") from tee_gateway.util import _fetch_opg_price_usd - with pytest.raises(ValueError, match="no price"): + try: _fetch_opg_price_usd() + pytest.fail("Expected ValueError but no exception was raised") + except ValueError as exc: + assert "no price" in str(exc), f"Unexpected ValueError message: {exc}" + except urllib.error.HTTPError as exc: + if exc.code == 429: + pytest.skip(f"CoinGecko rate-limited the integration test run: {exc}") + raise @requires_opg_price def test_fetch_returns_positive_decimal(self): From 2bb2ed679ae08d1324365988fcfbf6ad6e2b7063 Mon Sep 17 00:00:00 2001 From: kylexqian Date: Sat, 18 Apr 2026 23:26:23 -0700 Subject: [PATCH 24/25] =?UTF-8?q?fix:=20update=20testnet=20=E2=86=92=20mai?= =?UTF-8?q?nnet=20OPG=20address=20after=20ani/token-update=20rebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ani/token-update removed BASE_TESTNET_OPG_ADDRESS and BASE_TESTNET_NETWORK; update hardcoded asset address in test_util.py and the import in test_integration.py to use BASE_MAINNET_OPG_ADDRESS. Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/test/test_util.py | 4 ++-- tests/test_integration.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tee_gateway/test/test_util.py b/tee_gateway/test/test_util.py index 96df72b..374c4f0 100644 --- a/tee_gateway/test/test_util.py +++ b/tee_gateway/test/test_util.py @@ -209,7 +209,7 @@ def _make_context( model: str = "gpt-4.1", prompt_tokens: int = 100, completion_tokens: int = 50, - asset: str = "0x240b09731D96979f50B2C649C9CE10FcF9C7987F", # OPG testnet + asset: str = "0xFbC2051AE2265686a469421b2C5A2D5462FbF5eB", # OPG mainnet ) -> dict: return { "request_json": {"model": model}, @@ -288,7 +288,7 @@ def test_raises_when_usage_missing(self): "request_json": {"model": "gpt-4.1"}, "response_json": {}, "payment_requirements": { - "asset": "0x240b09731D96979f50B2C649C9CE10FcF9C7987F" + "asset": "0xFbC2051AE2265686a469421b2C5A2D5462FbF5eB" }, } with self.assertRaises(ValueError): diff --git a/tests/test_integration.py b/tests/test_integration.py index 67a7211..cd77983 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -120,7 +120,7 @@ def test_get_token_a_price_usd_returns_cached_value(self): @requires_opg_price def test_dynamic_cost_uses_live_price(self): """Full pipeline: token counts + live token price → positive on-chain units.""" - from tee_gateway.definitions import BASE_TESTNET_OPG_ADDRESS + from tee_gateway.definitions import BASE_MAINNET_OPG_ADDRESS from tee_gateway.util import ( _token_price_cache, dynamic_session_cost_calculator, @@ -136,7 +136,7 @@ def test_dynamic_cost_uses_live_price(self): "response_json": { "usage": {"prompt_tokens": 1000, "completion_tokens": 500} }, - "payment_requirements": {"asset": BASE_TESTNET_OPG_ADDRESS}, + "payment_requirements": {"asset": BASE_MAINNET_OPG_ADDRESS}, } cost = dynamic_session_cost_calculator(ctx) From 0b0b85fd25a2ffd4067eb7b7a75be43b7e1271ff Mon Sep 17 00:00:00 2001 From: kylexqian Date: Sun, 19 Apr 2026 14:32:55 -0700 Subject: [PATCH 25/25] Add error logging for pricing preflight failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Log at ERROR level when validate_pricing_preflight raises — covers both the CoinGecko no-price case in util.py and the catch sites in the chat and completions controllers, so the root cause is visible in server logs. Co-Authored-By: Claude Sonnet 4.6 --- tee_gateway/controllers/chat_controller.py | 1 + tee_gateway/controllers/completions_controller.py | 1 + tee_gateway/util.py | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/tee_gateway/controllers/chat_controller.py b/tee_gateway/controllers/chat_controller.py index 356dda4..24c2d3b 100644 --- a/tee_gateway/controllers/chat_controller.py +++ b/tee_gateway/controllers/chat_controller.py @@ -50,6 +50,7 @@ def create_chat_completion(body): try: validate_pricing_preflight(chat_request.model) except ValueError as exc: + logger.error("Pricing preflight failed for model %r: %s", chat_request.model, exc) return {"error": "Bad Request", "message": str(exc)}, 400 if chat_request.stream: diff --git a/tee_gateway/controllers/completions_controller.py b/tee_gateway/controllers/completions_controller.py index 2ed7ccf..1b3efd7 100644 --- a/tee_gateway/controllers/completions_controller.py +++ b/tee_gateway/controllers/completions_controller.py @@ -25,6 +25,7 @@ def create_completion(body): try: validate_pricing_preflight(body.model) except ValueError as exc: + logger.error("Pricing preflight failed for model %r: %s", body.model, exc) return {"error": "Bad Request", "message": str(exc)}, 400 try: diff --git a/tee_gateway/util.py b/tee_gateway/util.py index 875b795..effbcf7 100644 --- a/tee_gateway/util.py +++ b/tee_gateway/util.py @@ -200,6 +200,11 @@ def _fetch_opg_price_usd() -> Decimal: # Deterministic failure — the coin is listed but has no price. # Retrying won't help, so raise immediately without consuming # the remaining retry budget. + logger.error( + "CoinGecko returned no USD price for '%s' — token may not have a trading price yet. Response: %r", + OPG_PRICE_COINGECKO_ID, + data, + ) raise ValueError( f"CoinGecko returned no price for '{OPG_PRICE_COINGECKO_ID}' — " f"token may not have a trading price yet: {data!r}"