-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add live data pull for opengradient token #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0f9726f
dac78ea
ce3a491
015edb9
d81fbc7
6bd4075
7d3ec36
4e8f3f3
793cc93
fa2c545
baf01e9
f2d6363
1522c23
3b5d9d0
e0a4b30
e9f0d27
8f9f9eb
edda294
3ca5afa
bada03e
355f4dd
d02d590
6589ced
5207da9
2877fb3
2bb2ed6
0b0b85f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,19 +27,20 @@ | |
| from x402.http.types import RouteConfig | ||
| from x402.mechanisms.evm.exact import ExactEvmServerScheme | ||
| from x402.mechanisms.evm.upto import UptoEvmServerScheme | ||
| 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 | ||
|
|
||
| from .util import dynamic_session_cost_calculator | ||
| from .definitions import ( | ||
| BASE_TESTNET_NETWORK, | ||
| EVM_PAYMENT_ADDRESS, | ||
| BASE_OPG_ADDRESS, | ||
| BASE_MAINNET_NETWORK, | ||
| BASE_MAINNET_OPG_ADDRESS, | ||
| CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, | ||
| COMPLETIONS_OPG_SESSION_MAX_SPEND, | ||
| FACILITATOR_URL, | ||
| ) | ||
|
|
||
|
|
@@ -108,8 +109,10 @@ def _shutdown_heartbeat(): | |
| server = x402ResourceServerSync(facilitator) | ||
| store = SessionStore() | ||
|
|
||
| server.register(BASE_TESTNET_NETWORK, ExactEvmServerScheme()) | ||
| server.register(BASE_TESTNET_NETWORK, UptoEvmServerScheme()) | ||
| server.register(BASE_MAINNET_NETWORK, ExactEvmServerScheme()) | ||
|
|
||
| # Upto scheme registrations (permit2-based, variable settlement) | ||
| server.register(BASE_MAINNET_NETWORK, UptoEvmServerScheme()) | ||
|
|
||
| routes = { | ||
| "POST /v1/chat/completions": RouteConfig( | ||
|
|
@@ -119,16 +122,19 @@ def _shutdown_heartbeat(): | |
| pay_to=EVM_PAYMENT_ADDRESS, | ||
| price=AssetAmount( | ||
| amount=CHAT_COMPLETIONS_OPG_SESSION_MAX_SPEND, | ||
| asset=BASE_OPG_ADDRESS, | ||
| asset=BASE_MAINNET_OPG_ADDRESS, | ||
| extra={ | ||
| "name": "OPG", | ||
| "version": "2", | ||
| "name": "OpenGradient", | ||
| "version": "1", | ||
| "assetTransferMethod": "permit2", | ||
| }, | ||
| ), | ||
| network=BASE_TESTNET_NETWORK, | ||
| network=BASE_MAINNET_NETWORK, | ||
| ), | ||
| ], | ||
| extensions={ | ||
| **declare_erc20_approval_gas_sponsoring_extension(), | ||
| }, | ||
| mime_type="application/json", | ||
| description="Chat completion", | ||
| ), | ||
|
|
@@ -138,17 +144,20 @@ def _shutdown_heartbeat(): | |
| scheme="upto", | ||
| pay_to=EVM_PAYMENT_ADDRESS, | ||
| price=AssetAmount( | ||
| amount=COMPLETIONS_OPG_SESSION_MAX_SPEND, | ||
| asset=BASE_OPG_ADDRESS, | ||
| 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_TESTNET_NETWORK, | ||
| network=BASE_MAINNET_NETWORK, | ||
| ), | ||
| ], | ||
| extensions={ | ||
| **declare_erc20_approval_gas_sponsoring_extension(), | ||
| }, | ||
| mime_type="application/json", | ||
| description="Completion", | ||
| ), | ||
|
|
@@ -374,99 +383,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. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this? |
||
| # | ||
| # 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. isn't this some regression? |
||
| # 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__": | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,34 @@ | |
| 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 | ||
|
|
||
| # 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 for the OPG token. | ||
| # https://www.coingecko.com/en/coins/opengradient | ||
| OPG_PRICE_COINGECKO_ID: str = "opengradient" | ||
|
|
||
|
Comment on lines
+25
to
+28
|
||
| # Sanity bounds for the fetched token price. | ||
| # 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 | ||
| ) | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Heartbeat defaults | ||
| # --------------------------------------------------------------------------- | ||
| DEFAULT_HEARTBEAT_INTERVAL = 900 # 15 minutes | ||
| DEFAULT_HEARTBEAT_BUFFER = ( | ||
| 300 # 5 minutes — subtracted from time.time() to compensate for enclave clock drift | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -30,6 +30,7 @@ | |||||||||||||||||||||
| convert_messages, | ||||||||||||||||||||||
| extract_usage, | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
| from tee_gateway.util import validate_pricing_preflight | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -46,6 +47,12 @@ def create_chat_completion(body): | |||||||||||||||||||||
| connexion.request.get_json() | ||||||||||||||||||||||
| ) | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| 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 | ||||||||||||||||||||||
|
||||||||||||||||||||||
| return {"error": "Bad Request", "message": str(exc)}, 400 | |
| return {"error": "Bad Request", "message": str(exc)}, 400 | |
| except Exception as exc: | |
| logger.exception( | |
| "Pricing preflight validation failed due to an upstream/service error" | |
| ) | |
| return { | |
| "error": "Service Unavailable", | |
| "message": "Pricing validation is temporarily unavailable. Please try again later.", | |
| }, 503 |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,12 @@ def create_completion(body): | |||||||||||||||||
| else: | ||||||||||||||||||
| return {"error": "Request must be application/json"}, 415 | ||||||||||||||||||
|
|
||||||||||||||||||
| 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 | ||||||||||||||||||
|
||||||||||||||||||
| return {"error": "Bad Request", "message": str(exc)}, 400 | |
| return {"error": "Bad Request", "message": str(exc)}, 400 | |
| except Exception as exc: | |
| logger.error(f"Pricing preflight error: {str(exc)}", exc_info=True) | |
| return { | |
| "error": "Service Unavailable", | |
| "message": "Pricing validation unavailable", | |
| }, 503 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why not just let the exception propagate?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
normally this would be 500 no? since it's outside of the user's control
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this new?