|
3 | 3 | from dataclasses import dataclass |
4 | 4 | from typing import Any, Literal |
5 | 5 |
|
| 6 | +from agentscore_commerce.payment.amounts import usd_to_atomic |
| 7 | + |
6 | 8 |
|
7 | 9 | def agentscore_security_schemes(*, aip: bool = False) -> dict[str, Any]: |
8 | 10 | """Standard AgentScore identity security schemes for `components.securitySchemes`. |
@@ -101,25 +103,57 @@ def x_payment_info_extension( |
101 | 103 | price: XPaymentInfoPrice, |
102 | 104 | protocols: list[dict[str, Any]], |
103 | 105 | description: str | None = None, |
| 106 | + offers: list[dict[str, Any]] | None = None, |
104 | 107 | ) -> dict[str, Any]: |
105 | 108 | """Wrap a price + protocols block under ``x-payment-info``. |
106 | 109 |
|
107 | 110 | For spreading into an OpenAPI operation object. ``protocols`` is a list of |
108 | 111 | single-key dicts: ``{"x402": {}}`` for x402, ``{"mpp": {"method": ..., |
109 | 112 | "intent": ..., "currency": ...}}`` for MPP. Order is preserved. |
110 | 113 |
|
111 | | - Emits ``authMode: "payment"`` by default per the x402scan convention. |
| 114 | + The block carries both readers' shapes, because MPP and x402scan define the same |
| 115 | + ``x-payment-info`` extension differently and neither reads the other's keys: |
| 116 | + x402scan reads ``price`` + ``protocols`` (and ``authMode: "payment"``), MPP reads |
| 117 | + ``offers``. ``offers`` defaults to one per MPP protocol entry (:func:`offers_from`). |
112 | 118 | """ |
113 | 119 | if isinstance(price, XPaymentInfoFixedPrice): |
114 | 120 | price_dict: dict[str, Any] = {"mode": "fixed", "currency": price.currency, "amount": price.amount} |
115 | 121 | else: |
116 | 122 | price_dict = {"mode": "dynamic", "currency": price.currency, "min": price.min, "max": price.max} |
117 | 123 | block: dict[str, Any] = {"authMode": "payment", "price": price_dict, "protocols": protocols} |
| 124 | + derived = offers if offers is not None else offers_from(price, protocols) |
| 125 | + if derived: |
| 126 | + block["offers"] = derived |
118 | 127 | if description is not None: |
119 | 128 | block["description"] = description |
120 | 129 | return {"x-payment-info": block} |
121 | 130 |
|
122 | 131 |
|
| 132 | +def offers_from(price: XPaymentInfoPrice, protocols: list[dict[str, Any]]) -> list[dict[str, Any]]: |
| 133 | + """MPP payment offers (``draft-payment-discovery``) for the MPP entries in ``protocols``. |
| 134 | +
|
| 135 | + Priced in each method's smallest unit the way the 402 challenge prices it: Stripe in |
| 136 | + cents, the token rails (Tempo USDC.e, Solana USDC) in 6-decimal base units. x402 |
| 137 | + entries have no MPP offer; a dynamic price is ``None`` (``null``), which MPP defines |
| 138 | + as "depends on the request". |
| 139 | + """ |
| 140 | + offers: list[dict[str, Any]] = [] |
| 141 | + for p in protocols: |
| 142 | + mpp = p.get("mpp") |
| 143 | + if not isinstance(mpp, dict): |
| 144 | + continue |
| 145 | + method, _, slash_intent = str(mpp.get("method", "")).partition("/") |
| 146 | + intent = "session" if (slash_intent or mpp.get("intent")) == "session" else "charge" |
| 147 | + decimals = 2 if method == "stripe" else 6 |
| 148 | + fixed_usd = isinstance(price, XPaymentInfoFixedPrice) and price.currency.upper() == "USD" |
| 149 | + amount = str(usd_to_atomic(price.amount, decimals=decimals)) if fixed_usd else None |
| 150 | + offer: dict[str, Any] = {"intent": intent, "method": method, "amount": amount} |
| 151 | + if isinstance(mpp.get("currency"), str): |
| 152 | + offer["currency"] = mpp["currency"] |
| 153 | + offers.append(offer) |
| 154 | + return offers |
| 155 | + |
| 156 | + |
123 | 157 | def x_guidance_extension(text: str) -> dict[str, str]: |
124 | 158 | """Wrap a prose blurb under ``x-guidance`` for spreading into an OpenAPI ``info`` block. |
125 | 159 |
|
|
0 commit comments