Skip to content

Commit f3bc655

Browse files
committed
feat: reduces optionality for model setup
1 parent 3a5c4af commit f3bc655

5 files changed

Lines changed: 128 additions & 132 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -262,16 +262,15 @@ Caveats:
262262

263263
**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.
264264

265-
**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).
265+
**BYOK cloud providers** (easiest getting-started path) — provider knowledge is a single registry in `internal/model/model.go` (`knownProviders` / `ProviderInfo` with `Mode`/`BaseURL`/`Default`/`KeyURL`/`JoinURL`/`Free`); adding a provider is one row, no per-provider switch. `KeyURL` is the API-key dashboard (assumes account); optional `JoinURL` is a new-user landing page (may carry a referral tag) used in preference to `KeyURL` for browser-open and "new to X? Sign up" hints. 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 the curated free-tier model snapshot (currently OpenRouter only) intersected against the live `/v1/models` response to drop rotated-out ids; auto-applied for `openrouter` when no `--model` is passed.
266266

267-
Two front doors share one engine (`setupCloudProvider` in `cmd/obol/model.go`):
268-
- `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.
269-
- `obol model setup <provider> --api-key <key>` — the scriptable, no-browser equivalent. Unlisted endpoints still use `obol model setup custom`.
267+
Single front door: `obol model setup` (engine: `setupCloudProvider` in `cmd/obol/model.go`). Interactive picker defaults to OpenRouter — `obol model setup` with no flags walks a TTY user through provider pick → browser open at `JoinURL`/`KeyURL` → key prompt → free-roster seeding. Scriptable variant: `obol model setup --provider <id> --api-key <key>`. Unlisted endpoints: `obol model setup custom --endpoint … --model …`. `obol buy inference <provider>` is reserved for future credit top-ups against remote providers; today it errors with a redirect to `obol model setup`. `obol buy inference [<seller-url>]` (URL or no arg) is the **x402 crypto-paid seller** path — unchanged.
270268

271269
```bash
272-
obol buy inference venice # opens venice key page, prompts, wires up
273-
obol buy inference openrouter --free # seeds curated free models
274-
obol model setup venice --api-key $VENICE_API_KEY # scriptable / CI
270+
obol model setup # interactive; default = openrouter + free roster
271+
obol model setup --provider venice # opens https://venice.ai/chat?ref=ZynMuD (TTY) + prompts for key
272+
obol model setup --provider venice --api-key $VENICE_API_KEY # scriptable / CI
273+
obol model setup --provider openrouter --model openrouter/auto # paid OpenRouter (skips free-roster seeding)
275274
```
276275

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

cmd/obol/buy.go

Lines changed: 19 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -66,45 +66,27 @@ func buyCommand(cfg *config.Config) *cli.Command {
6666
func buyInferenceCommand(cfg *config.Config) *cli.Command {
6767
return &cli.Command{
6868
Name: "inference",
69-
Usage: "Buy inference for your agents — a hosted BYOK provider (Venice, OpenRouter, …) or an x402-gated seller",
70-
ArgsUsage: "[<provider>|<seller-url>]",
71-
Description: `Two ways to give your agents inference:
69+
Usage: "Buy inference for your agents from an x402-gated seller",
70+
ArgsUsage: "[<seller-url>]",
71+
Description: `Buy x402-gated inference from a seller.
7272
73-
1. Hosted provider (BYOK) — hand the command a provider id and it opens
74-
that provider's API-key page in your browser, takes the key, and wires
75-
your agents' LiteLLM gateway to it:
73+
Hand the command a seller URL (a storefront base like
74+
"https://inference.v1337.org" or a specific offer ".../services/aeon") and
75+
the CLI walks /api/services.json, picks the inference offer, and pre-signs
76+
payment authorizations via the agent's remote signer. With no argument the
77+
public ` + x402verifier.DefaultBuySellerURL + ` storefront is used.
7678
77-
obol buy inference venice
78-
obol buy inference openrouter --free
79+
In a TTY the flow prompts for auto-refill, request count, and confirmation.
80+
Pass --yes / -y for non-interactive runs (--count required).
7981
80-
Built-in providers: venice, openrouter, nvidia, gmi, novita,
81-
huggingface (plus anthropic, openai). The key is read from the
82-
provider's env var when already set, so this stays non-interactive in CI.
83-
84-
2. x402-gated seller — hand it a seller URL (a storefront base like
85-
"https://inference.v1337.org" or a specific offer ".../services/aeon")
86-
and the CLI walks /api/services.json, picks the inference offer, and
87-
pre-signs payment authorizations via the agent's remote signer. With no
88-
argument, the public ` + x402verifier.DefaultBuySellerURL + ` storefront is used.
89-
90-
In a TTY the seller flow prompts for auto-refill, request count, and
91-
confirmation. Pass --yes / -y for non-interactive runs (--count required).
82+
For hosted BYOK providers (Venice, OpenRouter, …) use ` + "`obol model setup`" + `
83+
instead — that path takes the API key and wires LiteLLM directly, no x402.
9284
9385
Examples:
94-
obol buy inference venice
95-
obol buy inference openrouter --free
86+
obol buy inference
9687
obol buy inference https://inference.v1337.org/services/aeon
9788
obol buy inference https://seller.example/services/foo --yes --count 100`,
9889
Flags: []cli.Flag{
99-
&cli.StringFlag{
100-
Name: "api-key",
101-
Usage: "API key for a hosted provider (BYOK). Also read from the provider's env var when set.",
102-
Sources: cli.EnvVars("LLM_API_KEY"),
103-
},
104-
&cli.BoolFlag{
105-
Name: "free",
106-
Usage: "For a hosted provider that has them, seed only the curated free-tier models (OpenRouter)",
107-
},
10890
&cli.StringFlag{
10991
Name: "seller",
11092
Usage: "Seller URL (alternative to positional). When neither is set the default storefront is used.",
@@ -177,66 +159,24 @@ Examples:
177159
}
178160
}
179161

