feat: record AI charges at gateway-settled cost under x402 upto

The claw402 gateway now settles chat routes pay-as-you-go (upto) and
reports the actual amount in the X-Claw402-Settled-Usd response header.
Capture it on all three x402 call paths (stream, simple, full), expose it
via Claw402Client.LastCallCostUSD, and prefer it over the flat catalog
estimate when recording ai_charges — cost stats now match real spend.
This commit is contained in:
tinkle-community
2026-07-28 19:32:10 +09:00
parent 7296b204e4
commit ca5dcbf4be
5 changed files with 79 additions and 5 deletions

View File

@@ -78,6 +78,11 @@ type Client struct {
Log Logger // Exported for sub-packages
Cfg *Config // Exported for sub-packages
// LastCallSettledUSD is the actually-settled cost (USD) of the most
// recent call, reported by x402 upto gateways via response header.
// Zero when the last call carried no settlement information.
LastCallSettledUSD float64
// Hooks are used to implement dynamic dispatch (polymorphism)
// When provider.DeepSeekClient embeds Client, Hooks point to DeepSeekClient
// This way methods called in Call() are automatically dispatched to the overridden version

View File

@@ -89,6 +89,17 @@ type Claw402Client struct {
func (c *Claw402Client) BaseClient() *mcp.Client { return c.Client }
// LastCallCostUSD reports the actually-settled cost of the most recent call,
// taken from the gateway's X-Claw402-Settled-Usd header (upto scheme).
// ok is false when the gateway did not report a settlement amount — callers
// should then fall back to the flat catalog price.
func (c *Claw402Client) LastCallCostUSD() (float64, bool) {
if c.Client.LastCallSettledUSD > 0 {
return c.Client.LastCallSettledUSD, true
}
return 0, false
}
// NewClaw402Client creates a claw402 client (backward compatible).
func NewClaw402Client() mcp.AIClient {
return NewClaw402ClientWithOptions()

View File

@@ -12,6 +12,7 @@ import (
"io"
"math/big"
"net/http"
"strconv"
"strings"
"time"
@@ -201,7 +202,35 @@ func SignBasePaymentHeader(privateKey *ecdsa.PrivateKey, paymentHeaderB64 string
return SignX402Payment(privateKey, senderAddr, req.Accepts[0], req.Resource)
}
// X402SettledUSDHeader carries the actually-settled cost (USD) of an upto
// call, set by the claw402 gateway on the paid response.
const X402SettledUSDHeader = "X-Claw402-Settled-Usd"
func storeRespHeader(sink []*http.Header, h http.Header) {
for _, p := range sink {
if p != nil {
*p = h
}
}
}
// captureSettledUSD records the gateway-reported settled cost on the client.
// Zero when the response carried no settlement header (e.g. exact-scheme
// routes, where the flat catalog price applies instead).
func captureSettledUSD(c *mcp.Client, h http.Header) {
c.LastCallSettledUSD = 0
if h == nil {
return
}
if v := h.Get(X402SettledUSDHeader); v != "" {
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
c.LastCallSettledUSD = f
}
}
}
// DoX402Request executes an HTTP request and handles the x402 v2 payment flow.
// An optional headerSink receives the headers of the successful response.
func DoX402Request(
ctx context.Context,
httpClient *http.Client,
@@ -209,6 +238,7 @@ func DoX402Request(
signFn X402SignFunc,
providerTag string,
logger mcp.Logger,
headerSink ...*http.Header,
) ([]byte, error) {
if ctx == nil {
ctx = context.Background()
@@ -277,6 +307,7 @@ func DoX402Request(
if attempt > 1 {
logger.Infof("✅ [%s] Payment retry succeeded on attempt %d", providerTag, attempt)
}
storeRespHeader(headerSink, resp2.Header)
return body2, nil
}
@@ -333,6 +364,7 @@ func DoX402Request(
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s API error (status %d): %s", providerTag, resp.StatusCode, string(body))
}
storeRespHeader(headerSink, resp.Header)
return body, nil
}
@@ -494,6 +526,7 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.LastCallSettledUSD = 0
resp, err := DoX402RequestStream(ctx, c.HTTPClient, func() (*http.Request, error) {
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
}, signFn, tag, c.Log)
@@ -501,6 +534,7 @@ func X402CallStream(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt
return "", err
}
defer resp.Body.Close()
captureSettledUSD(c, resp.Header)
ct := resp.Header.Get("Content-Type")
c.Log.Infof("📡 [%s] Response Content-Type: %s", tag, ct)
@@ -590,12 +624,15 @@ func X402Call(c *mcp.Client, signFn X402SignFunc, tag string, systemPrompt, user
return "", err
}
c.LastCallSettledUSD = 0
var respHeader http.Header
body, err := DoX402Request(context.Background(), c.HTTPClient, func() (*http.Request, error) {
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
}, signFn, tag, c.Log)
}, signFn, tag, c.Log, &respHeader)
if err != nil {
return "", err
}
captureSettledUSD(c, respHeader)
return c.Hooks.ParseMCPResponse(body)
}
@@ -616,12 +653,15 @@ func X402CallFull(c *mcp.Client, signFn X402SignFunc, tag string, req *mcp.Reque
return nil, err
}
c.LastCallSettledUSD = 0
var respHeader http.Header
body, err := DoX402Request(x402ContextFromRequest(req), c.HTTPClient, func() (*http.Request, error) {
return c.Hooks.BuildRequest(c.Hooks.BuildUrl(), jsonData)
}, signFn, tag, c.Log)
}, signFn, tag, c.Log, &respHeader)
if err != nil {
return nil, err
}
captureSettledUSD(c, respHeader)
return c.Hooks.ParseMCPResponseFull(body)
}

