Skip to content
Closed
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
30 changes: 22 additions & 8 deletions cmd/obol/sell.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,9 @@ Examples:
Usage: "Agent name for ERC-8004 registration (defaults to the offer name)",
},
&cli.StringFlag{
Name: "register-description",
Usage: "Agent description for ERC-8004 registration",
Name: "description",
Aliases: []string{"register-description"},
Usage: "Human-readable description of the service. Surfaced on the 402 payment page, in the storefront catalog, and (when registration is enabled) on the ERC-8004 registration document.",
},
&cli.StringFlag{
Name: "register-image",
Expand Down Expand Up @@ -296,6 +297,18 @@ Examples:
if modelFlag == "" {
return fmt.Errorf("--model is required (or run interactively to auto-detect)")
}
// LiteLLM's `paid/*` wildcard route doesn't match model names
// containing `/` — buyers signing against this seller would see
// requests fall through to the buyer sidecar with a 404 (see
// CLAUDE.md and the obol-agent's recent buy report). Reject
// up-front and suggest a `--` separator that survives the route.
if strings.Contains(modelFlag, "/") {
return fmt.Errorf(
"--model %q contains '/', which breaks LiteLLM's `paid/*` wildcard on the buyer side; "+
"use `--` (or another non-slash separator) — e.g. `%s` instead of `%s`",
modelFlag, strings.ReplaceAll(modelFlag, "/", "--"), modelFlag,
)
}

teeType := cmd.String("tee")
modelHash := cmd.String("model-hash")
Expand Down Expand Up @@ -345,7 +358,7 @@ Examples:
persistedRegistration, _, regErr := buildSellRegistrationConfig(name, sellRegistrationInput{
NoRegister: cmd.Bool("no-register"),
Name: cmd.String("register-name"),
Description: cmd.String("register-description"),
Description: cmd.String("description"),
Image: cmd.String("register-image"),
Skills: cmd.StringSlice("register-skills"),
Domains: cmd.StringSlice("register-domains"),
Expand Down Expand Up @@ -601,8 +614,9 @@ Examples:
Usage: "Agent name for ERC-8004 registration",
},
&cli.StringFlag{
Name: "register-description",
Usage: "Agent description for ERC-8004 registration",
Name: "description",
Aliases: []string{"register-description"},
Usage: "Human-readable description of the service. Surfaced on the 402 payment page, in the storefront catalog, and (when registration is enabled) on the ERC-8004 registration document.",
},
&cli.StringFlag{
Name: "register-image",
Expand Down Expand Up @@ -799,7 +813,7 @@ Examples:
NoRegister: cmd.Bool("no-register"),
Register: cmd.Bool("register"),
Name: cmd.String("register-name"),
Description: cmd.String("register-description"),
Description: cmd.String("description"),
Image: cmd.String("register-image"),
Skills: cmd.StringSlice("register-skills"),
Domains: cmd.StringSlice("register-domains"),
Expand Down Expand Up @@ -1362,7 +1376,7 @@ var demoTypes = map[string]demoSpec{
"quant": {
Type: "quant",
Price: "10",
Description: "Agent-backed chain analyst (Agent CRD + ServiceOffer of type=agent)",
Description: "A simple example agent that can analyse Ethereum and Base for you",
NeedsERPC: true,
DefaultChain: "ethereum",
DefaultToken: "OBOL",
Expand Down Expand Up @@ -3861,7 +3875,7 @@ func buildResumeGatewayArgs(d *inference.Deployment) []string {
args = append(args, "--register-name", v)
}
if v, _ := d.Registration["description"].(string); v != "" {
args = append(args, "--register-description", v)
args = append(args, "--description", v)
}
if v, _ := d.Registration["image"].(string); v != "" {
args = append(args, "--register-image", v)
Expand Down
17 changes: 14 additions & 3 deletions cmd/obol/sell_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ Examples:
Usage: "Agent name for ERC-8004 registration (defaults to the offer name)",
},
&cli.StringFlag{
Name: "register-description",
Usage: "Agent description for ERC-8004 registration (defaults to the agent's objective)",
Name: "description",
Aliases: []string{"register-description"},
Usage: "Human-readable description of the service. Surfaced on the 402 payment page, in the storefront catalog, and (when registration is enabled) on the ERC-8004 registration document. Defaults to the agent's objective.",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
Expand Down Expand Up @@ -173,7 +174,7 @@ Examples:
if regName == "" {
regName = name
}
regDesc := strings.TrimSpace(cmd.String("register-description"))
regDesc := strings.TrimSpace(cmd.String("description"))
if regDesc == "" {
regDesc = agent.Objective
}
Expand Down Expand Up @@ -418,6 +419,16 @@ func runAgentBackedDemo(
"metadata": map[string]any{
"name": name,
"namespace": offerNs,
// Agent-backed demos can't live in the legacy "demo"
// namespace today (the controller's confused-deputy guard at
// agent_resolver.go forces spec.agent.ref.namespace ==
// offer.namespace), so the catalog renderer can't infer
// "demo" from offer.namespace alone. The obol.org/demo
// label is the explicit signal — keep it set here so quant
// and friends show up under "Demo services" on the
// storefront. Drop this once we relax the cross-namespace
// guard (see plans/openapi-402-followups.md).
"labels": map[string]any{"obol.org/demo": "true"},
},
"spec": specMap,
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/obol/sell_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1396,7 +1396,7 @@ func TestBuildResumeGatewayArgs(t *testing.T) {
"--per-mtok", "23",
"--facilitator", "https://x402.gcp.obol.tech",
"--register-name", "Qwen3.6-27B AEON Ultimate",
"--register-description", "Uncensored Qwen3.6-27B abliteration",
"--description", "Uncensored Qwen3.6-27B abliteration",
"--register-skills", "llm/inference",
"--register-skills", "llm/uncensored",
"--register-domains", "inference.v1337.org",
Expand All @@ -1419,7 +1419,7 @@ func TestBuildResumeGatewayArgs(t *testing.T) {
},
wantNoSub: []string{
"--register-name",
"--register-description",
"--description",
},
},
{
Expand Down
12 changes: 12 additions & 0 deletions internal/embed/skills/buy-x402/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,18 @@ This is one tx, ~46k gas, valid forever (unless the user later revokes). EIP-300
storefront publishes machine-readable metadata at
`<base>/api/services.json` with full asset, EIP-712 signing domain,
transfer method, and atomic-unit price for every offered service.
- **`pay` timeout defaults to ~100 s.** This is the Cloudflare free-tier
tunnel cap — longer requests get killed by the edge before our client
ever sees a response. Reasoning models, long generations, or large
batches need `--timeout <seconds>` set explicitly, and the seller's own
upstream/edge limit still applies.
- **Avoid `/` in remote model identifiers.** LiteLLM's `paid/*` wildcard
route only matches a single segment; a remote `vendor/model` would
resolve to `paid/vendor/model` and miss the wildcard, so the request
falls through to the buyer sidecar and 404s. Sellers should use a non-
slash separator (e.g. `vendor--model`); buyers signing against a
legacy slashed name need the controller to insert an explicit LiteLLM
entry for the alias (`addLiteLLMModelEntry`).

## When to Use

Expand Down
26 changes: 22 additions & 4 deletions internal/embed/skills/buy-x402/scripts/buy.py
Original file line number Diff line number Diff line change
Expand Up @@ -1698,7 +1698,7 @@ def cmd_balance(chain=None):
# Pay (single-shot HTTP/x402 purchase)
# ---------------------------------------------------------------------------

def cmd_pay(url, method="GET", data=None, kind="http", network=None):
def cmd_pay(url, method="GET", data=None, kind="http", network=None, timeout=None):
"""Single-shot paid HTTP request: probe → pre-sign one auth → send with X-PAYMENT.

Stateless. Does not create a PurchaseRequest, does not touch the buyer
Expand All @@ -1708,7 +1708,17 @@ def cmd_pay(url, method="GET", data=None, kind="http", network=None):

`network` is an optional safety guard: when set, the seller's advertised
chain must match it or `pay` aborts before signing.

`timeout` is the seconds to wait for the seller's response. Defaults to
~100s (Cloudflare's free-tier tunnel cap — longer requests are killed by
the edge before our client sees a response anyway). Override for slower
inference (reasoning models, large batches) up to the seller's own
upstream/edge limit.
"""
if timeout is None or float(timeout) <= 0:
timeout = 100.0
else:
timeout = float(timeout)
method = (method or "GET").upper()

print(f"Probing {url} ...")
Expand Down Expand Up @@ -1807,7 +1817,7 @@ def cmd_pay(url, method="GET", data=None, kind="http", network=None):
print(f"Sending paid {method} {target_url} ...")
req = urllib.request.Request(target_url, data=request_data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
with urllib.request.urlopen(req, timeout=timeout) as resp:
body = resp.read().decode(errors="replace")
print(f"HTTP {resp.status}")
settle = resp.headers.get("X-PAYMENT-RESPONSE")
Expand Down Expand Up @@ -1949,7 +1959,7 @@ def usage():
print("Commands:")
print(" probe <endpoint-url> [--model <id>] [--type http|inference] [--method GET|POST]")
print(" Probe x402 pricing (default --type inference)")
print(" pay <url> [--type http|inference] [--method GET|POST] [--data '<body>'] [--network <name>]")
print(" pay <url> [--type http|inference] [--method GET|POST] [--data '<body>'] [--network <name>] [--timeout <seconds>]")
print(" Single-shot paid request (sign 1 auth, attach X-PAYMENT)")
print(" --network is a guard: aborts if seller is on a different chain")
print(" buy <name> --endpoint <url> --model <id> Pre-sign + configure paid/<model>")
Expand Down Expand Up @@ -1988,18 +1998,26 @@ def usage():
elif cmd == "pay":
positional, opts = parse_flags(rest)
if not positional:
print("Usage: pay <url> [--type http|inference] [--method GET|POST] [--data '<body>'] [--network <name>]", file=sys.stderr)
print("Usage: pay <url> [--type http|inference] [--method GET|POST] [--data '<body>'] [--network <name>] [--timeout <seconds>]", file=sys.stderr)
sys.exit(1)
kind = opts.get("type", "http")
if kind not in ("http", "inference"):
print(f"Error: --type must be 'http' or 'inference', got '{kind}'", file=sys.stderr)
sys.exit(1)
timeout = opts.get("timeout")
if timeout is not None:
try:
timeout = float(timeout)
except ValueError:
print(f"Error: --timeout must be a number of seconds, got '{timeout}'", file=sys.stderr)
sys.exit(1)
cmd_pay(
positional[0],
method=opts.get("method", "GET"),
data=opts.get("data"),
kind=kind,
network=opts.get("network"),
timeout=timeout,
)

elif cmd == "buy":
Expand Down
8 changes: 8 additions & 0 deletions internal/schemas/service-catalog.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@
"type": "string",
"minLength": 1
},
"skills": {
"type": "array",
"items": {
"type": "string",
"minLength": 1
},
"description": "OASF skills or buy-x402 skills the agent advertises. For type=agent offers this mirrors the resolved Agent.spec.skills allow-list; for non-agent offers it mirrors spec.registration.skills."
},
"isDemo": {
"type": "boolean"
},
Expand Down
8 changes: 7 additions & 1 deletion internal/schemas/service_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ type ServiceCatalogEntry struct {
ChainID int64 `json:"chainId,omitempty"`
Asset *ServiceCatalogAsset `json:"asset,omitempty"`
Description string `json:"description"`
IsDemo bool `json:"isDemo"`
// Skills are the OASF / buy-x402 skill names this offer advertises.
// For type=agent offers it mirrors AgentResolution.Skills (the
// resolved allow-list from the linked Agent CR); for non-agent
// offers it mirrors spec.registration.skills. Surfaced as pills on
// the storefront ServiceCard.
Skills []string `json:"skills,omitempty"`
IsDemo bool `json:"isDemo"`

// RegistrationPending is true when the offer is operationally ready
// (route published, payment gate active, upstream healthy) but its
Expand Down
32 changes: 31 additions & 1 deletion internal/serviceoffercontroller/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,24 @@ func offerOperationallyReady(offer *monetizeapi.ServiceOffer) bool {
// ready but has its on-chain ERC-8004 registration still pending. Used to
// flip ServiceCatalogEntry.RegistrationPending so storefront UIs can show
// a "registration pending" badge alongside the usable offer.
// isDemoOffer reports whether an offer should be rendered under the
// storefront's "Demo services" group. The legacy demo path puts offers
// directly in the "demo" namespace, but the agent-backed demo path
// (`obol sell demo quant`) lands the offer in agent-<name> because the
// controller's confused-deputy guard requires the ServiceOffer and the
// referenced Agent CR to share a namespace. To keep both paths grouping
// together on the storefront, the CLI sets obol.org/demo=true on
// agent-backed demos and we honour either signal.
func isDemoOffer(offer *monetizeapi.ServiceOffer) bool {
if offer == nil {
return false
}
if offer.Namespace == "demo" {
return true
}
return offer.Labels["obol.org/demo"] == "true"
}

func offerAwaitingRegistration(offer *monetizeapi.ServiceOffer) bool {
if offer == nil {
return false
Expand Down Expand Up @@ -978,6 +996,17 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string)
drainEndsAt = offer.DrainEndsAt().UTC().Format(time.RFC3339)
}

// Skills source matches the 402 renderer: for type=agent the
// resolved Agent allow-list wins (controller-populated), with a
// fallback to spec.registration.skills for non-agent offers
// that still want to surface skill tags on discovery.
var skills []string
if offer.IsAgent() && offer.Status.AgentResolution != nil && len(offer.Status.AgentResolution.Skills) > 0 {
skills = append([]string(nil), offer.Status.AgentResolution.Skills...)
} else if len(offer.Spec.Registration.Skills) > 0 {
skills = append([]string(nil), offer.Spec.Registration.Skills...)
}

svc := schemas.ServiceCatalogEntry{
Name: offer.Name,
Namespace: offer.Namespace,
Expand All @@ -988,7 +1017,8 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string)
PayTo: offer.Spec.Payment.PayTo,
Network: offer.Spec.Payment.Network,
Description: desc,
IsDemo: offer.Namespace == "demo",
Skills: skills,
IsDemo: isDemoOffer(offer),
RegistrationPending: offerAwaitingRegistration(offer),
DrainEndsAt: drainEndsAt,
}
Expand Down
12 changes: 12 additions & 0 deletions internal/x402/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,18 @@ type RouteRule struct {
// AgentRuntime is the runtime backing the agent ("hermes", etc).
// Surfaced as `accepts[].extra.agentRuntime`.
AgentRuntime string `yaml:"agentRuntime,omitempty"`

// OfferType records the originating ServiceOffer.spec.type
// (inference, http, agent, fine-tuning). The HTML 402 renderer uses
// this to pick type-appropriate copy and Buy CTAs.
OfferType string `yaml:"offerType,omitempty"`

// Model is the upstream model identifier for inference and agent
// offers. For type=inference it mirrors ServiceOffer.spec.model.name;
// for type=agent it mirrors AgentResolution.Model. Surfaced in the
// 402 page's primary Buy card so users can copy a fully-formed
// `obol buy inference --model ...` command.
Model string `yaml:"model,omitempty"`
}

// LoadConfig reads and parses a pricing configuration YAML file.
Expand Down
Loading
Loading