Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/renovate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,10 @@ jobs:
RENOVATE_DRY_RUN: ${{ github.event.inputs.dry_run == 'true' && 'full' || '' }}
RENOVATE_REPOSITORIES: ${{ github.repository }}
RENOVATE_BASE_BRANCHES: ${{ github.ref_name }}
# Allow-list for postUpgradeTasks (renovate.json). Only the Scalar
# SRI refresh is permitted: the @scalar/api-reference bump must
# rewrite the SRI hash in the same commit or the /api docs page
# ships a browser-blocked script tag.
RENOVATE_ALLOWED_COMMANDS: '["bash scripts/update-scalar-sri.sh"]'
with:
token: ${{ secrets.GITHUB_TOKEN }}
9 changes: 9 additions & 0 deletions cmd/obol/sell_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strings"
"time"

"github.com/ObolNetwork/obol-stack/internal/buyprompts"
"github.com/ObolNetwork/obol-stack/internal/config"
"github.com/ObolNetwork/obol-stack/internal/kubectl"
"github.com/ObolNetwork/obol-stack/internal/schemas"
Expand Down Expand Up @@ -269,7 +270,15 @@ func serviceHealth(e schemas.ServiceCatalogEntry) string {
}

// howToBuy renders a concise, type-appropriate purchase hint for a service.
// The published catalog carries the canonical instructions (entry.buy,
// generated by internal/buyprompts); the switch below is only a fallback for
// catalogs published by pre-buy-block controllers.
func howToBuy(e schemas.ServiceCatalogEntry) []string {
if e.Buy != nil {
if cli := strings.TrimSpace(e.Buy.Prompts[buyprompts.PromptCLI]); cli != "" {
return []string{cli}
}
}
switch e.Type {
case "inference":
base := endpointBase(e.Endpoint)
Expand Down
12 changes: 11 additions & 1 deletion docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,17 @@ contributors: if you write a guarded division, the epsilon is `1e-9`.

- `internal/x402/metrics.go` — verifier metric definitions
(`obol_x402_verifier_requests_total`, `_payment_required_total`,
`_payment_verified_total`, `_payment_failed_total`, `_charged_requests_total`).
`_payment_verified_total`, `_payment_failed_total`, `_charged_requests_total`,
`_payment_failure_reasons_total`, `_upstream_failed_after_verify_total`).
`_payment_failure_reasons_total` facets failures by a bounded `reason`
label (`invalid_payment_header`, `no_matching_requirement`,
`facilitator_unreachable`, `payment_invalid`, `settlement_failed`,
`settlement_rejected` — the set enumerated in
`internal/x402/forwardauth.go`), turning "the buy funnel leaks" into
"this stage eats the buyers". `_upstream_failed_after_verify_total`
counts paid requests bounced by the seller's own upstream after the
payment verified (never settled) — a seller-side problem, not a
payment-flow one.
- `internal/x402/verifier.go` — `prometheusLabels()` controls the verifier
label set; this is the canonical place to add a new bounded label.
- `internal/x402/buyer/metrics.go` — buyer-side counters
Expand Down
269 changes: 269 additions & 0 deletions internal/buyprompts/buyprompts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
// Package buyprompts is the single authoring point for buyer-facing "how to
// buy" instructions. Three surfaces show a buyer how to pay for a service —
// the 402 paywall page (internal/x402/paymentrequired.go), the public
// storefront (web/public-storefront), and the machine-readable catalog
// (/api/services.json via internal/serviceoffercontroller) — and when each
// composed its own copy they drifted: the 402 page taught agent buyers a
// call path that 404'd while every other surface taught the right one.
//
// The controller publishes the output of this package in each catalog
// entry's `buy` block; the storefront renders that block verbatim; the 402
// page builds its prompt cards from the same functions. Adding support for a
// new kind of buying software means adding one prompt key here — not forking
// a fourth copy of the instructions.
package buyprompts

import (
"fmt"
"strings"
)

// DefaultTaskExample is the placeholder task used in copy-paste prompts and
// wire examples wherever the buyer hasn't supplied a real one.
const DefaultTaskExample = "Summarise the README and list the top 3 risks."

// Prompt keys published in the catalog `buy.prompts` map. Stable API for
// storefront and downstream consumers.
const (
// PromptObolAgent is pasted into an Obol Stack agent (Hermes/OpenClaw)
// that has the buy-x402 skill.
PromptObolAgent = "obol-agent"
// PromptGenericLLM is pasted into any other AI agent (Claude, ChatGPT,
// Gemini, ...) with tool access but no Obol tooling.
PromptGenericLLM = "generic-llm"
// PromptCLI is the shell command a human runs from an obol-stack host.
PromptCLI = "cli"
)

// Input describes one purchasable service. All fields are display-ready
// strings; zero values degrade gracefully (placeholders, omitted clauses).
type Input struct {
// Type is the ServiceOffer type: inference, agent, http, fine-tuning.
// Unknown/empty values get http (single-shot pay) semantics.
Type string
// URL is the service base URL (e.g. https://host/services/<name>).
URL string
// SiteURL is the storefront origin used for discovery references
// (skill.md / openapi.json links). Empty falls back to x402.org.
SiteURL string
// Model is the model id for inference/agent offers ("" when unknown).
Model string
// PriceDisplay is the formatted price (e.g. "0.001 USDC per request").
PriceDisplay string
// NetworkLabel is the human-friendly chain name (e.g. "Base Sepolia").
NetworkLabel string
// TaskExample overrides DefaultTaskExample in prompts and examples.
TaskExample string
}

// CallShape is the machine-readable request recipe for a service. Buying
// software uses it instead of guessing the path/method/streaming mode.
type CallShape struct {
Method string `json:"method"`
// Path is relative to the service base URL ("" = the base itself).
Path string `json:"path,omitempty"`
// BodyKind: "openai-chat" (chat-completions JSON), "json" (operator-
// defined JSON), "multipart" (fine-tuning), or "none".
BodyKind string `json:"bodyKind"`
// Streaming reports whether the endpoint supports (and slow calls
// should use) `"stream": true`.
Streaming bool `json:"streaming"`
}

// Block is the full buyer-instruction block for one service, published as
// the catalog entry's `buy` field.
type Block struct {
CallShape CallShape `json:"callShape"`
Prompts map[string]string `json:"prompts"`
// Example is a copy-pasteable wire example of one paid request.
Example string `json:"example,omitempty"`
}

// Build assembles the canonical Block for a service.
func Build(in Input) Block {
switch normalizeType(in.Type) {
case "agent":
return agentBlock(in)
case "inference":
return inferenceBlock(in)
case "fine-tuning":
return fineTuningBlock(in)
default:
return httpBlock(in)
}
}

// GuideRef is the discovery reference woven into generic-LLM prompts: it
// tells a buyer with no Obol tooling where the full payment recipe lives.
func GuideRef(siteURL string) string {
siteURL = strings.TrimRight(siteURL, "/")
if siteURL == "" {
return "x402 micropayments (see https://www.x402.org)"
}
return fmt.Sprintf(
"x402 micropayments — read %s/skill.md for the full payment flow and %s/openapi.json for the exact request shapes",
siteURL, siteURL,
)
}

// ChatCompletionsURL is the canonical paid-call URL for chat-shaped offers
// (inference and agent). Must stay in lockstep with the gateway's tolerant
// path rewrite (internal/x402/verifier.go normalizeChatCompletionsPath) and
// buy.py's target construction.
func ChatCompletionsURL(baseURL string) string {
return strings.TrimSuffix(baseURL, "/") + "/v1/chat/completions"
}

// ChatExample renders the wire example of one paid chat-completions call.
func ChatExample(url, model, task string) string {
if task == "" {
task = DefaultTaskExample
}
modelClause := ""
if model != "" {
modelClause = fmt.Sprintf("\n \"model\": %q,", model)
}
return fmt.Sprintf(`POST %s
Content-Type: application/json
X-PAYMENT: <pre-signed-EIP-3009-or-Permit2-voucher>

{%s
"messages": [
{"role": "user", "content": %q}
]
}`, ChatCompletionsURL(url), modelClause, task)
}

func normalizeType(t string) string {
switch t {
case "inference", "agent", "fine-tuning":
return t
default:
return "http"
}
}

func task(in Input) string {
if in.TaskExample != "" {
return in.TaskExample
}
return DefaultTaskExample
}

func modelOr(in Input, placeholder string) string {
if in.Model != "" {
return in.Model
}
return placeholder
}

func agentBlock(in Input) Block {
modelFlag := modelOr(in, "<model-id>")
return Block{
CallShape: CallShape{
Method: "POST",
Path: "/v1/chat/completions",
BodyKind: "openai-chat",
Streaming: true,
},
Prompts: map[string]string{
PromptObolAgent: fmt.Sprintf(
"Use the buy-x402 skill's `pay-agent` command to buy one round of work from this "+
"Obol Agent — it has its own skills, tools, and memory, not just a model. Edit the "+
"message, then run:\n\n"+
"pay-agent %s --model %q --message %q",
in.URL, modelFlag, task(in),
),
PromptGenericLLM: fmt.Sprintf(
"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 to "+
"%s with this user message in `messages`: {\"role\":\"user\",\"content\":%q}. "+
"Attach a signed EIP-3009/Permit2 authorization as `X-PAYMENT`, use `\"stream\": true`, "+
"and report what the agent does.",
in.URL, GuideRef(in.SiteURL), ChatCompletionsURL(in.URL), task(in),
),
PromptCLI: fmt.Sprintf(
"python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py go %s --message %q",
in.URL, task(in),
),
},
Example: ChatExample(in.URL, in.Model, in.TaskExample),
}
}

func inferenceBlock(in Input) Block {
model := modelOr(in, "<model-id>")
return Block{
CallShape: CallShape{
Method: "POST",
Path: "/v1/chat/completions",
BodyKind: "openai-chat",
Streaming: true,
},
Prompts: map[string]string{
PromptObolAgent: fmt.Sprintf(
"There's an Obol paid-inference service at %s offering the %s model. "+
"Explain to me how it works, then — if I'm interested — run "+
"`obol buy inference %s` from this host to pre-authorize it and wire "+
"`paid/%s` into our local LiteLLM gateway. After it lands, switch "+
"yourself over to the new model and confirm.",
in.URL, model, in.URL, model,
),
PromptGenericLLM: fmt.Sprintf(
"I want to use the remote LLM at %s (model %s) as a paid OpenAI-compatible "+
"chat-completions endpoint at %s, paid with %s. Pre-sign a budget of EIP-3009/Permit2 "+
"authorizations and POST chat-completions bodies with the X-PAYMENT header attached.",
in.URL, model, ChatCompletionsURL(in.URL), GuideRef(in.SiteURL),
),
PromptCLI: fmt.Sprintf("obol buy inference %s", in.URL),
},
// The model field is required by chat-completions upstreams for
// inference offers, so the example keeps a placeholder when the
// real id is unknown (agents, by contrast, ignore it).
Example: ChatExample(in.URL, model, "<your prompt>"),
}
}

func httpBlock(in Input) Block {
priceClause := ""
if in.PriceDisplay != "" {
priceClause = " Pay " + in.PriceDisplay + "."
}
netClause := ""
if in.NetworkLabel != "" {
netClause = " Network: " + in.NetworkLabel + "."
}
return Block{
CallShape: CallShape{
Method: "GET",
BodyKind: "none",
},
Prompts: map[string]string{
PromptObolAgent: fmt.Sprintf(
"Use the buy-x402 skill's `pay` command to call %s once.%s%s "+
"Use the method and payload the seller documents, and report what it returns.",
in.URL, priceClause, netClause,
),
PromptGenericLLM: fmt.Sprintf(
"Call the paid HTTP endpoint at %s once. It's gated by %s.%s%s "+
"Fetch it with no payment to read the 402 `accepts[]` pricing, sign a matching "+
"EIP-3009/Permit2 authorization, retry the identical request with the payload "+
"base64-encoded in the `X-PAYMENT` header, and report the response.",
in.URL, GuideRef(in.SiteURL), priceClause, netClause,
),
PromptCLI: fmt.Sprintf(
"python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py go %s",
in.URL,
),
},
}
}

func fineTuningBlock(in Input) Block {
block := httpBlock(in)
block.CallShape = CallShape{
Method: "POST",
BodyKind: "multipart",
}
return block
}
Loading
Loading