180-
// runBuyInferenceProvider is the BYOK front door: open the provider's
181-
// API-key page (hermes-style openurl), take the key (--api-key → env →
182-
// prompt), then wire the LiteLLM gateway via the shared model-setup
183-
// engine. No wallet, no x402 — this is hosted inference with the user's
184-
// own key, the easiest way to get an agent talking to a model.
185-
func runBuyInferenceProvider(cfg *config.Config, cmd *cli.Command, prof model.ProviderInfo) error {
186-
u := getUI(cmd)
187-
u.Infof("Connecting %s for your agents (bring-your-own-key)", prof.Name)
188-
189-
apiKey := strings.TrimSpace(cmd.String("api-key"))
190-
if apiKey == "" {
191-
if key, envVar := model.ResolveAPIKey(prof.ID); key != "" {
192-
apiKey = key
193-
u.Infof("Using %s API key from %s", prof.Name, envVar)
194-
}
195-
}
196-
197-
// openurl: send the operator to the provider's onboarding page before
198-
// we prompt for the key (skipped when a key is already in hand or
199-
// non-TTY). Prefer JoinURL (the new-user landing page, possibly
200-
// referral-tagged) when set; fall back to SignupURL (keys dashboard).
201-
onboardURL := prof.JoinURL
202-
if onboardURL == "" {
203-
onboardURL = prof.SignupURL
204-
}
205-
if apiKey == "" && onboardURL != "" && u.IsTTY() && !u.IsJSON() {
206-
u.Infof("Opening %s to sign up / create an API key …", onboardURL)
207-
if err := openBrowser(onboardURL); err != nil {
208-
u.Dim(fmt.Sprintf("(couldn't open a browser — visit %s)", onboardURL))
209-
}
210-
}
211-
212-
var models []string
213-
if m := strings.TrimSpace(cmd.String("model")); m != "" {
214-
models = []string{m}
215-
}
216-
217-
// Shared engine: prompts for the key if still empty, seeds --free,
218-
// resolves a model (registry default or live /v1/models), patches
219-
// LiteLLM, and promotes + syncs the agents to use it.
220-
return setupCloudProvider(cfg, u, prof, apiKey, models, cmd.Bool("free"))
221-
}
222-
223162
// runBuyInference is the orchestrator for the new flow. Kept separate from
224163
// the cli.Command literal so the steps stay scannable: resolve agent →
225164
// resolve seller URL → pick catalog entry → resolve token+count+budget →
226165
// confirm → exec buy.py → optional model prefer + agent sync.
227166
func runBuyInference(ctx context.Context, cfg *config.Config, cmd *cli.Command) error {
228167
u := getUI(cmd)
229168

230-
// Front door: if the argument names a hosted provider in the registry
231-
// (venice, openrouter, …) rather than a seller URL, run BYOK onboarding
232-
// — open the provider's key page and wire the LiteLLM gateway. Ollama is
233-
// local and free, so it's not a "buy" target.
169+
// If the argument names a hosted provider in the registry (venice,
170+
// openrouter, …) rather than a seller URL, the user wants BYOK setup,
171+
// not an x402 purchase. Redirect to `obol model setup`. The command
172+
// name stays reserved for future credit top-up flows against the same
173+
// remote providers.
234174
arg := strings.TrimSpace(cmd.String("seller"))
235175
if arg == "" {
236176
arg = strings.TrimSpace(cmd.Args().First())
237177
}
238178
if prof, ok := model.ProviderByID(arg); ok && prof.ID != model.ProviderOllama {
239-
return runBuyInferenceProvider(cfg, cmd, prof)
179+
return fmt.Errorf("BYOK provider setup moved — run: obol model setup --provider %s", prof.ID)
240180
}
241181

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

cmd/obol/buy_test.go

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"github.com/ObolNetwork/obol-stack/internal/agentruntime"
1010
"github.com/ObolNetwork/obol-stack/internal/buy"
1111
"github.com/ObolNetwork/obol-stack/internal/config"
12-
"github.com/ObolNetwork/obol-stack/internal/model"
1312
"github.com/ObolNetwork/obol-stack/internal/schemas"
1413
)
1514

