Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,18 @@ Caveats:

**Auto-configuration**: `obol stack up` → `autoConfigureLLM()` detects host Ollama models, patches LiteLLM config. `obolup.sh` → `check_agent_model_api_key()` reads `~/.openclaw/openclaw.json`, resolves API key from `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` (Anthropic) or `OPENAI_API_KEY` (OpenAI), exports for downstream.

**BYOK cloud providers** (easiest getting-started path) — provider knowledge is a single registry in `internal/model/model.go` (`knownProviders` / `ProviderInfo` with `Mode`/`BaseURL`/`Default`/`SignupURL`/`Free`); adding a provider is one row, no per-provider switch. Built-in: `anthropic`, `openai`, `ollama` (native/local) + OpenAI-compatible aggregators `venice`, `openrouter`, `nvidia`, `gmi`, `novita`, `huggingface` (`Mode=openai-compatible` → `model_list` entry `openai/<id>` + explicit `api_base` + key from the provider's env var; no wildcard). When `--model` is omitted, setup uses the registry `Default` or lists the live `GET <base>/v1/models` (TTY picker / non-TTY error naming real ids). `--free` seeds only the curated free-tier model snapshot (OpenRouter).

Two front doors share one engine (`setupCloudProvider` in `cmd/obol/model.go`):
- `obol buy inference <provider>` — friendly onboarding: opens the provider's `SignupURL` in the browser (`openBrowser`, hermes-style), takes the key (`--api-key` → env var → prompt), wires LiteLLM + syncs agents. `obol buy inference` with a URL/no-arg is still the **x402 crypto-paid seller** path — dispatch keys on whether the positional arg matches a registry provider id.
- `obol model setup <provider> --api-key <key>` — the scriptable, no-browser equivalent. Unlisted endpoints still use `obol model setup custom`.

```bash
obol buy inference venice # opens venice key page, prompts, wires up
obol buy inference openrouter --free # seeds curated free models
obol model setup venice --api-key $VENICE_API_KEY # scriptable / CI
```

**External OpenAI-compatible LLM** (vLLM / sglang / mlx-lm / remote GPU) — canonical user flow, no ConfigMap surgery:

```bash
Expand Down
96 changes: 81 additions & 15 deletions cmd/obol/buy.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,28 +66,45 @@ func buyCommand(cfg *config.Config) *cli.Command {
func buyInferenceCommand(cfg *config.Config) *cli.Command {
return &cli.Command{
Name: "inference",
Usage: "Buy paid inference from an x402-gated seller via the obol-agent",
ArgsUsage: "[<seller-url>]",
Description: `Pre-authorizes an x402-gated inference seller through an obol-agent's wallet.
Usage: "Buy inference for your agents — a hosted BYOK provider (Venice, OpenRouter, …) or an x402-gated seller",
ArgsUsage: "[<provider>|<seller-url>]",
Description: `Two ways to give your agents inference:

Hand the command a seller URL — either a storefront base
("https://inference.v1337.org") or a specific offer
("https://inference.v1337.org/services/aeon") — and the CLI will walk
/api/services.json, pick the inference offer, and pre-sign authorizations
via the agent's remote signer.
1. Hosted provider (BYOK) — hand the command a provider id and it opens
that provider's API-key page in your browser, takes the key, and wires
your agents' LiteLLM gateway to it:

With no URL, the public ` + x402verifier.DefaultBuySellerURL + ` storefront is used.
obol buy inference venice
obol buy inference openrouter --free

In a TTY, the CLI prompts for auto-refill, request count, and
confirmation. Pass --yes / -y for non-interactive runs (CI, scripts) —
--count is required in that mode.
Built-in providers: venice, openrouter, nvidia, gmi, novita,
huggingface (plus anthropic, openai). The key is read from the
provider's env var when already set, so this stays non-interactive in CI.

2. x402-gated seller — hand it a seller URL (a storefront base like
"https://inference.v1337.org" or a specific offer ".../services/aeon")
and the CLI walks /api/services.json, picks the inference offer, and
pre-signs payment authorizations via the agent's remote signer. With no
argument, the public ` + x402verifier.DefaultBuySellerURL + ` storefront is used.

In a TTY the seller flow prompts for auto-refill, request count, and
confirmation. Pass --yes / -y for non-interactive runs (--count required).

Examples:
obol buy inference
obol buy inference venice
obol buy inference openrouter --free
obol buy inference https://inference.v1337.org/services/aeon
obol buy inference https://seller.example/services/foo --yes --count 100
obol buy inference https://seller.example/services/foo --auto-refill --refill-threshold 5 --refill-count 25`,
obol buy inference https://seller.example/services/foo --yes --count 100`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "api-key",
Usage: "API key for a hosted provider (BYOK). Also read from the provider's env var when set.",
Sources: cli.EnvVars("LLM_API_KEY"),
},
&cli.BoolFlag{
Name: "free",
Usage: "For a hosted provider that has them, seed only the curated free-tier models (OpenRouter)",
},
&cli.StringFlag{
Name: "seller",
Usage: "Seller URL (alternative to positional). When neither is set the default storefront is used.",
Expand Down Expand Up @@ -160,13 +177,62 @@ Examples:
}
}

