diff --git a/internal/embed/embed_crd_test.go b/internal/embed/embed_crd_test.go index e8b45085..67f3b7e9 100644 --- a/internal/embed/embed_crd_test.go +++ b/internal/embed/embed_crd_test.go @@ -149,8 +149,9 @@ func TestServiceOfferCRD_Fields(t *testing.T) { } // Required fields in spec (aligned with x402/ERC-8004 schema). agent - // joins this list as part of the type=agent offer flow. - for _, field := range []string{"type", "agent", "model", "upstream", "payment", "path", "registration"} { + // joins this list as part of the type=agent offer flow; dataset joins + // it for the type=dataset offer flow. + for _, field := range []string{"type", "agent", "dataset", "model", "upstream", "payment", "path", "registration"} { if _, exists := pm[field]; !exists { t.Errorf("spec.properties missing field %q", field) } @@ -162,13 +163,64 @@ func TestServiceOfferCRD_Fields(t *testing.T) { for _, v := range enum { got[v.(string)] = true } - for _, want := range []string{"inference", "fine-tuning", "http", "agent"} { + for _, want := range []string{"inference", "fine-tuning", "http", "agent", "dataset"} { if !got[want] { t.Errorf("spec.type.enum missing %q", want) } } } +// TestServiceOfferCRD_DatasetFields pins the type=dataset schema surface: +// the spec.dataset block carries the pinned artifact metadata (mirroring +// spec.agent), and price.perMB enables per-megabyte pricing. Mirrors +// TestServiceOfferCRD_Fields' navigation. +func TestServiceOfferCRD_DatasetFields(t *testing.T) { + data, err := ReadInfrastructureFile("base/templates/serviceoffer-crd.yaml") + if err != nil { + t.Fatalf("ReadInfrastructureFile: %v", err) + } + + crd := findDoc(multiDoc(data), "CustomResourceDefinition") + if crd == nil { + t.Fatal("no CRD document found") + } + + versions, ok := nested(crd, "spec", "versions").([]any) + if !ok || len(versions) == 0 { + t.Fatal("spec.versions is empty or wrong type") + } + v0, ok := versions[0].(map[string]any) + if !ok { + t.Fatal("versions[0] is not a map") + } + + specProps, ok := nested(v0, "schema", "openAPIV3Schema", "properties", "spec", "properties").(map[string]any) + if !ok { + t.Fatal("spec.properties is not a map") + } + + datasetProps, ok := nested(specProps, "dataset", "properties").(map[string]any) + if !ok { + t.Fatal("spec.dataset.properties missing — type=dataset offers can't pin a version") + } + for _, field := range []string{"manifestHash", "version", "fileHash", "sizeBytes"} { + if _, exists := datasetProps[field]; !exists { + t.Errorf("spec.dataset.properties missing field %q", field) + } + } + if sb, ok := datasetProps["sizeBytes"].(map[string]any); ok && sb["type"] != "integer" { + t.Errorf("spec.dataset.sizeBytes type = %v, want integer", sb["type"]) + } + + priceProps, ok := nested(specProps, "payment", "properties", "price", "properties").(map[string]any) + if !ok { + t.Fatal("spec.payment.price.properties missing") + } + if _, exists := priceProps["perMB"]; !exists { + t.Error("spec.payment.price.properties missing perMB — dataset offers can't price per-MB") + } +} + func TestServiceOfferCRD_PrinterColumns(t *testing.T) { data, err := ReadInfrastructureFile("base/templates/serviceoffer-crd.yaml") if err != nil { diff --git a/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml b/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml index 7b67e13a..274cd539 100644 --- a/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml +++ b/internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml @@ -79,6 +79,34 @@ spec: - namespace type: object type: object + dataset: + description: |- + Populated when type='dataset'. Pins the versioned dataset artifact + (an export bundle) the offer sells: the content-address anchor + (manifestHash), the published version, and the artifact size. The + controller surfaces these in the 402 response's extra.dataset block. + properties: + fileHash: + description: |- + SHA-256 of the served file, for whole-file integrity verification + after download. + type: string + manifestHash: + description: |- + Content-address anchor of the artifact: the export bundle + manifestHash (SHA-256 over the artifact contents). + type: string + sizeBytes: + description: Size of the served artifact in bytes. Drives per-MB + pricing. + format: int64 + minimum: 0 + type: integer + version: + description: Monotonic published version tag of the dataset (e.g. + "1", "2"). + type: string + type: object drainAt: description: |- DrainAt marks the offer as draining when non-nil. While the offer @@ -178,6 +206,9 @@ spec: perHour: description: Per-compute-hour price in USDC. Fine-tuning only. type: string + perMB: + description: Per-megabyte price in USDC. Dataset only. + type: string perMTok: description: Per-million-tokens price in USDC. Inference only. type: string @@ -278,12 +309,14 @@ spec: description: |- Service type. 'inference' enables model management; 'http' for any HTTP service; 'agent' references an Agent CR via spec.agent.ref and the - controller derives upstream + model + skills from the agent's status. + controller derives upstream + model + skills from the agent's status; + 'dataset' sells a versioned dataset artifact via spec.dataset. enum: - inference - fine-tuning - http - agent + - dataset type: string upstream: description: In-cluster service that handles the actual workload. diff --git a/internal/monetizeapi/types.go b/internal/monetizeapi/types.go index 2efb439b..18db62ae 100644 --- a/internal/monetizeapi/types.go +++ b/internal/monetizeapi/types.go @@ -101,9 +101,10 @@ type ServiceOfferList struct { type ServiceOfferSpec struct { // Service type. 'inference' enables model management; 'http' for any HTTP // service; 'agent' references an Agent CR via spec.agent.ref and the - // controller derives upstream + model + skills from the agent's status. + // controller derives upstream + model + skills from the agent's status; + // 'dataset' sells a versioned dataset artifact via spec.dataset. // +kubebuilder:default="http" - // +kubebuilder:validation:Enum=inference;fine-tuning;http;agent + // +kubebuilder:validation:Enum=inference;fine-tuning;http;agent;dataset Type string `json:"type,omitempty"` // Required when type='agent'. The controller resolves spec.agent.ref to @@ -111,6 +112,12 @@ type ServiceOfferSpec struct { // and surfaces the agent's pinned model + skills in the 402 response. Agent ServiceOfferAgent `json:"agent,omitempty"` + // Populated when type='dataset'. Pins the versioned dataset artifact + // (an export bundle) the offer sells: the content-address anchor + // (manifestHash), the published version, and the artifact size. The + // controller surfaces these in the 402 response's extra.dataset block. + Dataset ServiceOfferDataset `json:"dataset,omitempty"` + // LLM model metadata. Required when the upstream serves an LLM. Model ServiceOfferModel `json:"model,omitempty"` @@ -164,6 +171,24 @@ type ServiceOfferAgentRef struct { Namespace string `json:"namespace"` } +// ServiceOfferDataset is populated when Spec.Type == "dataset". It pins the +// versioned dataset artifact (an export bundle) the offer sells. The +// controller surfaces these fields in the 402 response's extra.dataset block +// so buyers see exactly which content-addressed version they're paying for. +type ServiceOfferDataset struct { + // Content-address anchor of the artifact: the export bundle + // manifestHash (SHA-256 over the artifact contents). + ManifestHash string `json:"manifestHash,omitempty"` + // Monotonic published version tag of the dataset (e.g. "1", "2"). + Version string `json:"version,omitempty"` + // SHA-256 of the served file, for whole-file integrity verification + // after download. + FileHash string `json:"fileHash,omitempty"` + // Size of the served artifact in bytes. Drives per-MB pricing. + // +kubebuilder:validation:Minimum=0 + SizeBytes int64 `json:"sizeBytes,omitempty"` +} + type ServiceOfferModel struct { // Model identifier (e.g. qwen3.5:35b). // +kubebuilder:validation:Required @@ -245,6 +270,8 @@ type ServiceOfferPriceTable struct { PerHour string `json:"perHour,omitempty"` // Per-training-epoch price in USDC. Fine-tuning only. PerEpoch string `json:"perEpoch,omitempty"` + // Per-megabyte price in USDC. Dataset only. + PerMB string `json:"perMB,omitempty"` } type ServiceOfferRegistration struct { @@ -423,6 +450,13 @@ func (o *ServiceOffer) IsAgent() bool { return o.Spec.Type == "agent" } +// IsDataset reports whether the offer sells a versioned dataset artifact. +// Type=="dataset" is the only signal; spec.dataset carries the pinned +// version metadata surfaced in the 402 extra block. +func (o *ServiceOffer) IsDataset() bool { + return o.Spec.Type == "dataset" +} + // IsDraining reports whether spec.drainAt has been set. Drained offers // transition through three phases: pre-drain (DrainAt nil), draining // (DrainAt set, now < DrainEndsAt), and drain-expired (DrainAt set, @@ -724,8 +758,7 @@ type AgentIdentityList struct { Items []AgentIdentity `json:"items"` } -type AgentIdentitySpec struct { -} +type AgentIdentitySpec struct{} type AgentIdentityStatus struct { // Per-chain ERC-8004 registrations for this identity document. diff --git a/internal/monetizeapi/zz_generated.deepcopy.go b/internal/monetizeapi/zz_generated.deepcopy.go index 3c0207f3..a541cc81 100644 --- a/internal/monetizeapi/zz_generated.deepcopy.go +++ b/internal/monetizeapi/zz_generated.deepcopy.go @@ -570,6 +570,21 @@ func (in *ServiceOfferAsset) DeepCopy() *ServiceOfferAsset { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServiceOfferDataset) DeepCopyInto(out *ServiceOfferDataset) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServiceOfferDataset. +func (in *ServiceOfferDataset) DeepCopy() *ServiceOfferDataset { + if in == nil { + return nil + } + out := new(ServiceOfferDataset) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServiceOfferList) DeepCopyInto(out *ServiceOfferList) { *out = *in @@ -710,6 +725,7 @@ func (in *ServiceOfferService) DeepCopy() *ServiceOfferService { func (in *ServiceOfferSpec) DeepCopyInto(out *ServiceOfferSpec) { *out = *in out.Agent = in.Agent + out.Dataset = in.Dataset out.Model = in.Model out.Upstream = in.Upstream out.Payment = in.Payment diff --git a/internal/schemas/service-catalog.schema.json b/internal/schemas/service-catalog.schema.json index 5a9808d2..1184341a 100644 --- a/internal/schemas/service-catalog.schema.json +++ b/internal/schemas/service-catalog.schema.json @@ -95,7 +95,8 @@ "inference", "fine-tuning", "http", - "agent" + "agent", + "dataset" ] }, "model": { @@ -119,7 +120,8 @@ "perRequest", "perMTok", "perHour", - "perEpoch" + "perEpoch", + "perMB" ] }, "priceAtomicUnits": { @@ -165,6 +167,21 @@ "type": "string", "format": "date-time", "description": "RFC3339 timestamp at which the offer's HTTPRoute will be torn down. Set only when the offer is draining. Catalog consumers should detect drain via the presence of this field." + }, + "datasetManifestHash": { + "type": "string", + "minLength": 1, + "description": "type=dataset only: content-address anchor (export bundle manifestHash, SHA-256) of the sold artifact." + }, + "datasetVersion": { + "type": "string", + "minLength": 1, + "description": "type=dataset only: monotonic published version tag of the dataset." + }, + "datasetSizeBytes": { + "type": "integer", + "minimum": 0, + "description": "type=dataset only: size of the served artifact in bytes." } } } diff --git a/internal/schemas/service_catalog.go b/internal/schemas/service_catalog.go index 6423b31d..5a1baef7 100644 --- a/internal/schemas/service_catalog.go +++ b/internal/schemas/service_catalog.go @@ -53,6 +53,15 @@ type ServiceCatalogEntry struct { // purely additive vs. pre-drain catalogs. Buyers SHOULD migrate to // alternative providers before this time. DrainEndsAt string `json:"drainEndsAt,omitempty"` + + // Dataset* fields are populated for type=dataset offers, mirroring how + // Model is populated for inference/agent. They expose the pinned, + // content-addressed dataset version on discovery surfaces so buyers + // know exactly which artifact (and version) an offer sells. Additive + // only — see the stable-wire-schema note above. + DatasetManifestHash string `json:"datasetManifestHash,omitempty"` + DatasetVersion string `json:"datasetVersion,omitempty"` + DatasetSizeBytes int64 `json:"datasetSizeBytes,omitempty"` } // ServiceCatalogAsset describes the settlement token resolved for a catalog diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index 3e2506c6..af6ac4a3 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -1162,6 +1162,14 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) } } + // type=dataset offers surface the pinned, content-addressed version + // on discovery, mirroring how Model is surfaced for inference/agent. + if offer.IsDataset() { + svc.DatasetManifestHash = offer.Spec.Dataset.ManifestHash + svc.DatasetVersion = offer.Spec.Dataset.Version + svc.DatasetSizeBytes = offer.Spec.Dataset.SizeBytes + } + services = append(services, svc) } @@ -1173,8 +1181,8 @@ func buildServiceCatalogJSON(offers []*monetizeapi.ServiceOffer, baseURL string) } // offerPriceRawAndUnit returns the raw decimal price string and which slot it -// occupies in the price table. Only one of perRequest / perMTok / perHour is -// expected to be set on a given offer. +// occupies in the price table. Only one of perRequest / perMTok / perHour / +// perMB is expected to be set on a given offer. func offerPriceRawAndUnit(offer *monetizeapi.ServiceOffer) (string, string) { switch { case offer.Spec.Payment.Price.PerRequest != "": @@ -1183,6 +1191,8 @@ func offerPriceRawAndUnit(offer *monetizeapi.ServiceOffer) (string, string) { return offer.Spec.Payment.Price.PerMTok, "perMTok" case offer.Spec.Payment.Price.PerHour != "": return offer.Spec.Payment.Price.PerHour, "perHour" + case offer.Spec.Payment.Price.PerMB != "": + return offer.Spec.Payment.Price.PerMB, "perMB" default: return "", "" } @@ -1352,6 +1362,8 @@ func describeOfferPrice(offer *monetizeapi.ServiceOffer) string { return offer.Spec.Payment.Price.PerMTok + " " + symbol + "/MTok" case offer.Spec.Payment.Price.PerHour != "": return offer.Spec.Payment.Price.PerHour + " " + symbol + "/hour" + case offer.Spec.Payment.Price.PerMB != "": + return offer.Spec.Payment.Price.PerMB + " " + symbol + "/MB" default: return "—" } diff --git a/internal/serviceoffercontroller/render_builders_test.go b/internal/serviceoffercontroller/render_builders_test.go index 5c3bf105..c750f135 100644 --- a/internal/serviceoffercontroller/render_builders_test.go +++ b/internal/serviceoffercontroller/render_builders_test.go @@ -312,6 +312,37 @@ func TestDescribeOfferPrice(t *testing.T) { }, want: "0.001 USDC/request", }, + { + name: "per-mb label for dataset offers", + spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{PerMB: "0.01"}, + }, + }, + want: "0.01 USDC/MB", + }, + { + // perMB is last in the precedence chain + // (perRequest > perMTok > perHour > perMB): a malformed offer + // with both set must surface the higher-priority unit. + name: "per-hour wins over per-mb", + spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Price: monetizeapi.ServiceOfferPriceTable{PerHour: "2.5", PerMB: "0.01"}, + }, + }, + want: "2.5 USDC/hour", + }, + { + name: "per-mb surfaces the OBOL symbol", + spec: monetizeapi.ServiceOfferSpec{ + Payment: monetizeapi.ServiceOfferPayment{ + Asset: monetizeapi.ServiceOfferAsset{Symbol: "OBOL"}, + Price: monetizeapi.ServiceOfferPriceTable{PerMB: "0.01"}, + }, + }, + want: "0.01 OBOL/MB", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -401,6 +432,8 @@ func TestFallbackOfferType(t *testing.T) { {"inference", "inference"}, {"http", "http"}, {"fine-tuning", "fine-tuning"}, + {"agent", "agent"}, + {"dataset", "dataset"}, } for _, tt := range tests { t.Run(tt.in, func(t *testing.T) { diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index a68d7061..395200eb 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -850,6 +850,96 @@ func TestBuildServiceCatalogJSON_AgentOfferUsesResolvedModel(t *testing.T) { } } +// TestBuildServiceCatalogJSON_DatasetOfferSurfacesVersion pins that a +// type=dataset offer surfaces its pinned, content-addressed version + per-MB +// price on the public catalog, mirroring how an agent offer surfaces its +// resolved model. +func TestBuildServiceCatalogJSON_DatasetOfferSurfacesVersion(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "pi-sessions", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "dataset", + Dataset: monetizeapi.ServiceOfferDataset{ + ManifestHash: "abc123", + Version: "2", + SizeBytes: 1048576, + }, + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base-sepolia", + PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerMB: "0.01"}, + }, + Registration: monetizeapi.ServiceOfferRegistration{ + Description: "Sanitized coding-session dataset", + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}, + }, + } + + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://seller.example") + assertServiceCatalogSchema(t, jsonStr) + + var services []schemas.ServiceCatalogEntry + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) + } + if len(services) != 1 { + t.Fatalf("expected 1 service, got %d: %s", len(services), jsonStr) + } + svc := services[0] + if svc.Type != "dataset" { + t.Errorf("type = %q, want dataset", svc.Type) + } + if svc.DatasetManifestHash != "abc123" { + t.Errorf("datasetManifestHash = %q, want abc123", svc.DatasetManifestHash) + } + if svc.DatasetVersion != "2" { + t.Errorf("datasetVersion = %q, want 2", svc.DatasetVersion) + } + if svc.DatasetSizeBytes != 1048576 { + t.Errorf("datasetSizeBytes = %d, want 1048576", svc.DatasetSizeBytes) + } + if svc.Price != "0.01 USDC/MB" { + t.Errorf("price = %q, want 0.01 USDC/MB", svc.Price) + } + if svc.PriceUnit != "perMB" { + t.Errorf("priceUnit = %q, want perMB", svc.PriceUnit) + } +} + +// TestBuildServiceCatalogJSON_NonDatasetOmitsDatasetFields pins the omitempty +// contract: a non-dataset offer must never carry dataset metadata. +func TestBuildServiceCatalogJSON_NonDatasetOmitsDatasetFields(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "infer", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "inference", + Model: monetizeapi.ServiceOfferModel{Name: "qwen3.5:9b"}, + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base-sepolia", + PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "0.001"}, + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}}, + }, + } + + jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://seller.example") + + var services []schemas.ServiceCatalogEntry + if err := json.Unmarshal([]byte(jsonStr), &services); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, jsonStr) + } + svc := services[0] + if svc.DatasetManifestHash != "" || svc.DatasetVersion != "" || svc.DatasetSizeBytes != 0 { + t.Errorf("inference offer must omit dataset fields, got %+v", svc) + } +} + // TestBuildServiceCatalogJSON_ExcludesNonReady locks in the filter pipeline: // nil offers, drain-expired offers, and offers with a DeletionTimestamp // must never leak onto the public storefront, even if they carry diff --git a/internal/x402/bazaar_test.go b/internal/x402/bazaar_test.go index e1269af0..9bb4b6ef 100644 --- a/internal/x402/bazaar_test.go +++ b/internal/x402/bazaar_test.go @@ -36,7 +36,8 @@ func TestBuildBazaarExtension(t *testing.T) { {"agent", "qwen3.5:9b", "qwen3.5:9b"}, {"inference", "", "your-model-id"}, {"http", "", ""}, - {"", "", ""}, // static config routes fall back to the generic shape + {"dataset", "training-v1", ""}, // dataset is a download → generic shape, no chat model + {"", "", ""}, // static config routes fall back to the generic shape } { ext := BuildBazaarExtension(tc.offerType, tc.model) diff --git a/internal/x402/config.go b/internal/x402/config.go index a878ac48..e6ac3f4c 100644 --- a/internal/x402/config.go +++ b/internal/x402/config.go @@ -129,6 +129,15 @@ type RouteRule struct { // `obol buy inference --model ...` command. Model string `yaml:"model,omitempty"` + // Dataset* fields carry the pinned dataset artifact metadata for + // type=dataset offers, mirroring how Agent* fields carry agent + // metadata. Surfaced in `accepts[].extra.dataset` so buyers see which + // content-addressed version they're paying for. + DatasetManifestHash string `yaml:"datasetManifestHash,omitempty"` + DatasetVersion string `yaml:"datasetVersion,omitempty"` + DatasetFileHash string `yaml:"datasetFileHash,omitempty"` + DatasetSizeBytes int64 `yaml:"datasetSizeBytes,omitempty"` + // MaxTimeoutSeconds is the per-request settle window advertised to // buyers (x402: maxTimeoutSeconds). Mirrors // ServiceOffer.spec.payment.maxTimeoutSeconds; 0 = use the verifier diff --git a/internal/x402/dataset_extras_test.go b/internal/x402/dataset_extras_test.go new file mode 100644 index 00000000..e3c580b0 --- /dev/null +++ b/internal/x402/dataset_extras_test.go @@ -0,0 +1,89 @@ +package x402 + +import ( + "testing" + + x402types "github.com/x402-foundation/x402/go/types" +) + +func TestMergeDatasetExtras_Noop_NonDatasetRule(t *testing.T) { + req := x402types.PaymentRequirements{Extra: map[string]any{"name": "USDC"}} + rule := &RouteRule{} + + mergeDatasetExtras(&req, rule) + + if _, ok := req.Extra["dataset"]; ok { + t.Error("non-dataset rule must not add a dataset extra") + } + if got := req.Extra["name"]; got != "USDC" { + t.Errorf("non-dataset merge clobbered existing extra.name: %v", got) + } +} + +func TestMergeDatasetExtras_AddsAllDatasetFields(t *testing.T) { + // Preserve an asset EIP-712 key already on Extra to prove the merge is + // additive (mirrors how mergeAgentExtras must not clobber extra.name). + req := x402types.PaymentRequirements{Extra: map[string]any{"name": "USDC"}} + rule := &RouteRule{ + DatasetManifestHash: "abc123", + DatasetVersion: "2", + DatasetFileHash: "def456", + DatasetSizeBytes: 1048576, + } + + mergeDatasetExtras(&req, rule) + + dataset, ok := req.Extra["dataset"].(map[string]interface{}) + if !ok { + t.Fatalf("extra.dataset wrong type: %T", req.Extra["dataset"]) + } + if got := dataset["manifestHash"]; got != "abc123" { + t.Errorf("dataset.manifestHash = %v, want abc123", got) + } + if got := dataset["version"]; got != "2" { + t.Errorf("dataset.version = %v, want 2", got) + } + if got := dataset["fileHash"]; got != "def456" { + t.Errorf("dataset.fileHash = %v, want def456", got) + } + if got := dataset["sizeBytes"]; got != int64(1048576) { + t.Errorf("dataset.sizeBytes = %v (%T), want int64(1048576)", got, got) + } + if got := req.Extra["name"]; got != "USDC" { + t.Errorf("dataset merge clobbered existing extra.name: %v", got) + } +} + +func TestMergeDatasetExtras_InitialisesNilExtra(t *testing.T) { + // BuildV2RequirementWithAsset always returns a non-nil Extra, but + // mergeDatasetExtras must still cope with a nil map for callers that + // build PaymentRequirements directly (e.g. tests). + req := x402types.PaymentRequirements{} + rule := &RouteRule{DatasetManifestHash: "abc123"} + + mergeDatasetExtras(&req, rule) + + if req.Extra == nil { + t.Fatal("Extra not initialised") + } + dataset, ok := req.Extra["dataset"].(map[string]interface{}) + if !ok || dataset["manifestHash"] != "abc123" { + t.Errorf("dataset.manifestHash missing: %+v", req.Extra) + } +} + +func TestMergeDatasetExtras_OmitsZeroValuedFields(t *testing.T) { + // Only a manifestHash is set; the empty version/fileHash and zero + // sizeBytes must be omitted so buyers don't see blank keys. + req := x402types.PaymentRequirements{} + rule := &RouteRule{DatasetManifestHash: "abc123"} + + mergeDatasetExtras(&req, rule) + + dataset := req.Extra["dataset"].(map[string]interface{}) + for _, k := range []string{"version", "fileHash", "sizeBytes"} { + if _, ok := dataset[k]; ok { + t.Errorf("dataset.%s should be omitted when unset, got %v", k, dataset[k]) + } + } +} diff --git a/internal/x402/paymentrequired_test.go b/internal/x402/paymentrequired_test.go index 4105c629..d62b751d 100644 --- a/internal/x402/paymentrequired_test.go +++ b/internal/x402/paymentrequired_test.go @@ -287,6 +287,28 @@ func TestHTMLAware_HTTPKeepsLegacyCopy(t *testing.T) { } } +// Dataset offers have no bespoke 402 copy in P1: normalizeOfferType folds +// "dataset" into the "http" render branch, so the page shows the generic +// Pay-with-Obol CTA, not the inference CLI card. Dataset version metadata +// reaches buyers via accepts[].extra.dataset, not the HTML copy. +func TestHTMLAware_DatasetUsesGenericHTTPCopy(t *testing.T) { + d := sampleDisplay() + d.OfferType = "dataset" + + render := NewHTMLAwarePaymentRequired(d) + r := httptest.NewRequest("GET", "/services/pi-sessions", nil) + r.Header.Set("Accept", "text/html") + w := httptest.NewRecorder() + render(w, r, []x402types.PaymentRequirements{sampleRequirement()}, nil) + + body := w.Body.String() + mustContain(t, body, "Pay with your Obol Agent") + mustContain(t, body, "buy-x402 skill") + if strings.Contains(body, "obol buy inference") { + t.Errorf("dataset-type 402 page should NOT show the inference CLI primary card") + } +} + func TestFormatAmount(t *testing.T) { cases := []struct { atomic string diff --git a/internal/x402/serviceoffer_source.go b/internal/x402/serviceoffer_source.go index 2983f5ba..e44a3197 100644 --- a/internal/x402/serviceoffer_source.go +++ b/internal/x402/serviceoffer_source.go @@ -191,6 +191,15 @@ func routeRuleFromOffer(offer *monetizeapi.ServiceOffer, upstreamAuth string) (R rule.Model = offer.Spec.Model.Name } + if offer.IsDataset() { + // Normalize the hex digests so buyers can byte-compare the advertised + // values against a freshly computed SHA-256 regardless of operator casing. + rule.DatasetManifestHash = strings.ToLower(offer.Spec.Dataset.ManifestHash) + rule.DatasetVersion = offer.Spec.Dataset.Version + rule.DatasetFileHash = strings.ToLower(offer.Spec.Dataset.FileHash) + rule.DatasetSizeBytes = offer.Spec.Dataset.SizeBytes + } + return rule, nil } @@ -206,6 +215,8 @@ func effectivePrice(offer *monetizeapi.ServiceOffer) (price, priceModel, perMTok return price, "perMTok", offer.Spec.Payment.Price.PerMTok, schemas.ApproxTokensPerRequest, nil case offer.Spec.Payment.Price.PerHour != "": return offer.Spec.Payment.Price.PerHour, "perHour", "", 0, nil + case offer.Spec.Payment.Price.PerMB != "": + return offer.Spec.Payment.Price.PerMB, "perMB", "", 0, nil default: return "0", "", "", 0, nil } diff --git a/internal/x402/serviceoffer_source_test.go b/internal/x402/serviceoffer_source_test.go index 6865fe10..90c28751 100644 --- a/internal/x402/serviceoffer_source_test.go +++ b/internal/x402/serviceoffer_source_test.go @@ -200,6 +200,56 @@ func TestRouteRuleFromOffer_AgentResolutionAdvertisesRuntimeModelSkills(t *testi } } +// TestRouteRuleFromOffer_DatasetAdvertisesDatasetMetadata pins that a +// type=dataset offer plumbs spec.dataset onto the RouteRule (so the verifier +// can surface accepts[].extra.dataset in the 402) and prices via perMB. +// Mirrors the agent-resolution route-rule test. +func TestRouteRuleFromOffer_DatasetAdvertisesDatasetMetadata(t *testing.T) { + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "pi-sessions", Namespace: "llm"}, + Spec: monetizeapi.ServiceOfferSpec{ + Type: "dataset", + Dataset: monetizeapi.ServiceOfferDataset{ + ManifestHash: "ABC123", + Version: "2", + FileHash: "DEF456", + SizeBytes: 1048576, + }, + Payment: monetizeapi.ServiceOfferPayment{ + Network: "base-sepolia", + PayTo: "0x1111111111111111111111111111111111111111", + Price: monetizeapi.ServiceOfferPriceTable{PerMB: "0.01"}, + }, + }, + } + + route, err := routeRuleFromOffer(offer, "") + if err != nil { + t.Fatalf("routeRuleFromOffer: %v", err) + } + if route.OfferType != "dataset" { + t.Errorf("OfferType = %q, want dataset", route.OfferType) + } + if route.DatasetManifestHash != "abc123" { + t.Errorf("DatasetManifestHash = %q, want abc123 (lowercased)", route.DatasetManifestHash) + } + if route.DatasetVersion != "2" { + t.Errorf("DatasetVersion = %q, want 2", route.DatasetVersion) + } + if route.DatasetFileHash != "def456" { + t.Errorf("DatasetFileHash = %q, want def456 (lowercased)", route.DatasetFileHash) + } + if route.DatasetSizeBytes != 1048576 { + t.Errorf("DatasetSizeBytes = %d, want 1048576", route.DatasetSizeBytes) + } + if route.Price != "0.01" { + t.Errorf("Price = %q, want 0.01 (from perMB)", route.Price) + } + if route.Pattern != "/services/pi-sessions/*" { + t.Errorf("Pattern = %q, want /services/pi-sessions/*", route.Pattern) + } +} + // TestRouteRuleFromOffer_PlumbsMaxTimeoutSeconds pins the regression where // ServiceOffer.spec.payment.maxTimeoutSeconds was silently dropped on the // floor — the verifier hardcoded 60 in BuildV2RequirementWithAsset and diff --git a/internal/x402/verifier.go b/internal/x402/verifier.go index 29f4451d..9cc54541 100644 --- a/internal/x402/verifier.go +++ b/internal/x402/verifier.go @@ -333,6 +333,7 @@ func (v *Verifier) matchPaidRouteFull(cfg *PricingConfig, uri string) (*RouteRul asset := ResolveAssetInfo(chain, rule) requirement := BuildV2RequirementWithAsset(chain, asset, rule.Price, wallet, rule.MaxTimeoutSeconds) mergeAgentExtras(&requirement, rule) + mergeDatasetExtras(&requirement, rule) extensions := WithBazaar(BuildExtensionsForAsset(asset), rule.OfferType, rule.Model) return rule, requirement, extensions, prometheusLabels(rule), chain, asset, true } @@ -397,6 +398,33 @@ func mergeAgentExtras(req *x402types.PaymentRequirements, rule *RouteRule) { } } +// mergeDatasetExtras adds the dataset fields from a RouteRule to the +// requirement's Extra map under "dataset" so buyers probing a 402 see +// exactly which content-addressed dataset version they're paying for. No-op +// for non-dataset rules. +func mergeDatasetExtras(req *x402types.PaymentRequirements, rule *RouteRule) { + if rule.DatasetManifestHash == "" && rule.DatasetVersion == "" && rule.DatasetFileHash == "" && rule.DatasetSizeBytes == 0 { + return + } + if req.Extra == nil { + req.Extra = make(map[string]interface{}) + } + dataset := make(map[string]interface{}) + if rule.DatasetManifestHash != "" { + dataset["manifestHash"] = rule.DatasetManifestHash + } + if rule.DatasetVersion != "" { + dataset["version"] = rule.DatasetVersion + } + if rule.DatasetFileHash != "" { + dataset["fileHash"] = rule.DatasetFileHash + } + if rule.DatasetSizeBytes > 0 { + dataset["sizeBytes"] = rule.DatasetSizeBytes + } + req.Extra["dataset"] = dataset +} + // buildPaymentDisplay turns the matched rule + chain + asset into pre-formatted // strings for the HTML 402 page. The atomic-amount input is the value already // computed for the wire requirement (rule.Price * 10^decimals), so passing