Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4047a25
feat: Add CoinGecko OPG/USD price feed background service
kylexqian Apr 20, 2026
5ff9fc9
refactor: Reorganize price feed into tee_gateway/price_feed/ package
kylexqian Apr 20, 2026
69f7150
refactor: dependency injection for OPG price feed via make_cost_calcu…
kylexqian Apr 20, 2026
7d52080
refactor: replace make_cost_calculator factory with calculate_session…
kylexqian Apr 20, 2026
c0d8373
feat: include price feed status in /health response
kylexqian Apr 20, 2026
f1b6526
test: verify calculate_session_cost fetches live price on every call
kylexqian Apr 20, 2026
092f14d
fix: update test_pricing.py for calculate_session_cost signature change
kylexqian Apr 20, 2026
996a950
fix: add attr-defined to type: ignore on monkey-patch line
kylexqian Apr 20, 2026
3e1b81c
fix: address Copilot review comments on price feed
kylexqian Apr 20, 2026
340476c
Update .github/workflows/test.yml
kylexqian Apr 20, 2026
be53f76
fix: update stale dynamic_session_cost_calculator references
kylexqian Apr 20, 2026
5891728
feat: add pre-inference pricing gate and remove ineffective monkey-patch
kylexqian Apr 20, 2026
ba2258f
fix: address Copilot review comments (docstrings, defensive coding, t…
kylexqian Apr 20, 2026
3b1da9a
feat: TGE fallback price and CoinGecko sanity checks
kylexqian Apr 20, 2026
b2e9702
feat: expire stale OPG price after 4 hours
kylexqian Apr 20, 2026
6289250
Update tee_gateway/price_feed/feed.py
kylexqian Apr 20, 2026
caf076c
Update tee_gateway/util.py
kylexqian Apr 20, 2026
4b9e582
Merge branch 'ani/token-update' into feat/coingecko-opg-price-feed
kylexqian Apr 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,7 @@ 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_price_feed.py tests/test_pricing.py -v --import-mode=importlib
# To also run integration tests (real CoinGecko network calls), add:
# env:
# RUN_INTEGRATION_TESTS: "1"
146 changes: 56 additions & 90 deletions tee_gateway/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@
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 .util import calculate_session_cost
from .model_registry import get_model_config
from .price_feed import OPGPriceFeed
from .definitions import (
EVM_PAYMENT_ADDRESS,
BASE_MAINNET_NETWORK,
Expand Down Expand Up @@ -107,6 +108,13 @@ def _shutdown_heartbeat():

atexit.register(_shutdown_heartbeat)

# ---------------------------------------------------------------------------
# OPG price feed — start before x402 middleware so the first request can be
# priced correctly. Runs as a daemon thread; no cleanup needed on exit.
# ---------------------------------------------------------------------------
_price_feed = OPGPriceFeed()
_price_feed.start()
Comment on lines 109 to +116
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_price_feed.start() performs a synchronous initial fetch with retries before returning. With defaults (FETCH_TIMEOUT=10, max_retries=3, retry_delay=10), a CoinGecko outage can delay process startup by ~50s. Consider starting the background thread first and doing the initial fetch asynchronously, or making the initial fetch/retry parameters configurable (or reduced) specifically for startup so the server can come up promptly.

Suggested change
atexit.register(_shutdown_heartbeat)
# ---------------------------------------------------------------------------
# OPG price feed — start before x402 middleware so the first request can be
# priced correctly. Runs as a daemon thread; no cleanup needed on exit.
# ---------------------------------------------------------------------------
_price_feed = OPGPriceFeed()
_price_feed.start()
def _start_price_feed_async():
"""Start the OPG price feed without blocking process startup."""
def _run():
try:
_price_feed.start()
except Exception:
logger.exception("Failed to start OPG price feed")
threading.Thread(
target=_run,
name="opg-price-feed-startup",
daemon=True,
).start()
atexit.register(_shutdown_heartbeat)
# ---------------------------------------------------------------------------
# OPG price feed — start before x402 middleware so the first request can be
# priced correctly. Start initialization asynchronously so upstream outages do
# not block process startup. The feed itself continues to run in the
# background; no cleanup needed on exit.
# ---------------------------------------------------------------------------
_price_feed = OPGPriceFeed()
_start_price_feed_async()

Copilot uses AI. Check for mistakes.

facilitator = HTTPFacilitatorClientSync(FacilitatorConfig(url=FACILITATOR_URL))
server = x402ResourceServerSync(facilitator)
store = SessionStore()
Expand Down Expand Up @@ -303,6 +311,7 @@ def health():
"status": "OK",
"version": "1.0.0",
"tee_enabled": True,
"price_feed": _price_feed.get_status(),
}, 200


Expand Down Expand Up @@ -374,109 +383,66 @@ def _patched_read_body_bytes(environ):

x402_flask._read_body_bytes = _patched_read_body_bytes


def _session_cost_calculator(ctx: dict) -> int:
# Post-inference cost calculation — response already sent to client.
# Predictable failures (unknown price, unknown model) are blocked by the
# pre-inference gate; any exception here indicates a provider-side error
# (e.g. missing usage field in the LLM response). The x402 middleware
# swallows the exception in close(), so the client is not charged.
# Log CRITICAL so provider errors are never silently missed.
try:
return calculate_session_cost(ctx, _price_feed.get_price)
except Exception as exc:
logger.critical(
"Post-inference cost calculation failed (provider error) — "
"client was NOT charged: %s",
exc,
exc_info=True,
)
raise


_payment_mw = payment_middleware(
application,
routes=routes,
server=server,
session_store=store,
cost_per_request=100000000000000, # static precheck/fallback estimate
session_idle_timeout=100,
session_cost_calculator=dynamic_session_cost_calculator,
session_cost_calculator=_session_cost_calculator,
)

# ---------------------------------------------------------------------------
# Strict cost-resolution patch
#
# 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.
# Pre-inference pricing gate
#
# 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.
# In the upto session scheme the response is streamed to the client before
# cost is settled, so a post-inference pricing failure cannot be surfaced as
# an HTTP error. Instead we validate everything that can be checked up-front
# and reject the request early if pricing would fail:
# 1. Price feed has a valid OPG/USD price (CoinGecko fetch succeeded).
# 2. The requested model is in the registry (has a known per-token price).
# ---------------------------------------------------------------------------


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)