// runBuyInferenceProvider is the BYOK front door: open the provider's
// API-key page (hermes-style openurl), take the key (--api-key → env →
// prompt), then wire the LiteLLM gateway via the shared model-setup
// engine. No wallet, no x402 — this is hosted inference with the user's
// own key, the easiest way to get an agent talking to a model.
func runBuyInferenceProvider(cfg *config.Config, cmd *cli.Command, prof model.ProviderInfo) error {
u := getUI(cmd)
u.Infof("Connecting %s for your agents (bring-your-own-key)", prof.Name)

apiKey := strings.TrimSpace(cmd.String("api-key"))
if apiKey == "" {
if key, envVar := model.ResolveAPIKey(prof.ID); key != "" {
apiKey = key
u.Infof("Using %s API key from %s", prof.Name, envVar)
}
}

// openurl: send the operator to the provider's key page before we
// prompt for the key (skipped when a key is already in hand or non-TTY).
if apiKey == "" && prof.SignupURL != "" && u.IsTTY() && !u.IsJSON() {
u.Infof("Opening %s to create an API key …", prof.SignupURL)
if err := openBrowser(prof.SignupURL); err != nil {
u.Dim(fmt.Sprintf("(couldn't open a browser — visit %s)", prof.SignupURL))
}
}

var models []string
if m := strings.TrimSpace(cmd.String("model")); m != "" {
models = []string{m}
}

// Shared engine: prompts for the key if still empty, seeds --free,
// resolves a model (registry default or live /v1/models), patches
// LiteLLM, and promotes + syncs the agents to use it.
return setupCloudProvider(cfg, u, prof, apiKey, models, cmd.Bool("free"))
}

// runBuyInference is the orchestrator for the new flow. Kept separate from
// the cli.Command literal so the steps stay scannable: resolve agent →
// resolve seller URL → pick catalog entry → resolve token+count+budget →
// confirm → exec buy.py → optional model prefer + agent sync.
func runBuyInference(ctx context.Context, cfg *config.Config, cmd *cli.Command) error {
u := getUI(cmd)

// Front door: if the argument names a hosted provider in the registry
// (venice, openrouter, …) rather than a seller URL, run BYOK onboarding
// — open the provider's key page and wire the LiteLLM gateway. Ollama is
// local and free, so it's not a "buy" target.
arg := strings.TrimSpace(cmd.String("seller"))
if arg == "" {
arg = strings.TrimSpace(cmd.Args().First())
}
if prof, ok := model.ProviderByID(arg); ok && prof.ID != model.ProviderOllama {
return runBuyInferenceProvider(cfg, cmd, prof)
}

u.Info("Purchasing remote inference for running Obol Agents")

target, err := resolveBuyAgent(cfg, cmd)
Expand Down
35 changes: 35 additions & 0 deletions cmd/obol/buy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (

"github.com/ObolNetwork/obol-stack/internal/agentruntime"
"github.com/ObolNetwork/obol-stack/internal/buy"
"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/model"
"github.com/ObolNetwork/obol-stack/internal/schemas"
)

Expand Down Expand Up @@ -600,3 +602,36 @@ func TestLooksLikeURL(t *testing.T) {
}
}
}

// TestBuyInference_BYOKFrontDoor pins the BYOK onboarding surface on
// `obol buy inference`: the command exposes --api-key/--free/--model, and
// every registry provider that isn't local Ollama is recognized as a
// hosted-provider argument (the dispatch the Action keys on).
func TestBuyInference_BYOKFrontDoor(t *testing.T) {
cmd := buyInferenceCommand(&config.Config{})

want := map[string]bool{"api-key": false, "free": false, "model": false, "seller": false}
for _, f := range cmd.Flags {
for _, n := range f.Names() {
if _, ok := want[n]; ok {
want[n] = true
}
}
}
for n, found := range want {
if !found {
t.Errorf("buy inference missing --%s flag", n)
}
}

// Hosted providers route to BYOK onboarding; ollama does not (local).
for _, id := range []string{"venice", "openrouter", "nvidia", "gmi", "novita", "huggingface"} {
p, ok := model.ProviderByID(id)
if !ok || p.ID == model.ProviderOllama {
t.Errorf("provider %q should be a BYOK buy-inference target", id)
}
}
if p, ok := model.ProviderByID("ollama"); !ok || p.ID != model.ProviderOllama {
t.Errorf("ollama must remain a local (non-buy) provider")
}
}
Loading
Loading