View File

@@ -56,12 +56,18 @@ func (s *AIChargeStore) initTables() error {
// Record records a new AI charge
func (s *AIChargeStore) Record(traderID, model, provider string) error {
cost := GetModelPrice(model)
return s.RecordWithCost(traderID, model, provider, GetModelPrice(model))
}
// RecordWithCost records a charge with an explicit cost — e.g. the actual
// settled amount reported by the payment gateway (upto scheme) — instead of
// the flat per-call estimate from modelPrices.
func (s *AIChargeStore) RecordWithCost(traderID, model, provider string, costUSD float64) error {
charge := &AICharge{
TraderID: traderID,
Model: model,
Provider: provider,
CostUSD: cost,
CostUSD: costUSD,
}
return s.db.Create(charge).Error
}

View File

@@ -135,12 +135,24 @@ func (at *AutoTrader) runCycle() error {
// Use the effective model name (custom model, e.g. "gpt-5.6") so the
// per-call price lookup matches what was actually invoked — at.aiModel is
// the provider id (e.g. "claw402") and would fall back to the default price.
// Prefer the gateway-reported settled amount (upto scheme) over the flat
// catalog estimate when the client exposes it.
if aiDecision != nil && at.store != nil {
chargeModel := at.config.CustomModelName
if chargeModel == "" {
chargeModel = at.aiModel
}
if chargeErr := at.store.AICharge().Record(at.id, chargeModel, at.config.AIModel); chargeErr != nil {
var chargeErr error
if r, ok := at.mcpClient.(interface{ LastCallCostUSD() (float64, bool) }); ok {
if actual, has := r.LastCallCostUSD(); has {
chargeErr = at.store.AICharge().RecordWithCost(at.id, chargeModel, at.config.AIModel, actual)
} else {
chargeErr = at.store.AICharge().Record(at.id, chargeModel, at.config.AIModel)
}
} else {
chargeErr = at.store.AICharge().Record(at.id, chargeModel, at.config.AIModel)
}
if chargeErr != nil {
at.logWarnf("⚠️ Failed to record AI charge: %v", chargeErr)
}
}