@application.before_request
def _check_pricing_ready():
if request.path not in ("/v1/chat/completions", "/v1/completions"):
return
try:
_price_feed.get_price()
except ValueError as exc:
logger.warning("Rejecting inference request — price feed unavailable: %s", exc)
return jsonify({"error": f"Pricing unavailable: {exc}"}), 503

body = request.get_json(silent=True, cache=True) or {}
model = body.get("model")
if model:
try:
get_model_config(model)
except ValueError:
return jsonify({"error": f"Model '{model}' is not supported"}), 400

_payment_mw._resolve_session_request_cost = _types.MethodType( # type: ignore[method-assign, attr-defined]
_strict_resolve_session_request_cost, _payment_mw
)

logger.info("x402 payment middleware initialized")

Expand Down
7 changes: 7 additions & 0 deletions tee_gateway/price_feed/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from .config import PriceFeedConfig
from .feed import OPGPriceFeed

__all__ = [
"OPGPriceFeed",
"PriceFeedConfig",
]
52 changes: 52 additions & 0 deletions tee_gateway/price_feed/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
Configuration constants and dataclass for the OPG price feed.
"""

from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal


# ---------------------------------------------------------------------------
# CoinGecko API
# ---------------------------------------------------------------------------
COINGECKO_BASE_URL = "https://api.coingecko.com/api/v3"
COINGECKO_PLATFORM = "base" # Base mainnet platform identifier on CoinGecko
FETCH_TIMEOUT = 10 # seconds per HTTP request

# ---------------------------------------------------------------------------
# Refresh / retry defaults
# ---------------------------------------------------------------------------
DEFAULT_REFRESH_INTERVAL = 300 # 5 minutes between background refresh cycles
DEFAULT_MAX_RETRIES = 3 # attempts per refresh cycle before giving up
DEFAULT_RETRY_DELAY = 10 # seconds between retry attempts within a cycle

# ---------------------------------------------------------------------------
# TGE (Token Generation Event) fallback
# ---------------------------------------------------------------------------
# Before the TGE cutover, OPG is not yet listed on CoinGecko. Return a fixed
# fallback price so inference requests can be priced immediately at launch.
# After the cutover, the live CoinGecko price is used.
TGE_CUTOVER_UTC = datetime(2026, 4, 21, 12, 30, 0, tzinfo=timezone.utc)
TGE_FALLBACK_PRICE_USD = Decimal("0.10")

# ---------------------------------------------------------------------------
# Stale-price thresholds
# ---------------------------------------------------------------------------
# get_price() logs WARNING when last successful fetch is older than
# STALE_WARNING_MULTIPLIER × refresh_interval seconds.
STALE_WARNING_MULTIPLIER = 2

# get_price() raises ValueError when last successful fetch is older than
# STALE_PRICE_MAX_AGE seconds — at this point the cached price is considered
# too outdated to use for billing.
STALE_PRICE_MAX_AGE = 4 * 60 * 60 # 4 hours


@dataclass(frozen=True)
class PriceFeedConfig:
"""Runtime configuration for the OPG price feed background service."""

refresh_interval: int = DEFAULT_REFRESH_INTERVAL
max_retries: int = DEFAULT_MAX_RETRIES
retry_delay: float = DEFAULT_RETRY_DELAY
Loading
Loading