diff --git a/internal/serviceoffercontroller/hostoffer_test.go b/internal/serviceoffercontroller/hostoffer_test.go index 1f47ff85..e5b439b4 100644 --- a/internal/serviceoffercontroller/hostoffer_test.go +++ b/internal/serviceoffercontroller/hostoffer_test.go @@ -183,6 +183,72 @@ func TestBuildOfferBundles(t *testing.T) { } } +// TestBuildOfferBundles_InferenceOfferAgreesWithOpenAPI pins that a +// hostname-bound inference offer (no custom Spec.Routes — synthesized +// root catch-all) advertises the same paid path on both discovery +// surfaces: openapi.json paths and /.well-known/x402 resources. +// Before the openAPIRelPathForOfferRoute fix, x402 collapsed "/*" to +// the bare origin while openapi hard-coded /v1/chat/completions. +func TestBuildOfferBundles_InferenceOfferAgreesWithOpenAPI(t *testing.T) { + profile := schemas.StorefrontProfile{DisplayName: "Acme"} + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "chat", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "inference", + Hostname: "chat.v1337.example", + Upstream: monetizeapi.ServiceOfferUpstream{Service: "gateway", Namespace: "llm", Port: 8080}, + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base-sepolia", + PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.1"}, + }, + // Spec.Routes intentionally empty: EffectiveRoutes synthesizes /*. + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{ + {Type: "ModelReady", Status: "True"}, + {Type: "UpstreamHealthy", Status: "True"}, + {Type: "PaymentGateReady", Status: "True"}, + {Type: "RoutePublished", Status: "True"}, + }, + }, + } + + bundles := buildOfferBundles([]*monetizeapi.ServiceOffer{offer}, profile, noUpstreamOpenAPI) + byPath := map[string]string{} + for _, f := range bundles { + byPath[f.Path] = f.Content + } + + var doc map[string]any + if err := json.Unmarshal([]byte(byPath["offers/llm/chat/openapi.json"]), &doc); err != nil { + t.Fatalf("openapi bundle: %v", err) + } + paths := doc["paths"].(map[string]any) + if len(paths) != 1 { + t.Fatalf("paths = %v, want exactly one key", mapKeys(paths)) + } + if _, ok := paths["/v1/chat/completions"]; !ok { + t.Fatalf("paths = %v, want /v1/chat/completions", mapKeys(paths)) + } + + var wk struct { + Resources []struct { + Resource string `json:"resource"` + } `json:"resources"` + } + if err := json.Unmarshal([]byte(byPath["offers/llm/chat/x402.json"]), &wk); err != nil { + t.Fatalf("x402 bundle: %v", err) + } + if len(wk.Resources) != 1 { + t.Fatalf("len(resources) = %d, want 1", len(wk.Resources)) + } + want := "https://chat.v1337.example/v1/chat/completions" + if wk.Resources[0].Resource != want { + t.Errorf("resources[0].resource = %q, want %q (must agree with openapi.json path)", wk.Resources[0].Resource, want) + } +} + // TestBuildOfferBundles_BrandingOverride pins the per-origin identity merge: // spec.branding fields override the storefront profile on the dedicated // origin's surfaces, empty fields inherit. diff --git a/internal/serviceoffercontroller/offerbundle.go b/internal/serviceoffercontroller/offerbundle.go index ba9c34ba..84786e41 100644 --- a/internal/serviceoffercontroller/offerbundle.go +++ b/internal/serviceoffercontroller/offerbundle.go @@ -197,6 +197,20 @@ func rerootAuthInfo(item map[string]any) map[string]any { return out } +// openAPIRelPathForOfferRoute resolves the OpenAPI-relative path a single +// route maps to, matching openAPIPathsForOffer's per-type path selection so +// /.well-known/x402 and openapi.json agree on the same paid operation path. +// Inference/agent offers always expose the fixed OpenAI-compatible endpoint +// regardless of the declared route table (openAPIPathsForOffer ignores +// spec.routes for these types); every other type maps the route's own path +// through openAPIRelPathForRoute exactly as the OpenAPI builder does. +func openAPIRelPathForOfferRoute(offer *monetizeapi.ServiceOffer, routePath string) string { + if offer.IsInference() || offer.IsAgent() { + return "/v1/chat/completions" + } + return openAPIRelPathForRoute(routePath) +} + // buildOfferWellKnownX402 renders the /.well-known/x402 discovery document: // one resource entry per paid route, each carrying the signable payment // requirements (mirrors the 402 accepts[] fields so a crawler can price the @@ -228,7 +242,7 @@ func buildOfferWellKnownX402(offer *monetizeapi.ServiceOffer) string { desc = offerDescription(offer, "x402 payment-gated service.") } resources = append(resources, map[string]any{ - "resource": origin + joinOpenAPIPath("/", openAPIRelPathForRoute(rt.Path)), + "resource": origin + joinOpenAPIPath("/", openAPIRelPathForOfferRoute(offer, rt.Path)), "type": "http", "method": method, "description": desc,