diff --git a/internal/embed/skills/buy-x402/SKILL.md b/internal/embed/skills/buy-x402/SKILL.md index a3e02a5d..6b7bed90 100644 --- a/internal/embed/skills/buy-x402/SKILL.md +++ b/internal/embed/skills/buy-x402/SKILL.md @@ -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 `** — 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 ` before retrying (mechanism: docs/observability.md, "Verify settlement against the chain"). Applies to `pay-agent` too. -- **`pay-agent --model `** — single-shot paid **streaming** agent call. Same payment shape as `pay` (one auth, X-PAYMENT, max-loss = price), but POSTs to `/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 `. +- **`pay-agent `** — single-shot paid **streaming** agent call. Same payment shape as `pay` (one auth, X-PAYMENT, max-loss = price), but POSTs to `/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 `. - **`buy `** — 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/`. Use for long-running paid inference. Max loss = N × price (only as vouchers are spent); runtime path holds zero signer access. - **`buy --model --set-default`** — same as `buy` above, then adopt `paid/` 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. @@ -187,7 +187,7 @@ python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py maint |---------|-------------| | `probe [--model ] [--type http\|inference\|agent] [--method GET\|POST]` | Send request without payment, parse 402 response for pricing | | `pay [--type http\|inference] [--method GET\|POST] [--data ]` | Single-shot paid request: sign 1 auth, attach X-PAYMENT, send | -| `pay-agent --model [--message \| --data ] [--timeout ]` | Single-shot paid streaming agent call: SSE events flush to stdout as they arrive (default timeout 1h) | +| `pay-agent [--message \| --data ] [--timeout ]` | 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 --endpoint --model [--budget N] [--count N]` | Pre-sign auths, create/update `PurchaseRequest`, expose `paid/` | | `buy --endpoint --model --set-default [--auto-refill]` | As above, then set `paid/` as the agent's own primary model in-pod (no restart, no host CLI) | | `process \| --all` | Reconcile `autoRefill` policies against live `x402-buyer` status | diff --git a/internal/embed/skills/buy-x402/scripts/buy.py b/internal/embed/skills/buy-x402/scripts/buy.py index caf4cbc5..4ce3f939 100644 --- a/internal/embed/skills/buy-x402/scripts/buy.py +++ b/internal/embed/skills/buy-x402/scripts/buy.py @@ -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 @@ -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 ) is required for `pay-agent`.\n" - "Example: pay-agent --model qwen3.5:9b --message 'summarize the docs'", + "Example: pay-agent --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) @@ -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 --model [--message '' | --data ''] [--timeout ]") + print(" pay-agent [--message '' | --data ''] [--timeout ]") print(" [--token ] [--network ] [--payment-option ]") print(" Single-shot paid streaming agent call (POST /v1/chat/completions,") print(" stream: true). Each SSE event flushes to stdout so a calling") @@ -2788,7 +2785,7 @@ def usage(): positional, opts = parse_flags(rest) if not positional: print( - "Usage: pay-agent --model [--message '' | --data ''] " + "Usage: pay-agent [--message '' | --data ''] " "[--network ] [--timeout ]", file=sys.stderr, ) @@ -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"), diff --git a/internal/serviceoffercontroller/agent_render.go b/internal/serviceoffercontroller/agent_render.go index e1ade18a..8da52f1b 100644 --- a/internal/serviceoffercontroller/agent_render.go +++ b/internal/serviceoffercontroller/agent_render.go @@ -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 @@ -109,8 +122,8 @@ 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 @@ -118,6 +131,7 @@ agent: disabled_toolsets: - memory - web + - code_execution skills: external_dirs: - /data/.hermes/obol-skills diff --git a/internal/serviceoffercontroller/agent_render_test.go b/internal/serviceoffercontroller/agent_render_test.go index dfc227c5..ede9a994 100644 --- a/internal/serviceoffercontroller/agent_render_test.go +++ b/internal/serviceoffercontroller/agent_render_test.go @@ -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) diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index 8481b077..25743131 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -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", }, }, } @@ -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, "/") @@ -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 = "—" } @@ -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)) diff --git a/internal/serviceoffercontroller/render_builders_test.go b/internal/serviceoffercontroller/render_builders_test.go index 5c3bf105..9b3e14e0 100644 --- a/internal/serviceoffercontroller/render_builders_test.go +++ b/internal/serviceoffercontroller/render_builders_test.go @@ -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" { diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index c903a338..fe3544cf 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -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 { diff --git a/internal/x402/agent_extras_test.go b/internal/x402/agent_extras_test.go index 66d35614..1af20366 100644 --- a/internal/x402/agent_extras_test.go +++ b/internal/x402/agent_extras_test.go @@ -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", @@ -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) @@ -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) } } diff --git a/internal/x402/bazaar.go b/internal/x402/bazaar.go index db3bf8ee..29382e2d 100644 --- a/internal/x402/bazaar.go +++ b/internal/x402/bazaar.go @@ -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/), 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() } diff --git a/internal/x402/bazaar_test.go b/internal/x402/bazaar_test.go index e1269af0..0d53b9da 100644 --- a/internal/x402/bazaar_test.go +++ b/internal/x402/bazaar_test.go @@ -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 diff --git a/internal/x402/paymentrequired.go b/internal/x402/paymentrequired.go index d23c261d..ed920873 100644 --- a/internal/x402/paymentrequired.go +++ b/internal/x402/paymentrequired.go @@ -414,13 +414,13 @@ func inferenceCopy(url, siteURL string, d PaymentDisplay) typeCopy { "pre-authorizes the provider through your agent's wallet and registers the model as " + "paid/<model> in your local LiteLLM gateway, so every agent in your stack " + "can call it like any other OpenAI-compatible model."), - ShowPrimary: true, - PrimaryTitle: "Use this service for your Obol Agent's model", - PrimaryLede: "Run this from your obol-stack host. The CLI walks `/api/services.json`, prompts for auto-refill + a request count, and pre-signs the authorizations from your master agent's wallet. Pass `--yes --count ` for non-interactive runs.", - PrimaryIsCode: true, - PrimaryPayload: cmd, - PromptObol: prompt, - PromptOther: other, + ShowPrimary: true, + PrimaryTitle: "Use this service for your Obol Agent's model", + PrimaryLede: "Run this from your obol-stack host. The CLI walks `/api/services.json`, prompts for auto-refill + a request count, and pre-signs the authorizations from your master agent's wallet. Pass `--yes --count ` for non-interactive runs.", + PrimaryIsCode: true, + PrimaryPayload: cmd, + PromptObol: prompt, + PromptOther: other, ChatCompletionsNote: "Direct HTTP buyers use OpenAI-style chat-completions. A minimal paid request looks like:", ChatCompletionsBody: fmt.Sprintf(`POST %s/v1/chat/completions Content-Type: application/json @@ -441,40 +441,35 @@ X-PAYMENT: // Other-AI-Agent prompt cards drive the action, and a chat-completions // example sits next to the raw x402 JSON in the Pay-manually card to // make the wire shape obvious to readers walking the spec by hand. -func agentCopy(url, siteURL string, d PaymentDisplay) typeCopy { - model := sanitizeDisplayToken(d.Model, "") - modelClause := "" - modelLine := "" - if model != "" { - modelClause = fmt.Sprintf(`"model": "%s",`, model) - modelLine = " (running " + model + ")" - } - +func agentCopy(url, siteURL string, _ PaymentDisplay) typeCopy { + // Deliberately no model: an Obol Agent runs its own model, skills, and + // memory — the buyer never picks one. Surfacing the underlying model here + // is noise (and the chat-completions `model` field is ignored by the + // agent), so the example omits it entirely. body := fmt.Sprintf(`POST %s Content-Type: application/json X-PAYMENT: { - %s "messages": [ {"role": "user", "content": ""} ] -}`, url, modelClause) +}`, url) - modelFlag := sanitizeDisplayToken(d.Model, "") prompt := fmt.Sprintf( - "Use the buy-x402 skill's `pay-agent` command to buy one round of work from this Obol Agent%s. "+ - "This is an *agent*, not a raw model — it has its own skills, tools, and memory. Example:\n\n"+ - "pay-agent %s --model %s --message \"\"", - modelLine, url, modelFlag, + "Use the buy-x402 skill's `pay-agent` command to buy one round of work from this Obol Agent. "+ + "This is an *agent*, not a raw model — it has its own skills, tools, and memory (and picks "+ + "its own model), so you only send it a prompt. Example:\n\n"+ + "pay-agent %s --message \"\"", + url, ) other := fmt.Sprintf( - "Help me call the Obol Agent at %s%s — it's an autonomous agent (tools + skills + memory), "+ + "Help me call the Obol Agent at %s — it's an autonomous agent (tools + skills + memory), "+ "not a raw LLM. It's gated by %s. POST OpenAI-style chat-completions JSON with a real "+ "prompt in `messages`, attach a signed EIP-3009/Permit2 authorization as `X-PAYMENT`, "+ "and report what the agent does.", - url, modelLine, x402GuideRef(siteURL), + url, x402GuideRef(siteURL), ) return typeCopy{ diff --git a/internal/x402/paymentrequired_test.go b/internal/x402/paymentrequired_test.go index a9ef9563..9cdcf0be 100644 --- a/internal/x402/paymentrequired_test.go +++ b/internal/x402/paymentrequired_test.go @@ -252,9 +252,10 @@ func TestHTMLAware_AgentShowsChatCompletionsInPayManually(t *testing.T) { } mustContain(t, body, "Pay manually (raw HTTP 402)") mustContain(t, body, "Obol Agents accept OpenAI-style chat-completions bodies") - // Example chat-completions body (JSON snippet inside
; html/template
-	// escapes the quotes).
-	mustContain(t, body, `"model": "qwen3.5:9b"`)
+	// The agent runs its own model — the real id must never leak into the
+	// 402 page (neither the hand-written example nor the embedded bazaar
+	// JSON). The bazaar example carries a neutral placeholder instead.
+	mustNotContain(t, body, "qwen3.5:9b")
 	mustContain(t, body, `"messages":`)
 
 	// Lede uses the operator-facing copy and links to docs.obol.org.
@@ -387,6 +388,13 @@ func mustContain(t *testing.T, haystack, needle string) {
 	}
 }
 
