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
4 changes: 2 additions & 2 deletions internal/embed/skills/buy-x402/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ metadata: { "openclaw": { "emoji": "\ud83d\uded2", "requires": { "bins": ["pytho
Purchase access to remote x402-gated services. There are two flows, picked by usage shape:

- **`pay <url>`** — single-shot. Probe the URL, sign **one** payment authorization, attach `X-PAYMENT`, send the request, return the response. Stateless. Use for `type:http` services and any one-off purchase. Max loss = price of one request. Settlement normally lands only after the request succeeds — but a facilitator can submit the settle tx on-chain and *then* fail the request. When that happens the failure report prints `⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN` with the tx hash: verify with `balance --chain <X>` before retrying (mechanism: docs/observability.md, "Verify settlement against the chain"). Applies to `pay-agent` too.
- **`pay-agent <url> --model <id>`** — single-shot paid **streaming** agent call. Same payment shape as `pay` (one auth, X-PAYMENT, max-loss = price), but POSTs to `<url>/v1/chat/completions` with `stream: true` and forwards every SSE event verbatim to stdout as it arrives. Use this for `type:agent` ServiceOffers when the calling agent wants to consume the response *itself* (memory, tool-call traces, partial results) instead of routing it through LiteLLM as a paid alias. Default HTTP read timeout is **1 hour** — agent calls can legitimately run for many minutes; override with `--timeout <seconds>`.
- **`pay-agent <url>`** — single-shot paid **streaming** agent call. Same payment shape as `pay` (one auth, X-PAYMENT, max-loss = price), but POSTs to `<url>/v1/chat/completions` with `stream: true` and forwards every SSE event verbatim to stdout as it arrives. No `--model`: a `type:agent` offer runs its own model (the request `model` field is ignored), so you only send a prompt. Use this for `type:agent` ServiceOffers when the calling agent wants to consume the response *itself* (memory, tool-call traces, partial results) instead of routing it through LiteLLM as a paid alias. Default HTTP read timeout is **1 hour** — agent calls can legitimately run for many minutes; override with `--timeout <seconds>`.
- **`buy <name>`** — pre-authorize a budget. Sign **N** authorizations up front (the buyer pays nothing yet), declare them in a `PurchaseRequest` CR, let the `x402-buyer` sidecar redeem them transparently as the agent calls the model through LiteLLM at `paid/<remote-model>`. Use for long-running paid inference. Max loss = N × price (only as vouchers are spent); runtime path holds zero signer access.
- **`buy <name> --model <id> --set-default`** — same as `buy` above, then adopt `paid/<remote-model>` as the agent's **own primary model**, in-pod, by itself: an atomic `hermes config set model.default` that Hermes re-reads per request (effective next chat turn, **no restart**, no host-side `obol model prefer`/`obol model sync`). Refuses if the model isn't selectable in LiteLLM. Pair with `--auto-refill` so the primary model doesn't brick when the pre-authorized budget runs out.

Expand Down Expand Up @@ -187,7 +187,7 @@ python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py maint
|---------|-------------|
| `probe <url> [--model <id>] [--type http\|inference\|agent] [--method GET\|POST]` | Send request without payment, parse 402 response for pricing |
| `pay <url> [--type http\|inference] [--method GET\|POST] [--data <body>]` | Single-shot paid request: sign 1 auth, attach X-PAYMENT, send |
| `pay-agent <url> --model <id> [--message <text> \| --data <json>] [--timeout <s>]` | Single-shot paid streaming agent call: SSE events flush to stdout as they arrive (default timeout 1h) |
| `pay-agent <url> [--message <text> \| --data <json>] [--timeout <s>]` | Single-shot paid streaming agent call (no `--model` — the agent runs its own): SSE events flush to stdout as they arrive (default timeout 1h) |
| `buy <name> --endpoint <url> --model <id> [--budget N] [--count N]` | Pre-sign auths, create/update `PurchaseRequest`, expose `paid/<model>` |
| `buy <name> --endpoint <url> --model <id> --set-default [--auto-refill]` | As above, then set `paid/<model>` as the agent's own primary model in-pod (no restart, no host CLI) |
| `process <name> \| --all` | Reconcile `autoRefill` policies against live `x402-buyer` status |
Expand Down
20 changes: 8 additions & 12 deletions internal/embed/skills/buy-x402/scripts/buy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2372,7 +2372,7 @@ def cmd_pay(url, method="GET", data=None, kind="http", network=None, timeout=Non
sys.exit(1)


def cmd_pay_agent(url, messages=None, model_id=None, network=None, timeout=None, body=None, token=None, payment_option=None):
def cmd_pay_agent(url, messages=None, network=None, timeout=None, body=None, token=None, payment_option=None):
"""Single-shot paid streaming agent call: probe -> sign one auth -> SSE-stream.

Sibling of `cmd_pay` for `type=agent` ServiceOffers. Differences from
Expand Down Expand Up @@ -2414,27 +2414,24 @@ def cmd_pay_agent(url, messages=None, model_id=None, network=None, timeout=None,
# Force streaming on. cmd_pay handles non-streaming; cmd_pay_agent
# exists precisely to stream.
parsed_body["stream"] = True
if model_id and not parsed_body.get("model"):
parsed_body["model"] = model_id
else:
if not messages:
print(
"Error: --message (or --data <json>) is required for `pay-agent`.\n"
"Example: pay-agent <url> --model qwen3.5:9b --message 'summarize the docs'",
"Example: pay-agent <url> --message 'summarize the docs'",
file=sys.stderr,
)
sys.exit(1)
if not model_id:
print("Error: --model is required when using --message.", file=sys.stderr)
sys.exit(1)
# type=agent ServiceOffers run their own model — there is nothing to
# select and the agent ignores any `model` field — so pay-agent sends
# only the prompt.
parsed_body = {
"model": model_id,
"messages": [{"role": "user", "content": messages}],
"stream": True,
}

print(f"Probing {url} ...")
pricing = _probe_endpoint(url, model_id=model_id or "test", kind="inference")
pricing = _probe_endpoint(url, model_id="probe", kind="inference")
if not pricing:
print("Failed to get x402 pricing.", file=sys.stderr)
sys.exit(1)
Expand Down Expand Up @@ -2708,7 +2705,7 @@ def usage():
print(" Single-shot paid request (sign 1 auth, attach X-PAYMENT)")
print(" Multi-currency offers: pick which asset/price to pay with")
print(" --token/--network/--payment-option (probe to see options)")
print(" pay-agent <url> --model <id> [--message '<text>' | --data '<json>'] [--timeout <seconds>]")
print(" pay-agent <url> [--message '<text>' | --data '<json>'] [--timeout <seconds>]")
print(" [--token <SYMBOL>] [--network <name>] [--payment-option <N>]")
print(" Single-shot paid streaming agent call (POST /v1/chat/completions,")
print(" stream: true). Each SSE event flushes to stdout so a calling")
Expand Down Expand Up @@ -2788,7 +2785,7 @@ def usage():
positional, opts = parse_flags(rest)
if not positional:
print(
"Usage: pay-agent <url> --model <id> [--message '<text>' | --data '<json>'] "
"Usage: pay-agent <url> [--message '<text>' | --data '<json>'] "
"[--network <name>] [--timeout <seconds>]",
file=sys.stderr,
)
Expand All @@ -2805,7 +2802,6 @@ def usage():
cmd_pay_agent(
positional[0],
messages=opts.get("message"),
model_id=opts.get("model"),
network=opts.get("network"),
timeout=timeout,
body=opts.get("data"),
Expand Down
30 changes: 22 additions & 8 deletions internal/serviceoffercontroller/agent_render.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,25 @@ func agentManifests(agent *monetizeapi.Agent, litellmKey, apiKey string) ([]*uns
//
// Sub-agent constraints: every Agent CR is a sub-agent-for-sale (the
// master is deployed via `obol agent init`, not via ServiceOffer), so the
// terminal/agent caps below apply unconditionally. The Cloudflare free
// tunnel cuts off requests at 100s, so lifetime_seconds is bounded under
// that. terminal.timeout must stay <= lifetime_seconds so no single
// operation can outlive the session. max_turns and reasoning_effort cap
// chattiness, and disabled_toolsets drops Hermes tool families that aren't
// useful in a paid-service context (memory persistence, web search).
// terminal/agent caps below apply unconditionally. Sold agents run behind a
// named Cloudflare tunnel (no ~100s quick-tunnel idle cut), and a single paid
// data call can legitimately be slow (an x402 payment round-trip plus a
// first-party data query), so terminal.timeout/lifetime_seconds carry real
// headroom rather than the old 80s/90s that timed out heavier queries.
// terminal.timeout must stay <= lifetime_seconds so no single operation can
// outlive the session. max_turns and reasoning_effort cap chattiness, and
// disabled_toolsets drops Hermes tool families that aren't useful in a
// paid-service context (memory persistence, web search).
//
// code_execution (the `execute_code` tool) is disabled too: it runs arbitrary
// in-process Python whose subprocess/file calls bypass the terminal
// DANGEROUS_PATTERNS gate, so Hermes requires a per-script approval that no
// human can grant during an unattended paid turn — the tool just fails closed
// and small models loop on it (observed: gemma4 retrying execute_code until
// the turn dies). Skills that shell out (e.g. buy-x402, the hyperliquid
// data skill) run their `python3 .../foo.py` via the `terminal` tool instead,
// where a benign script auto-approves and genuinely dangerous commands stay
// gated — granular, not a blanket --yolo bypass.
func renderHermesConfig(model, litellmKey string) string {
return fmt.Sprintf(`model:
default: %q
Expand All @@ -109,15 +122,16 @@ func renderHermesConfig(model, litellmKey string) string {
terminal:
backend: local
cwd: /data/.hermes/workspace
timeout: 80
lifetime_seconds: 90
timeout: 170
lifetime_seconds: 180
docker_mount_cwd_to_workspace: false
agent:
max_turns: 30
reasoning_effort: low
disabled_toolsets:
- memory
- web
- code_execution
skills:
external_dirs:
- /data/.hermes/obol-skills
Expand Down
7 changes: 5 additions & 2 deletions internal/serviceoffercontroller/agent_render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,13 +365,16 @@ func TestRenderHermesConfig_HasModelAndSkillsDir(t *testing.T) {
func TestRenderHermesConfig_SubAgentConstraints(t *testing.T) {
cfg := renderHermesConfig("qwen3.5:9b", "lit-key")
for _, must := range []string{
`timeout: 80`,
`lifetime_seconds: 90`,
`timeout: 170`,
`lifetime_seconds: 180`,
`max_turns: 30`,
`reasoning_effort: low`,
`disabled_toolsets:`,
`- memory`,
`- web`,
// execute_code is blocked in unattended gateway turns (needs a human
// approval no one can grant); skills must shell out via `terminal`.
`- code_execution`,
} {
if !strings.Contains(cfg, must) {
t.Errorf("hermes config missing sub-agent constraint %q\n---\n%s", must, cfg)
Expand Down
24 changes: 21 additions & 3 deletions internal/serviceoffercontroller/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,13 @@ func buildSkillCatalogConfigMap(content, servicesJSON, openAPIJSON, apiDocsHTML
"services.json": servicesJSON,
"openapi.json": openAPIJSON,
"api.html": apiDocsHTML,
"httpd.conf": ".md:text/markdown\n.json:application/json\n.html:text/html\n",
// charset=utf-8 on the text types so UTF-8 content (em dashes
// in the catalog, accented operator descriptions, …) renders
// correctly instead of mojibake — busybox httpd otherwise sends
// a bare text/* type and clients fall back to Latin-1/CP1252.
// JSON is always UTF-8 by spec (RFC 8259), so it carries no
// charset param.
"httpd.conf": ".md:text/markdown; charset=utf-8\n.json:application/json\n.html:text/html; charset=utf-8\n",
},
},
}
Expand Down Expand Up @@ -899,6 +905,18 @@ func offerPublishedForRegistration(offer *monetizeapi.ServiceOffer) bool {
isConditionTrue(offer.Status, "RoutePublished")
}

// catalogModelName returns the model id to surface in the catalog, or "" to
// omit it. Agent offers run their own model and ignore the request `model`
// field, so the id is an internal detail and is never surfaced — mirrors the
// 402 page / extra / bazaar model-strip in internal/x402. Inference (and other
// model-bearing) offers keep their id, since there the buyer selects it.
func catalogModelName(offer *monetizeapi.ServiceOffer) string {
if offer.IsAgent() {
return ""
}
return offer.Spec.Model.Name
}

func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL string) string {
baseURL = strings.TrimRight(baseURL, "/")

Expand Down Expand Up @@ -955,7 +973,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin
lines = append(lines, "| Service | Type | Model | Pay with | Status | Endpoint |")
lines = append(lines, "|---------|------|-------|----------|--------|----------|")
for _, offer := range ready {
modelName := offer.Spec.Model.Name
modelName := catalogModelName(offer)
if modelName == "" {
modelName = "—"
}
Expand All @@ -977,7 +995,7 @@ func buildSkillCatalogMarkdown(offers []*monetizeapi.ServiceOffer, baseURL strin
}
lines = append(lines, "", "## Service Details", "")
for _, offer := range ready {
modelName := offer.Spec.Model.Name
modelName := catalogModelName(offer)
endpoint := baseURL + offer.EffectivePath()
lines = append(lines, fmt.Sprintf("### %s", offer.Name))
lines = append(lines, fmt.Sprintf("- **Endpoint**: `%s`", endpoint))
Expand Down
5 changes: 5 additions & 0 deletions internal/serviceoffercontroller/render_builders_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,11 @@ func TestBuildSkillCatalogConfigMap(t *testing.T) {
if conf, _ := data["httpd.conf"].(string); !strings.Contains(conf, ".md:text/markdown") || !strings.Contains(conf, ".json:application/json") || !strings.Contains(conf, ".html:text/html") {
t.Errorf("httpd.conf missing required mime mappings: %q", conf)
}
// Text types must declare charset=utf-8 or UTF-8 content (em dashes,
// accented descriptions) renders as Latin-1 mojibake.
if conf, _ := data["httpd.conf"].(string); !strings.Contains(conf, ".md:text/markdown; charset=utf-8") || !strings.Contains(conf, ".html:text/html; charset=utf-8") {
t.Errorf("httpd.conf text types missing charset=utf-8: %q", conf)
}
// Managed-by label so the controller owns cleanup on uninstall.
lbls, _ := cm.Object["metadata"].(map[string]any)["labels"].(map[string]any)
if lbls["obol.org/managed-by"] != "serviceoffer-controller" {
Expand Down
49 changes: 49 additions & 0 deletions internal/serviceoffercontroller/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,55 @@ func TestBuildSkillCatalogMarkdown_DrainAdditiveDetail(t *testing.T) {
}
}

// TestBuildSkillCatalogMarkdown_AgentModelStripped locks in that agent offers
// never surface their underlying model in the catalog (the agent runs its own
// model and ignores the request `model` field — it's an internal detail), while
// inference offers keep it (there the buyer selects the model). Mirrors the
// 402 page / extra / bazaar model-strip in internal/x402.
func TestBuildSkillCatalogMarkdown_AgentModelStripped(t *testing.T) {
readyCond := []monetizeapi.Condition{{Type: "Ready", Status: "True"}}
agentOffer := &monetizeapi.ServiceOffer{
ObjectMeta: metav1.ObjectMeta{Name: "analyst", Namespace: "agent-analyst"},
Spec: monetizeapi.ServiceOfferSpec{
Type: "agent",
Model: monetizeapi.ServiceOfferModel{Name: "gemma4-aeon-uncensored"},
Payment: monetizeapi.ServiceOfferPayment{
Network: "base-sepolia",
PayTo: "0x1111111111111111111111111111111111111111",
Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.01"},
},
},
Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond},
}
inferenceOffer := &monetizeapi.ServiceOffer{
ObjectMeta: metav1.ObjectMeta{Name: "raw-llm", Namespace: "llm"},
Spec: monetizeapi.ServiceOfferSpec{
Type: "inference",
Model: monetizeapi.ServiceOfferModel{Name: "qwen36-deep"},
Payment: monetizeapi.ServiceOfferPayment{
Network: "base-sepolia",
PayTo: "0x2222222222222222222222222222222222222222",
Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"},
},
},
Status: monetizeapi.ServiceOfferStatus{Conditions: readyCond},
}

content := buildSkillCatalogMarkdown(
[]*monetizeapi.ServiceOffer{agentOffer, inferenceOffer},
"https://example.com",
)

// Agent: model never appears (table column is "—", no **Model** detail).
if strings.Contains(content, "gemma4-aeon-uncensored") {
t.Errorf("agent offer leaked its internal model into the catalog:\n%s", content)
}
// Inference: model is buyer-facing and must stay (table + detail bullet).
if !strings.Contains(content, "- **Model**: qwen36-deep") {
t.Errorf("inference offer dropped its (buyer-selectable) model bullet:\n%s", content)
}
}

func TestBuildSkillCatalogHTTPRoute(t *testing.T) {
route := buildSkillCatalogHTTPRoute()
if route.GetName() != skillCatalogRouteName {
Expand Down
15 changes: 9 additions & 6 deletions internal/x402/agent_extras_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func TestMergeAgentExtras_Noop_NonAgentRule(t *testing.T) {
}
}

func TestMergeAgentExtras_AddsAllAgentFields(t *testing.T) {
func TestMergeAgentExtras_AddsAgentFieldsButNotModel(t *testing.T) {
req := x402types.PaymentRequirements{Extra: map[string]any{}}
rule := &RouteRule{
AgentModel: "qwen3.5:9b",
Expand All @@ -34,8 +34,8 @@ func TestMergeAgentExtras_AddsAllAgentFields(t *testing.T) {

mergeAgentExtras(&req, rule)

if got := req.Extra["agentModel"]; got != "qwen3.5:9b" {
t.Errorf("agentModel = %v, want qwen3.5:9b", got)
if _, ok := req.Extra["agentModel"]; ok {
t.Error("agentModel must not be surfaced — the underlying model is an internal detail, not buyer-facing")
}
if got := req.Extra["agentRuntime"]; got != "hermes" {
t.Errorf("agentRuntime = %v", got)
Expand All @@ -55,14 +55,17 @@ func TestMergeAgentExtras_InitialisesNilExtra(t *testing.T) {
// mergeAgentExtras must still cope with a nil map for callers that
// build PaymentRequirements directly (e.g. tests).
req := x402types.PaymentRequirements{}
rule := &RouteRule{AgentModel: "qwen3.5:9b"}
rule := &RouteRule{AgentRuntime: "hermes"}

mergeAgentExtras(&req, rule)

if req.Extra == nil {
t.Fatal("Extra not initialised")
}
if req.Extra["agentModel"] != "qwen3.5:9b" {
t.Errorf("agentModel missing: %+v", req.Extra)
if _, ok := req.Extra["agentModel"]; ok {
t.Error("agentModel must not be surfaced")
}
if req.Extra["agentRuntime"] != "hermes" {
t.Errorf("agentRuntime missing: %+v", req.Extra)
}
}
9 changes: 8 additions & 1 deletion internal/x402/bazaar.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,15 @@ func WithBazaar(extensions map[string]any, offerType, model string) map[string]a
// gets the generic operator-defined JSON shape.
func BuildBazaarExtension(offerType, model string) map[string]any {
switch normalizeOfferType(offerType) {
case "inference", "agent":
case "inference":
// The buyer selects the model (paid/<remote-model>), so the real id
// is buyer-facing and correct to advertise in the example.
return bazaarChatCompletions(model)
case "agent":
// An agent runs its own model and ignores the request `model` field,
// so the model id is an internal detail, not buyer-facing. Seed the
// chat example with the neutral placeholder rather than the real id.
return bazaarChatCompletions("")
default:
return bazaarGenericJSON()
}
Expand Down
2 changes: 1 addition & 1 deletion internal/x402/bazaar_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestBuildBazaarExtension(t *testing.T) {
wantModel string
}{
{"inference", "llama-3-70b", "llama-3-70b"},
{"agent", "qwen3.5:9b", "qwen3.5:9b"},
{"agent", "qwen3.5:9b", "your-model-id"}, // agent model is internal — placeholder, never the real id
{"inference", "", "your-model-id"},
{"http", "", ""},
{"", "", ""}, // static config routes fall back to the generic shape
Expand Down
Loading
Loading