mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-04 01:48:22 +08:00
4cadf6f442
- Fix Stop() race condition using sync.Once - Add ensureHistory() to prevent nil panic in planner/dispatcher - Add bounds check on trader ID slicing - Log saveExecutionState and clearSetupState errors instead of discarding - Remove always-true modelID condition in onboard setup - Add Chinese setup keywords and expand model name aliases - Strip max_tokens from claw402 requests to avoid thinking-model budget exhaustion - Hide Agent nav tab (Beta) pending merge to main - Sync tests with code changes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
280 lines
9.1 KiB
Go
280 lines
9.1 KiB
Go
package payment
|
|
|
|
import (
|
|
"crypto/ecdsa"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/ethereum/go-ethereum/crypto"
|
|
|
|
"nofx/mcp"
|
|
"nofx/mcp/provider"
|
|
"nofx/store"
|
|
"nofx/wallet"
|
|
)
|
|
|
|
// Per-call cost buffers for preflight. Reasoner models emit long chain-of-thought
|
|
// tokens whose cost can far exceed the flat per-call estimate in store.GetModelPrice,
|
|
// so they use a larger multiplier.
|
|
const (
|
|
preflightSafetyMultiplier = 1.5
|
|
preflightReasonerSafetyMultiplier = 4.0
|
|
)
|
|
|
|
// ErrInsufficientFunds is returned when the claw402 wallet does not hold
|
|
// enough USDC to cover the estimated cost of a call. Callers can type-assert
|
|
// to surface balance/needed/address to the UI.
|
|
type ErrInsufficientFunds struct {
|
|
Address string
|
|
Balance float64
|
|
Needed float64
|
|
Model string
|
|
}
|
|
|
|
func (e *ErrInsufficientFunds) Error() string {
|
|
return fmt.Sprintf(
|
|
"claw402 insufficient USDC: wallet=%s balance=$%.4f needed=$%.4f model=%s",
|
|
shortAddr(e.Address), e.Balance, e.Needed, e.Model,
|
|
)
|
|
}
|
|
|
|
// shortAddr renders 0x1234…abcd for log/error strings that may leak into
|
|
// telemetry bundles. The full address stays on the struct for programmatic use.
|
|
func shortAddr(addr string) string {
|
|
if len(addr) < 10 {
|
|
return addr
|
|
}
|
|
return addr[:6] + "…" + addr[len(addr)-4:]
|
|
}
|
|
|
|
const (
|
|
DefaultClaw402URL = "https://claw402.ai"
|
|
DefaultClaw402Model = "deepseek-v4-flash"
|
|
)
|
|
|
|
// claw402ModelEndpoints maps user-friendly model names to claw402 API paths.
|
|
var claw402ModelEndpoints = map[string]string{
|
|
// OpenAI
|
|
"gpt-5.4": "/api/v1/ai/openai/chat/5.4",
|
|
"gpt-5.4-pro": "/api/v1/ai/openai/chat/5.4-pro",
|
|
"gpt-5.3": "/api/v1/ai/openai/chat/5.3",
|
|
"gpt-5-mini": "/api/v1/ai/openai/chat/5-mini",
|
|
// Anthropic
|
|
"claude-opus": "/api/v1/ai/anthropic/messages/opus",
|
|
// DeepSeek
|
|
"deepseek": "/api/v1/ai/deepseek/chat",
|
|
"deepseek-reasoner": "/api/v1/ai/deepseek/chat/reasoner",
|
|
"deepseek-v4-flash": "/api/v1/ai/deepseek/v4-flash",
|
|
"deepseek-v4-pro": "/api/v1/ai/deepseek/v4-pro",
|
|
// Qwen
|
|
"qwen-max": "/api/v1/ai/qwen/chat/max",
|
|
"qwen-plus": "/api/v1/ai/qwen/chat/plus",
|
|
"qwen-turbo": "/api/v1/ai/qwen/chat/turbo",
|
|
"qwen-flash": "/api/v1/ai/qwen/chat/flash",
|
|
// Grok
|
|
"grok-4.1": "/api/v1/ai/grok/chat/4.1",
|
|
// Gemini
|
|
"gemini-3.1-pro": "/api/v1/ai/gemini/chat/3.1-pro",
|
|
// Kimi
|
|
"kimi-k2.5": "/api/v1/ai/kimi/chat/k2.5",
|
|
// Z.AI (智谱)
|
|
"glm-5": "/api/v1/ai/zhipu/chat",
|
|
"glm-5-turbo": "/api/v1/ai/zhipu/chat/turbo",
|
|
}
|
|
|
|
func init() {
|
|
mcp.RegisterProvider(mcp.ProviderClaw402, func(opts ...mcp.ClientOption) mcp.AIClient {
|
|
return NewClaw402ClientWithOptions(opts...)
|
|
})
|
|
}
|
|
|
|
// Claw402Client implements AIClient using claw402.ai's x402 v2 USDC payment gateway.
|
|
// When the selected model routes to an Anthropic endpoint, it automatically uses
|
|
// the Anthropic wire format for requests and responses (via an internal ClaudeClient).
|
|
type Claw402Client struct {
|
|
*mcp.Client
|
|
privateKey *ecdsa.PrivateKey
|
|
claudeProxy *provider.ClaudeClient // non-nil when endpoint is /anthropic/
|
|
}
|
|
|
|
func (c *Claw402Client) BaseClient() *mcp.Client { return c.Client }
|
|
|
|
// NewClaw402Client creates a claw402 client (backward compatible).
|
|
func NewClaw402Client() mcp.AIClient {
|
|
return NewClaw402ClientWithOptions()
|
|
}
|
|
|
|
// NewClaw402ClientWithOptions creates a claw402 client with options.
|
|
func NewClaw402ClientWithOptions(opts ...mcp.ClientOption) mcp.AIClient {
|
|
baseOpts := []mcp.ClientOption{
|
|
mcp.WithProvider(mcp.ProviderClaw402),
|
|
mcp.WithModel(DefaultClaw402Model),
|
|
mcp.WithBaseURL(DefaultClaw402URL),
|
|
mcp.WithTimeout(X402Timeout),
|
|
mcp.WithMaxRetries(1), // disable outer retry — inner x402 loop handles retries; outer retry causes duplicate payments
|
|
}
|
|
allOpts := append(baseOpts, opts...)
|
|
baseClient := mcp.NewClient(allOpts...).(*mcp.Client)
|
|
baseClient.UseFullURL = true
|
|
baseClient.BaseURL = DefaultClaw402URL + claw402ModelEndpoints[DefaultClaw402Model]
|
|
|
|
c := &Claw402Client{Client: baseClient}
|
|
baseClient.Hooks = c
|
|
return c
|
|
}
|
|
|
|
// SetAPIKey stores the EVM private key and selects the model endpoint.
|
|
func (c *Claw402Client) SetAPIKey(apiKey string, _ string, customModel string) {
|
|
hexKey := strings.TrimPrefix(apiKey, "0x")
|
|
privKey, err := crypto.HexToECDSA(hexKey)
|
|
if err != nil {
|
|
c.Log.Warnf("⚠️ [MCP] Claw402: invalid private key: %v", err)
|
|
} else {
|
|
c.privateKey = privKey
|
|
c.APIKey = apiKey
|
|
addr := crypto.PubkeyToAddress(privKey.PublicKey).Hex()
|
|
c.Log.Infof("🔧 [MCP] Claw402 wallet: %s", addr)
|
|
}
|
|
if customModel != "" {
|
|
c.Model = customModel
|
|
}
|
|
endpoint := c.resolveEndpoint()
|
|
c.BaseURL = DefaultClaw402URL + endpoint
|
|
|
|
// Anthropic endpoints need different wire format (Messages API)
|
|
if strings.Contains(endpoint, "/anthropic/") {
|
|
c.claudeProxy = &provider.ClaudeClient{Client: c.Client}
|
|
c.Log.Infof("🔧 [MCP] Claw402 model: %s → %s (Anthropic format)", c.Model, endpoint)
|
|
} else {
|
|
c.claudeProxy = nil
|
|
c.Log.Infof("🔧 [MCP] Claw402 model: %s → %s", c.Model, endpoint)
|
|
}
|
|
}
|
|
|
|
// resolveEndpoint returns the API path for the configured model.
|
|
func (c *Claw402Client) resolveEndpoint() string {
|
|
if ep, ok := claw402ModelEndpoints[c.Model]; ok {
|
|
return ep
|
|
}
|
|
// Allow raw path override (e.g. "/api/v1/ai/openai/chat/5.4")
|
|
if strings.HasPrefix(c.Model, "/api/") {
|
|
return c.Model
|
|
}
|
|
return claw402ModelEndpoints[DefaultClaw402Model]
|
|
}
|
|
|
|
func (c *Claw402Client) SetAuthHeader(h http.Header) { X402SetAuthHeader(h) }
|
|
|
|
func (c *Claw402Client) Call(systemPrompt, userPrompt string) (string, error) {
|
|
if err := c.preflightBalance(); err != nil {
|
|
return "", err
|
|
}
|
|
return X402CallStream(c.Client, c.signPayment, "Claw402", systemPrompt, userPrompt, nil)
|
|
}
|
|
|
|
func (c *Claw402Client) CallWithRequestFull(req *mcp.Request) (*mcp.LLMResponse, error) {
|
|
if err := c.preflightBalance(); err != nil {
|
|
return nil, err
|
|
}
|
|
return X402CallFull(c.Client, c.signPayment, "Claw402", req)
|
|
}
|
|
|
|
// walletAddress derives the EVM address from the configured private key.
|
|
// Returns "" when no key has been set (client unconfigured).
|
|
func (c *Claw402Client) walletAddress() string {
|
|
if c.privateKey == nil {
|
|
return ""
|
|
}
|
|
return crypto.PubkeyToAddress(c.privateKey.PublicKey).Hex()
|
|
}
|
|
|
|
// preflightBalance short-circuits a call when the wallet cannot cover the
|
|
// estimated cost. RPC failures fall through — x402 will still reject an
|
|
// actually-empty wallet, so we prefer availability over extra strictness.
|
|
func (c *Claw402Client) preflightBalance() error {
|
|
addr := c.walletAddress()
|
|
if addr == "" {
|
|
return nil
|
|
}
|
|
balance, err := wallet.QueryUSDCBalanceCached(addr)
|
|
if err != nil {
|
|
c.Log.Warnf("⚠️ [MCP] Claw402 balance preflight skipped (RPC error): %v", err)
|
|
return nil
|
|
}
|
|
multiplier := preflightSafetyMultiplier
|
|
if strings.Contains(strings.ToLower(c.Model), "reasoner") {
|
|
multiplier = preflightReasonerSafetyMultiplier
|
|
}
|
|
needed := store.GetModelPrice(c.Model) * multiplier
|
|
if balance < needed {
|
|
return &ErrInsufficientFunds{
|
|
Address: addr,
|
|
Balance: balance,
|
|
Needed: needed,
|
|
Model: c.Model,
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// signPayment signs x402 v2 EIP-712 payment on Base chain + USDC.
|
|
func (c *Claw402Client) signPayment(paymentHeaderB64 string) (string, error) {
|
|
return SignBasePaymentHeader(c.privateKey, paymentHeaderB64, "Claw402")
|
|
}
|
|
|
|
// ── Format overrides for Anthropic endpoints ─────────────────────────────────
|
|
|
|
// stripMaxTokens removes per-call max_tokens caps from a body destined for
|
|
// claw402. The gateway already enforces a per-route default/floor/cap
|
|
// (see providers/*.yaml token_default_max_out / token_min_max_out /
|
|
// token_max_out_cap). Sending a small max_tokens here on a thinking model
|
|
// (Kimi K2.5, DeepSeek R1/V4) caused reasoning tokens to consume the entire
|
|
// budget and left `delta.content` empty, surfacing as "no content received".
|
|
// upto settles on real usage, so removing the cap costs nothing extra.
|
|
func stripMaxTokens(body map[string]any) map[string]any {
|
|
if body == nil {
|
|
return body
|
|
}
|
|
delete(body, "max_tokens")
|
|
delete(body, "max_completion_tokens")
|
|
return body
|
|
}
|
|
|
|
func (c *Claw402Client) BuildMCPRequestBody(systemPrompt, userPrompt string) map[string]any {
|
|
if c.claudeProxy != nil {
|
|
return c.claudeProxy.BuildMCPRequestBody(systemPrompt, userPrompt)
|
|
}
|
|
return stripMaxTokens(c.Client.BuildMCPRequestBody(systemPrompt, userPrompt))
|
|
}
|
|
|
|
func (c *Claw402Client) BuildRequestBodyFromRequest(req *mcp.Request) map[string]any {
|
|
if c.claudeProxy != nil {
|
|
return c.claudeProxy.BuildRequestBodyFromRequest(req)
|
|
}
|
|
return stripMaxTokens(c.Client.BuildRequestBodyFromRequest(req))
|
|
}
|
|
|
|
func (c *Claw402Client) ParseMCPResponse(body []byte) (string, error) {
|
|
if c.claudeProxy != nil {
|
|
return c.claudeProxy.ParseMCPResponse(body)
|
|
}
|
|
return c.Client.ParseMCPResponse(body)
|
|
}
|
|
|
|
func (c *Claw402Client) ParseMCPResponseFull(body []byte) (*mcp.LLMResponse, error) {
|
|
if c.claudeProxy != nil {
|
|
return c.claudeProxy.ParseMCPResponseFull(body)
|
|
}
|
|
return c.Client.ParseMCPResponseFull(body)
|
|
}
|
|
|
|
// BuildUrl returns the full claw402 endpoint URL.
|
|
func (c *Claw402Client) BuildUrl() string {
|
|
return c.BaseURL
|
|
}
|
|
|
|
func (c *Claw402Client) BuildRequest(url string, jsonData []byte) (*http.Request, error) {
|
|
return X402BuildRequest(url, jsonData)
|
|
}
|