+func mustNotContain(t *testing.T, haystack, needle string) {
+	t.Helper()
+	if strings.Contains(haystack, needle) {
+		t.Errorf("body unexpectedly contains %q", needle)
+	}
+}
+
 // sanitizeDisplayToken must pass real model ids / offer names through
 // untouched while collapsing anything carrying shell metacharacters to the
 // placeholder — the values land in copy-pasteable commands on the public
diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go
index 264522af..ab42479f 100644
--- a/internal/x402/verifier.go
+++ b/internal/x402/verifier.go
@@ -444,19 +444,18 @@ func patternToPrefix(pattern string) string {
 	return strings.TrimSuffix(pattern, "*")
 }
 
-// mergeAgentExtras adds the agent fields from a RouteRule to the
-// requirement's Extra map so buyers probing a 402 see which model and
-// skills are powering the offer. No-op for non-agent rules.
+// mergeAgentExtras adds agent metadata from a RouteRule to the requirement's
+// Extra map so buyers probing a 402 can tell it's an agent. The underlying
+// model is intentionally NOT surfaced: an Obol Agent runs its own model and
+// the buyer never selects one, so the model id is an internal detail, not
+// buyer-facing info. No-op for non-agent rules.
 func mergeAgentExtras(req *x402types.PaymentRequirements, rule *RouteRule) {
-	if rule.AgentModel == "" && len(rule.AgentSkills) == 0 && rule.AgentRuntime == "" {
+	if len(rule.AgentSkills) == 0 && rule.AgentRuntime == "" {
 		return
 	}
 	if req.Extra == nil {
 		req.Extra = make(map[string]interface{})
 	}
-	if rule.AgentModel != "" {
-		req.Extra["agentModel"] = rule.AgentModel
-	}
 	if len(rule.AgentSkills) > 0 {
 		// Materialise as []any so JSON marshalling produces a proper array
 		// regardless of whether the source loaded it from yaml or