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.
- Module:
github.com/solvela-ai/solvela/sdks/go - Source:
sdks/go/in the monorepo - Go: 1.23+
Installation
go get github.com/solvela-ai/solvela/sdks/goThe 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
)| Option | Description |
|---|---|
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_urlmust usehttps://. Plaintexthttp://to a remote host returns*ClientErrorfromNewClient. - Unknown payment schemes from the gateway raise
*ClientErrorat JSON decode time rather than silently mis-branching downstream. MaxPaymentAmountdefaults to 10 USDC (atomic) — a misbehaving gateway cannot drain the wallet on a single call.ExpectedEscrowProgramis pinned toMainnetEscrowProgramIDby default, so an escrow deposit is never signed against an unexpected program — a mismatching 402 returns*EscrowProgramMismatchErrorbefore signing. Repoint withWithExpectedEscrowProgramor clear withDisableEscrowProgramPin.- The HTTP client refuses redirects to keep the
Payment-Signatureheader 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:
- Sends the request.
- Receives 402 with the price quote.
- Picks a compatible scheme (
SchemeExactorSchemeEscrow) whose network and asset match the configured Solana mainnet + USDC mint. - Calls
signer.SignPayment(ctx, amount, payTo, resource, accepted)to build and sign the SPL transfer. - Retries with the
Payment-Signatureheader.
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.
| Field | Type | Description |
|---|---|---|
ID | string | Canonical model identifier (e.g. "openai/gpt-4o") |
Provider | string | Upstream provider ("openai", "anthropic", …) |
DisplayName | string | Human-readable label |
ContextWindow | int | Maximum input tokens |
SupportsStreaming / SupportsTools / SupportsVision / Reasoning | bool | Capability flags |
InputUsdcPerMillion / OutputUsdcPerMillion | float64 | Provider rate in USDC per million tokens (human units, not atomic) |
Currency / FeePercent | string / int | Platform 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)
}
}| Error | Returned when |
|---|---|
*PaymentRequiredError | Gateway returns 402 and no Signer is configured (or no compatible scheme was found). Carries PaymentRequired. |
*PaymentRejectedError | Gateway 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. |
*AmountExceedsMaxError | Gateway's quoted amount exceeds the configured MaxPaymentAmount. Carries Amount and MaxAmount. |
*RecipientMismatchError | Gateway's pay_to doesn't match the configured ExpectedRecipient. |
*EscrowProgramMismatchError | The 402's escrow program ID doesn't match the configured ExpectedEscrowProgram pin. Returned before any escrow deposit is signed. Carries Expected and Actual. |
*InsufficientBalanceError | Wallet balance is too low for the quoted amount. Carries Have / Need. |
*GatewayError | Non-200/402 HTTP response. Carries Status and Message. |
*SignerError | The configured Signer failed to build or sign the payment transaction. |
*WalletError | Wallet operation failed (e.g. base58 decode, keypair length). |
*TimeoutError | HTTP timeout exceeded. Carries TimeoutSecs. |
*QualityDegradedError | Response failed the quality check after MaxQualityRetries. Carries Reason and the degraded Response. |
*ClientError | Catch-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/smokeCancellation 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.