SDKs

Go SDK

Go client for Solvela with functional-option configuration and transparent x402 payments

solvela-go is the official Go SDK for the Solvela gateway. Context-aware, goroutine-safe, dataclass-style typed, and handles the x402 payment handshake transparently when a Signer is wired in.

Installation

go get github.com/solvela-ai/solvela/sdks/go

The SDK depends only on the standard library plus github.com/mr-tron/base58 and filippo.io/edwards25519. No full Solana SDK is pulled in — the built-in KeypairSigner builds transactions over raw Solana JSON-RPC.

Note

KeypairSigner signs escrow only. The default signer (solvela.NewKeypairSigner(wallet, rpcURL)) builds real on-chain escrow deposits, and scheme selection automatically prefers escrow for it (via the optional SchemeCapable interface). The exact (direct USDC-SPL transfer) scheme is intentionally unimplemented in Go — SignPayment returns a clear error rather than silently substituting another scheme. For exact-scheme payments, use the Rust SDK or supply a custom Signer (see Custom signers).

Quick start

package main

import (
	"context"
	"fmt"
	"log"

	solvela "github.com/solvela-ai/solvela/sdks/go"
)

func main() {
	wallet, _, err := solvela.CreateWallet()
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Wallet:", wallet.Address())

	client, err := solvela.NewClient(wallet, nil,
		solvela.WithGatewayURL("https://api.solvela.ai"),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer client.Close()

	models, err := client.Models(context.Background())
	if err != nil {
		log.Fatal(err)
	}
	for _, m := range models[:3] {
		fmt.Printf("%s — %s ($%.2f/M in)\n", m.ID, m.DisplayName, m.InputUsdcPerMillion)
	}
}

Without a Signer, paid endpoints return *PaymentRequiredError on the first call so the agent can react.

Configuration

NewClient takes a *Wallet, a Signer (optional — pass nil if you only need unsigned endpoints like Models()), and zero or more functional options.

import (
	"time"

	solvela "github.com/solvela-ai/solvela/sdks/go"
)

client, err := solvela.NewClient(wallet, signer,
	solvela.WithGatewayURL("https://api.solvela.ai"),     // default: https://api.solvela.ai
	solvela.WithRPCURL("https://api.mainnet-beta.solana.com"),
	solvela.WithTimeout(60*time.Second),                  // default: 180s
	solvela.WithMaxPaymentAmount(10_000_000),             // per-call cap: 10 USDC atomic
	solvela.WithExpectedRecipient("recipient-wallet-pub"),// if set, gateway must pay-to this
	solvela.WithExpectedEscrowProgram(solvela.MainnetEscrowProgramID), // escrow program pin (this is the default)
	solvela.WithCache(true),                              // opt-in response caching
	solvela.WithSessions(true),                           // opt-in three-strike escalation
	solvela.WithSessionTTL(30*time.Minute),
	solvela.WithQualityCheck(true),                       // opt-in degraded-response retry
	solvela.WithMaxQualityRetries(2),
	solvela.WithFreeFallbackModel("openai/gpt-4o-mini"),  // swap on zero balance
)
OptionDescription
WithGatewayURL(url)Gateway base URL. Plaintext http:// only allowed for localhost, 127.0.0.1, ::1.
WithRPCURL(url)Solana JSON-RPC endpoint your Signer should target.
WithTimeout(d)Override the 180s default HTTP timeout.
WithMaxPaymentAmount(atomic)Cap each signed payment. Default: 10 USDC (10_000_000 atomic units).
WithExpectedRecipient(addr)Refuse 402s whose pay_to doesn't match.
WithExpectedEscrowProgram(id)Pin the escrow program ID the client will sign deposits against. Default: MainnetEscrowProgramID.
DisableEscrowProgramPin()Clear the escrow-program pin, allowing any advertised program ID. Prefer WithExpectedEscrowProgram for non-default deployments.
WithCache(bool) / WithSessions(bool)Toggle in-memory caches. Default: both off.
WithSessionTTL(d)Lifetime of session entries when sessions are on.
WithQualityCheck(bool) / WithMaxQualityRetries(n)Retry on empty/repetitive/truncated responses.
WithFreeFallbackModel(id)Substitute this model when the polled balance hits zero. Requires WithBalanceMonitor.
WithBalanceMonitor(interval, fetcher)Background USDC balance poller (see Balance monitoring).

Security defaults

  • Non-loopback gateway_url must use https://. Plaintext http:// to a remote host returns *ClientError from NewClient.
  • Unknown payment schemes from the gateway raise *ClientError at JSON decode time rather than silently mis-branching downstream.
  • MaxPaymentAmount defaults to 10 USDC (atomic) — a misbehaving gateway cannot drain the wallet on a single call.
  • ExpectedEscrowProgram is pinned to MainnetEscrowProgramID by default, so an escrow deposit is never signed against an unexpected program — a mismatching 402 returns *EscrowProgramMismatchError before signing. Repoint with WithExpectedEscrowProgram or clear with DisableEscrowProgramPin.
  • The HTTP client refuses redirects to keep the Payment-Signature header from leaking to a third party.

The chat flow

Chat() runs the default path as a single request. Opt-in flags add steps: balance guard → session lookup → cache check → request → quality check → cache store → session record.

ChatResponse is OpenAI-compatible — resp.ID, resp.Choices[i].Message, resp.Usage.

With a signer wired up, the SDK automatically:

  1. Sends the request.
  2. Receives 402 with the price quote.
  3. Picks a compatible scheme (SchemeExact or SchemeEscrow) whose network and asset match the configured Solana mainnet + USDC mint.
  4. Calls signer.SignPayment(ctx, amount, payTo, resource, accepted) to build and sign the SPL transfer.
  5. Retries with the Payment-Signature header.

If the gateway returns 402 again after the signed retry, the SDK returns *PaymentRejectedError carrying the rejection reason. As of v0.4.0 it also carries the second 402's parsed body in the PaymentRequired field (cost breakdown, accepted schemes, gateway error message) — on both the non-streaming and streaming paths — so callers can inspect why the payment was rejected without re-parsing the response. The field is nil when the error is constructed from a reason alone, matching Python's payment_required and TypeScript's paymentRequired.

Streaming

ch, err := client.ChatStream(ctx, req)
if err != nil {
	log.Fatal(err)
}
for chunk := range ch {
	if chunk.Err != nil {
		log.Fatal(chunk.Err)
	}
	if len(chunk.Chunk.Choices) > 0 && chunk.Chunk.Choices[0].Delta.Content != nil {
		fmt.Print(*chunk.Chunk.Choices[0].Delta.Content)
	}
}
fmt.Println()

ChatStream runs the same preflight payment handshake as Chat — a Signer is required for paid streaming, otherwise the call returns *PaymentRequiredError before opening the stream. A post-signing 402 on the streaming POST is converted to *PaymentRejectedError, exactly like the non-streaming path — errors.As(err, &rejErr) distinguishes "signed and rejected" from a pre-signing *PaymentRequiredError challenge on both paths. The conversion carries the rejection's parsed 402 body across into rejErr.PaymentRequired (v0.4.0+).

Models and pricing

models, err := client.Models(ctx)
if err != nil {
	log.Fatal(err)
}
for _, m := range models {
	fmt.Printf("%-35s in=%5.2f  out=%5.2f  ctx=%7d  tools=%t vision=%t reasoning=%t\n",
		m.ID, m.InputUsdcPerMillion, m.OutputUsdcPerMillion, m.ContextWindow,
		m.SupportsTools, m.SupportsVision, m.Reasoning)
}

ModelInfo mirrors the gateway's /v1/models shape — capabilities and pricing are flattened off the nested capabilities and pricing objects the gateway emits.

FieldTypeDescription
IDstringCanonical model identifier (e.g. "openai/gpt-4o")
ProviderstringUpstream provider ("openai", "anthropic", …)
DisplayNamestringHuman-readable label
ContextWindowintMaximum input tokens
SupportsStreaming / SupportsTools / SupportsVision / ReasoningboolCapability flags
InputUsdcPerMillion / OutputUsdcPerMillionfloat64Provider rate in USDC per million tokens (human units, not atomic)
Currency / FeePercentstring / intPlatform fee terms ("USDC" / 5 by default)

Error hierarchy

Every SDK error is a concrete struct implementing error. Use errors.As to branch on a specific type:

import "errors"

resp, err := client.Chat(ctx, req)
if err != nil {
	var prErr *solvela.PaymentRequiredError
	var rejErr *solvela.PaymentRejectedError
	var amtErr *solvela.AmountExceedsMaxError
	var gwErr *solvela.GatewayError
	var toErr *solvela.TimeoutError
	switch {
	case errors.As(err, &prErr):
		fmt.Printf("Payment needed: %s %s\n",
			prErr.PaymentRequired.CostBreakdown.Total,
			prErr.PaymentRequired.CostBreakdown.Currency)
	case errors.As(err, &rejErr):
		fmt.Printf("Rejected: %s\n", rejErr.Reason)
	case errors.As(err, &amtErr):
		fmt.Printf("Quote %d exceeds local cap %d\n", amtErr.Amount, amtErr.MaxAmount)
	case errors.As(err, &gwErr):
		fmt.Printf("Gateway HTTP %d: %s\n", gwErr.Status, gwErr.Message)
	case errors.As(err, &toErr):
		fmt.Printf("Timed out after %.1fs\n", toErr.TimeoutSecs)
	default:
		log.Fatal(err)
	}
}
ErrorReturned when
*PaymentRequiredErrorGateway returns 402 and no Signer is configured (or no compatible scheme was found). Carries PaymentRequired.
*PaymentRejectedErrorGateway returns 402 again after a signed retry. Carries Reason, plus (v0.4.0+) the second 402's parsed body in PaymentRequired — populated on both the non-streaming and streaming paths, nil if constructed from a reason alone.
*AmountExceedsMaxErrorGateway's quoted amount exceeds the configured MaxPaymentAmount. Carries Amount and MaxAmount.
*RecipientMismatchErrorGateway's pay_to doesn't match the configured ExpectedRecipient.
*EscrowProgramMismatchErrorThe 402's escrow program ID doesn't match the configured ExpectedEscrowProgram pin. Returned before any escrow deposit is signed. Carries Expected and Actual.
*InsufficientBalanceErrorWallet balance is too low for the quoted amount. Carries Have / Need.
*GatewayErrorNon-200/402 HTTP response. Carries Status and Message.
*SignerErrorThe configured Signer failed to build or sign the payment transaction.
*WalletErrorWallet operation failed (e.g. base58 decode, keypair length).
*TimeoutErrorHTTP timeout exceeded. Carries TimeoutSecs.
*QualityDegradedErrorResponse failed the quality check after MaxQualityRetries. Carries Reason and the degraded Response.
*ClientErrorCatch-all for wire-format violations (unknown payment scheme, malformed amount, invalid gateway URL).

Balance monitoring

For long-running agents, WithBalanceMonitor starts a background goroutine that polls USDC balance and wires transitions back into the client's balance guard.

fetcher := func() (float64, error) {
	// Query your Solana RPC for the wallet's USDC token-account balance.
	// Return the float USDC value (not atomic units).
	return queryUsdcBalance(wallet.Address())
}

client, err := solvela.NewClient(wallet, signer,
	solvela.WithGatewayURL("https://api.solvela.ai"),
	solvela.WithBalanceMonitor(30*time.Second, fetcher),
	solvela.WithFreeFallbackModel("openai/gpt-4o-mini"),
)
if err != nil {
	log.Fatal(err)
}
defer client.Close()

When the polled balance hits 0.0 and FreeFallbackModel is set, the chat balance guard swaps the requested model for the free fallback automatically. Without a monitor, LastKnownBalance stays unset and the guard is dormant.

Concurrency

SolvelaClient, ResponseCache, SessionStore, and BalanceMonitor are all goroutine-safe — their internal state is protected by a mutex. Share one client across goroutines:

var wg sync.WaitGroup
for _, prompt := range prompts {
	wg.Add(1)
	go func(p string) {
		defer wg.Done()
		req := &solvela.ChatRequest{
			Model: "openai/gpt-4o-mini",
			Messages: []solvela.ChatMessage{{Role: solvela.RoleUser, Content: p}},
		}
		if _, err := client.Chat(ctx, req); err != nil {
			log.Println(err)
		}
	}(prompt)
}
wg.Wait()

Custom signers

The built-in KeypairSigner signs escrow-scheme payments only. To sign exact-scheme payments — or to plug in HSM-backed signing or a custodial backend — implement the Signer interface yourself:

type Signer interface {
	SignPayment(
		ctx context.Context,
		amountAtomic uint64,
		recipient string,
		resource solvela.Resource,
		accepted solvela.PaymentAccept,
	) (*solvela.PaymentPayload, error)
}

A minimal sketch using crypto/ed25519 plus a Solana JSON-RPC client of your choice:

type myKeypairSigner struct {
	wallet *solvela.Wallet
	rpc    SolanaRPC // your client — e.g. gagliardetto/solana-go
}

func (s myKeypairSigner) SignPayment(
	ctx context.Context,
	amountAtomic uint64,
	recipient string,
	resource solvela.Resource,
	accepted solvela.PaymentAccept,
) (*solvela.PaymentPayload, error) {
	// 1. Build a USDC-SPL transfer to `recipient` for `amountAtomic`.
	// 2. Sign with s.wallet.Sign(...) or by passing the raw key into your RPC client.
	// 3. Return a PaymentPayload populated with the signed transaction.
	signedTxB64 := buildAndSignUsdcSplTransfer(ctx, s.rpc, s.wallet, recipient, amountAtomic)
	return &solvela.PaymentPayload{
		X402Version: solvela.X402Version,
		Resource:    resource,
		Accepted:    accepted,
		Payload:     solvela.SolanaPayload{Transaction: signedTxB64},
	}, nil
}

Wire amounts (PaymentAccept.Amount) are decimal strings in atomic USDC units (1 USDC = 1,000,000). SignPayment is invoked with amountAtomic already parsed by the SDK.

Smoke harness

A smoke test in sdks/go/scripts/smoke/main.go exercises the wire contract against a live gateway: it lists models, asserts at least one reports streaming + paid input pricing, and issues an unsigned chat to verify the 402 round-trip parses cleanly. Run it before tagging a release:

SOLVELA_GATEWAY_URL=https://staging.solvela.ai go run ./scripts/smoke

Cancellation and timeouts

WithTimeout(d) (default 180s) caps every HTTP call. Cancel an in-flight Chat by cancelling the context.Context you pass in — the underlying http.Client honors ctx.Done() and surfaces the cancellation as either ctx.Err() or *TimeoutError.

Note

Source: sdks/go/ in the monorepo. The canonical chat-flow entry point is client.go; the smoke harness lives at sdks/go/scripts/smoke/main.go. See also x402 Protocol for the underlying payment flow.