@@ -603,35 +602,35 @@ func TestLooksLikeURL(t *testing.T) {
603602
}
604603
}
605604

606-
// TestBuyInference_BYOKFrontDoor pins the BYOK onboarding surface on
607-
// `obol buy inference`: the command exposes --api-key/--free/--model, and
608-
// every registry provider that isn't local Ollama is recognized as a
609-
// hosted-provider argument (the dispatch the Action keys on).
610-
func TestBuyInference_BYOKFrontDoor(t *testing.T) {
605+
// TestBuyInference_NoBYOKArm pins that `obol buy inference` is x402-only:
606+
// the BYOK provider arm (which previously dispatched to setupCloudProvider
607+
// when a provider id was passed) has been removed in favour of
608+
// `obol model setup`. The `--api-key` and `--free` flags went with it.
609+
// The command name stays reserved for future remote-credit top-ups.
610+
func TestBuyInference_NoBYOKArm(t *testing.T) {
611611
cmd := buyInferenceCommand(&config.Config{})
612612

613-
want := map[string]bool{"api-key": false, "free": false, "model": false, "seller": false}
613+
// BYOK-only flags must be gone; the x402 surface keeps --seller.
614+
gone := map[string]bool{"api-key": false, "free": false}
615+
stillHere := map[string]bool{"seller": false}
614616
for _, f := range cmd.Flags {
615617
for _, n := range f.Names() {
616-
if _, ok := want[n]; ok {
617-
want[n] = true
618+
if _, ok := gone[n]; ok {
619+
gone[n] = true
620+
}
621+
if _, ok := stillHere[n]; ok {
622+
stillHere[n] = true
618623
}
619624
}
620625
}
621-
for n, found := range want {
622-
if !found {
623-
t.Errorf("buy inference missing --%s flag", n)
626+
for n, found := range gone {
627+
if found {
628+
t.Errorf("buy inference must not expose --%s (BYOK arm removed; use `obol model setup`)", n)
624629
}
625630
}
626-
627-
// Hosted providers route to BYOK onboarding; ollama does not (local).
628-
for _, id := range []string{"venice", "openrouter", "nvidia", "gmi", "novita", "huggingface"} {
629-
p, ok := model.ProviderByID(id)
630-
if !ok || p.ID == model.ProviderOllama {
631-
t.Errorf("provider %q should be a BYOK buy-inference target", id)
631+
for n, found := range stillHere {
632+
if !found {
633+
t.Errorf("buy inference missing --%s flag", n)
632634
}
633635
}
634-
if p, ok := model.ProviderByID("ollama"); !ok || p.ID != model.ProviderOllama {
635-
t.Errorf("ollama must remain a local (non-buy) provider")
636-
}
637636
}

cmd/obol/model.go

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,17 +97,17 @@ func modelSetupCommand(cfg *config.Config) *cli.Command {
9797
providers, _ := model.GetAvailableProviders(cfg)
9898

9999
options := make([]string, len(providers))
100-
// Default to Venicethe friendliest BYOK on-ramp (cheap,
101-
// no credit card to start, referral link in the JoinURL).
102-
// Falls back to index 0 if Venice is ever removed from the
103-
// registry.
100+
// Default to OpenRouterfree roster auto-seeds when no
101+
// --model is passed, so a new user gets a working remote
102+
// model with zero credit-card friction. Falls back to
103+
// index 0 if OpenRouter is ever removed from the registry.
104104
defaultIdx := 0
105105
for i, p := range providers {
106106
label := fmt.Sprintf("%s (%s)", p.Name, p.ID)
107107
if det, ok := creds[p.ID]; ok {
108108
label += " — detected: " + det.source
109109
}
110-
if p.ID == "venice" {
110+
if p.ID == "openrouter" {
111111
defaultIdx = i
112112
}
113113

@@ -202,11 +202,25 @@ func setupOllama(cfg *config.Config, u *ui.UI, models []string) error {
202202

203203
func setupCloudProvider(cfg *config.Config, u *ui.UI, prof model.ProviderInfo, apiKey string, models []string, free bool) error {
204204
if apiKey == "" {
205+
// Prefer JoinURL (new-user landing, possibly referral-tagged) for
206+
// the browser open; fall back to KeyURL (keys dashboard). Always
207+
// print both as Dim hints so the URLs are visible whether the
208+
// browser opened or not.
209+
onboardURL := prof.JoinURL
210+
if onboardURL == "" {
211+
onboardURL = prof.KeyURL
212+
}
213+
if onboardURL != "" && u.IsTTY() && !u.IsJSON() {
214+
u.Infof("Opening %s to sign up / create an API key …", onboardURL)
215+
if err := openBrowser(onboardURL); err != nil {
216+
u.Dim(fmt.Sprintf("(couldn't open a browser — visit %s)", onboardURL))
217+
}
218+
}
205219
if prof.JoinURL != "" {
206220
u.Dim(fmt.Sprintf("New to %s? Sign up: %s", prof.Name, prof.JoinURL))
207221
}
208-
if prof.SignupURL != "" {
209-
u.Dim(fmt.Sprintf("Get a %s API key: %s", prof.Name, prof.SignupURL))
222+
if prof.KeyURL != "" {
223+
u.Dim(fmt.Sprintf("Get a %s API key: %s", prof.Name, prof.KeyURL))
210224
}
211225

212226
var err error
@@ -220,14 +234,24 @@ func setupCloudProvider(cfg *config.Config, u *ui.UI, prof model.ProviderInfo, a
220234
}
221235
}
222236

237+
// OpenRouter zero-friction default: when no explicit --model and no
238+
// explicit --free, auto-seed the curated free roster. The Dim hint
239+
// surfaces openrouter/auto as the paid upgrade for users with balance.
240+
if !free && len(models) == 0 && prof.ID == "openrouter" && len(prof.Free) > 0 {
241+
free = true
242+
u.Dim("Seeding OpenRouter's curated free models. With account balance, use: obol model setup --provider openrouter --model openrouter/auto")
243+
}
244+
223245
// --free: seed the provider's curated free-tier models (unless the
224-
// operator already named explicit --model values).
246+
// operator already named explicit --model values). The static roster
247+
// is intersected against the live /v1/models response so removed or
248+
// renamed ids drop out without breaking the install.
225249
if free {
226250
if len(prof.Free) == 0 {
227251
return fmt.Errorf("--free is not available for %s (no curated free models); pass --model instead", prof.Name)
228252
}
229253
if len(models) == 0 {
230-
models = append([]string(nil), prof.Free...)
254+
models = filterFreeAgainstLive(u, prof, apiKey)
231255
u.Infof("Seeding %d curated free %s model(s)", len(models), prof.Name)
232256
}
233257
}
@@ -260,6 +284,41 @@ func setupCloudProvider(cfg *config.Config, u *ui.UI, prof model.ProviderInfo, a
260284
return promoteAndSync(cfg, u, models)
261285
}
262286

287+
// filterFreeAgainstLive intersects the registry's curated Free model list
288+
// with the provider's live /v1/models response so removed or renamed ids
289+
// drop out before they hit LiteLLM. On any fetch failure (network down,
290+
// auth error, unparseable body) it falls back to the full static list —
291+
// a slightly stale roster is better than a zero-model install.
292+
func filterFreeAgainstLive(u *ui.UI, prof model.ProviderInfo, apiKey string) []string {
293+
static := append([]string(nil), prof.Free...)
294+
live, err := model.FetchOpenAICompatibleModels(prof.BaseURL, apiKey)
295+
if err != nil || len(live) == 0 {
296+
return static
297+
}
298+
liveSet := make(map[string]bool, len(live))
299+
for _, id := range live {
300+
liveSet[id] = true
301+
}
302+
filtered := make([]string, 0, len(static))
303+
dropped := make([]string, 0)
304+
for _, id := range static {
305+
if liveSet[id] {
306+
filtered = append(filtered, id)
307+
} else {
308+
dropped = append(dropped, id)
309+
}
310+
}
311+
if len(filtered) == 0 {
312+
// All curated ids missing from live list — likely a major rotation;
313+
// fall back rather than seed nothing.
314+
return static
315+
}
316+
if len(dropped) > 0 {
317+
u.Dim(fmt.Sprintf("(%d curated free model(s) no longer listed by %s: %s)", len(dropped), prof.Name, strings.Join(dropped, ", ")))
318+
}
319+
return filtered
320+
}
321+
263322
// resolveSetupModel picks a model when the operator passed none. A registry
264323
// Default wins (overridable in a TTY). With no static default — BYOK
265324
// aggregators whose catalog rotates — it lists the live /v1/models endpoint:
@@ -290,7 +349,7 @@ func resolveSetupModel(u *ui.UI, prof model.ProviderInfo, apiKey string) (string
290349
if u.IsTTY() && !u.IsJSON() {
291350
return u.Input(fmt.Sprintf("Model id for %s", prof.Name), "")
292351
}
293-
return "", fmt.Errorf("could not resolve a model for %s: pass --model <id> (keys/models at %s)", prof.Name, prof.SignupURL)
352+
return "", fmt.Errorf("could not resolve a model for %s: pass --model <id> (keys/models at %s)", prof.Name, prof.KeyURL)
294353
}
295354

296355
if u.IsTTY() && !u.IsJSON() {

0 commit comments

Comments
 (0)