Skip to content

fix(x402): strip healthPath from buyer endpoint URL construction#539

Open
bussyjd wants to merge 1 commit into
mainfrom
fix/buyer-url-strip-healthpath
Open

fix(x402): strip healthPath from buyer endpoint URL construction#539
bussyjd wants to merge 1 commit into
mainfrom
fix/buyer-url-strip-healthpath

Conversation

@bussyjd
Copy link
Copy Markdown
Collaborator

@bussyjd bussyjd commented May 24, 2026

Summary

buy.py's _normalize_endpoint only stripped trailing /v1/chat/completions and /chat/completions suffixes. When a seller declared a non-trivial upstream.healthPath (e.g. /api/tags for Ollama) and that segment leaked into the --endpoint URL, the spec builder happily re-appended /v1/chat/completions on top, producing dead URLs like http://traefik.../services/demo-hello/api/tags/v1/chat/completions.

Repro

Seller:

obol sell http demo-hello --upstream ollama --port 11434 --namespace llm \
  --health-path /api/tags --per-request 0.001 --chain base-sepolia --wallet 0x...

Buyer:

buy.py buy demo-hello --endpoint <URL> --model demo --count 1

Before fix: PurchaseRequest.spec.endpoint = http://traefik.../services/demo-hello/api/tags/v1/chat/completions. LiteLLM paid/demo POSTs through the x402-buyer sidecar to that URL → 404 from Traefik.

Root cause

buy.py (skill layer). The seller-side surface was clean:

  • internal/serviceoffercontroller/render.go and controller.go only ever emit offer.EffectivePath() (= /services/<name>) for storefront / /skill.md / registration / status.Endpoint. EffectiveHealthPath() is consumed exactly once, by the controller's upstream health probe at controller.go:589.
  • internal/x402/serviceoffer_source.go sets RouteRule.UpstreamURL to http://<svc>.<ns>.svc.cluster.local:<port> (no healthPath) and StripPrefix to offer.EffectivePath().
  • internal/x402/forwardauth.go::buildResourceURL echoes the incoming request URI back as the 402 resource.url, but the seller never advertises a URL with healthPath in accepts[].

So the bug was confined to the buyer: it accepted any user-supplied URL containing /services/<name>/<extra>... and didn't sanitise the <extra> tail before appending /v1/chat/completions.

Fix

_normalize_endpoint now also detects /services/<segment>/... paths and truncates to /services/<segment> before appending the LLM suffix. External (non-/services) sellers are untouched.

Test plan

  • Added tests/test_buy_normalize_endpoint.py (13 cases) covering:
    • existing trailing-slash / /v1/chat/completions / /chat/completions strip behaviour preserved
    • canonical /services/<name> URL unchanged
    • healthPath segments /api/tags, /health, nested paths, and /api/tags/v1/chat/completions all collapse to /services/<name>
    • assembled <normalized>/v1/chat/completions matches canonical buyer URL
    • non-/services URLs left intact (external x402 sellers)
  • go build ./... clean
  • go test ./internal/x402/... ./internal/serviceoffercontroller/... green
  • go test ./... green except one pre-existing failure (TestWarnIfNoChatModel_EmitsWarnWhenNoModels in internal/stack — unrelated, confirmed against main)
  • Existing Python tests in tests/test_buy_autorefill.py still pass (4 pre-existing socket failures from offline test env — confirmed against main)
  • Live cluster: obol sell http demo-hello --health-path /api/tags ... then buy.py buy demo-hello --endpoint http://traefik.../services/demo-hello/api/tags ... and confirm paid/demo returns 200

Coordination

Bundle PR #536 touches the same buy.py script area. This is a small, surgical change inside _normalize_endpoint; conflict resolution post-merge of #536 should be a quick rebase.

`buy.py`'s `_normalize_endpoint` previously only trimmed trailing
`/v1/chat/completions` and `/chat/completions` suffixes from the
user-supplied `--endpoint`. When the seller's ServiceOffer declared a
non-trivial `upstream.healthPath` (e.g. `/api/tags` for Ollama), and the
endpoint URL passed to `buy.py buy` included that healthPath segment
between `/services/<name>` and the LLM suffix, `_build_purchase_spec`
then re-appended `/v1/chat/completions` on top of it. The resulting
PurchaseRequest endpoint was
`http://traefik.../services/demo-hello/api/tags/v1/chat/completions`,
which 404s — `healthPath` is for the controller's upstream health probe
only, never part of the publicly routed offer path
(`/services/<name>`).

Normalize the endpoint to its canonical offer base: when the path
matches `/services/<segment>/...`, truncate to `/services/<segment>`
before appending the LLM suffix. Preserves existing behaviour for
non-`/services` URLs (external x402 sellers) and bare `/services/<name>`
endpoints.

Added `tests/test_buy_normalize_endpoint.py` covering the regression
plus existing-suffix and non-`/services` URL cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant