mirror of
https://github.com/NoFxAiOS/nofx.git
synced 2026-07-28 22:42:52 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user