Skip to content

Commit 52b1dbc

Browse files
bussyjdOisinKyne
authored andcommitted
feat: advertise agent offer model metadata
1 parent 7fcb295 commit 52b1dbc

5 files changed

Lines changed: 259 additions & 1 deletion

File tree

cmd/obol/sell_agent.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/ObolNetwork/obol-stack/internal/hermes"
1313
"github.com/ObolNetwork/obol-stack/internal/kubectl"
1414
"github.com/ObolNetwork/obol-stack/internal/model"
15+
"github.com/ObolNetwork/obol-stack/internal/monetizeapi"
1516
"github.com/ObolNetwork/obol-stack/internal/schemas"
1617
"github.com/ObolNetwork/obol-stack/internal/tunnel"
1718
"github.com/ObolNetwork/obol-stack/internal/ui"
@@ -209,11 +210,16 @@ Examples:
209210
for i, s := range agent.Skills {
210211
skills[i] = s
211212
}
213+
symbol := assetTerms.Symbol
214+
if symbol == "" {
215+
symbol = strings.ToUpper(tokenName)
216+
}
212217
spec["registration"] = map[string]any{
213218
"enabled": true,
214219
"name": regName,
215220
"description": regDesc,
216221
"skills": skills,
222+
"metadata": agentOfferRegistrationMetadata(agent, price, symbol, chain),
217223
}
218224
}
219225

@@ -347,6 +353,10 @@ func runAgentBackedDemo(
347353
// 2. Build and apply the agent-typed ServiceOffer.
348354
register := cmd.Bool("register")
349355
offerNs := agentcrd.Namespace(agentName)
356+
agentForMetadata, _ := getAgentRefForSale(cfg, agentName)
357+
if agentForMetadata == nil {
358+
agentForMetadata = &agentRefForSale{Name: agentName, Namespace: offerNs, Runtime: monetizeapi.AgentRuntimeHermes}
359+
}
350360

351361
payment := map[string]any{
352362
"scheme": "exact",
@@ -379,6 +389,7 @@ func runAgentBackedDemo(
379389
"name": name,
380390
"description": spec.Description,
381391
"skills": skillsAny,
392+
"metadata": agentOfferRegistrationMetadata(agentForMetadata, price, symbol, chain),
382393
}
383394
}
384395

@@ -473,6 +484,8 @@ type agentRefForSale struct {
473484
Name string
474485
Namespace string
475486
WalletAddress string
487+
Runtime string
488+
Model string
476489
Objective string
477490
Skills []string
478491
}
@@ -530,21 +543,58 @@ func decodeAgentJSON(raw string) (*agentRefForSale, error) {
530543
Namespace string `json:"namespace"`
531544
} `json:"metadata"`
532545
Spec struct {
546+
Runtime string `json:"runtime"`
547+
Model string `json:"model"`
533548
Skills []string `json:"skills"`
534549
Objective string `json:"objective"`
535550
} `json:"spec"`
536551
Status struct {
537552
WalletAddress string `json:"walletAddress"`
553+
PinnedModel string `json:"pinnedModel"`
538554
} `json:"status"`
539555
}
540556
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
541557
return nil, err
542558
}
559+
model := strings.TrimSpace(doc.Spec.Model)
560+
if model == "" {
561+
model = strings.TrimSpace(doc.Status.PinnedModel)
562+
}
563+
runtime := strings.TrimSpace(doc.Spec.Runtime)
564+
if runtime == "" {
565+
runtime = monetizeapi.AgentRuntimeHermes
566+
}
543567
return &agentRefForSale{
544568
Name: doc.Metadata.Name,
545569
Namespace: doc.Metadata.Namespace,
546570
WalletAddress: doc.Status.WalletAddress,
571+
Runtime: runtime,
572+
Model: model,
547573
Objective: doc.Spec.Objective,
548574
Skills: append([]string(nil), doc.Spec.Skills...),
549575
}, nil
550576
}
577+
578+
func agentOfferRegistrationMetadata(agent *agentRefForSale, price, symbol, chain string) map[string]string {
579+
metadata := map[string]string{
580+
"pricingUnit": "agent-turn",
581+
}
582+
if price = strings.TrimSpace(price); price != "" {
583+
metadata["x402Price"] = price
584+
}
585+
if symbol = strings.TrimSpace(symbol); symbol != "" {
586+
metadata["x402Asset"] = strings.ToUpper(symbol)
587+
}
588+
if chain = strings.TrimSpace(chain); chain != "" {
589+
metadata["x402Network"] = chain
590+
}
591+
runtime := monetizeapi.AgentRuntimeHermes
592+
if agent != nil && strings.TrimSpace(agent.Runtime) != "" {
593+
runtime = strings.TrimSpace(agent.Runtime)
594+
}
595+
metadata["runtime"] = runtime
596+
if agent != nil && strings.TrimSpace(agent.Model) != "" {
597+
metadata["model"] = strings.TrimSpace(agent.Model)
598+
}
599+
return metadata
600+
}

cmd/obol/sell_agent_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ func TestDecodeAgentJSON_FullDocument(t *testing.T) {
4040
if got.WalletAddress != "0xabcdef0123456789abcdef0123456789abcdef01" {
4141
t.Errorf("walletAddress = %q", got.WalletAddress)
4242
}
43+
if got.Runtime != "hermes" {
44+
t.Errorf("runtime = %q", got.Runtime)
45+
}
46+
if got.Model != "qwen3.5:9b" {
47+
t.Errorf("model = %q", got.Model)
48+
}
4349
if len(got.Skills) != 2 || got.Skills[0] != "addresses" {
4450
t.Errorf("skills = %v", got.Skills)
4551
}
@@ -60,6 +66,24 @@ func TestDecodeAgentJSON_StatusFieldsAreOptional(t *testing.T) {
6066
if got.Objective != "" {
6167
t.Errorf("expected empty objective, got %q", got.Objective)
6268
}
69+
if got.Runtime != "hermes" {
70+
t.Errorf("runtime default = %q, want hermes", got.Runtime)
71+
}
72+
}
73+
74+
func TestDecodeAgentJSON_ModelFallsBackToStatusPinnedModel(t *testing.T) {
75+
raw := `{
76+
"metadata": {"name": "quant", "namespace": "agent-quant"},
77+
"spec": {"skills": ["addresses"]},
78+
"status": {"pinnedModel": "paid/aeon"}
79+
}`
80+
got, err := decodeAgentJSON(raw)
81+
if err != nil {
82+
t.Fatalf("decodeAgentJSON: %v", err)
83+
}
84+
if got.Model != "paid/aeon" {
85+
t.Errorf("model = %q, want paid/aeon", got.Model)
86+
}
6387
}
6488

6589
func TestDecodeAgentJSON_RejectsGarbage(t *testing.T) {
@@ -121,6 +145,40 @@ func TestPickAgentDefault(t *testing.T) {
121145
}
122146
}
123147

148+
func TestAgentOfferRegistrationMetadata_AdvertisesRuntimeModelAndPrice(t *testing.T) {
149+
got := agentOfferRegistrationMetadata(&agentRefForSale{
150+
Runtime: "hermes",
151+
Model: "qwen3.5:9b",
152+
}, "10", "OBOL", "ethereum")
153+
154+
want := map[string]string{
155+
"runtime": "hermes",
156+
"model": "qwen3.5:9b",
157+
"pricingUnit": "agent-turn",
158+
"x402Price": "10",
159+
"x402Asset": "OBOL",
160+
"x402Network": "ethereum",
161+
}
162+
for k, v := range want {
163+
if got[k] != v {
164+
t.Errorf("metadata[%s] = %q, want %q (full=%v)", k, got[k], v, got)
165+
}
166+
}
167+
}
168+
169+
func TestAgentOfferRegistrationMetadata_DefaultsRuntimeHermes(t *testing.T) {
170+
got := agentOfferRegistrationMetadata(nil, "0.001", "usdc", "base-sepolia")
171+
if got["runtime"] != "hermes" {
172+
t.Errorf("runtime = %q, want hermes", got["runtime"])
173+
}
174+
if got["x402Asset"] != "USDC" {
175+
t.Errorf("x402Asset = %q, want USDC", got["x402Asset"])
176+
}
177+
if _, ok := got["model"]; ok {
178+
t.Errorf("model should be omitted when unknown: %v", got)
179+
}
180+
}
181+
124182
func TestSellAgentCommand_FlagShape(t *testing.T) {
125183
cfg := newTestConfig(t)
126184
cmd := sellCommand(cfg)

internal/schemas/service-catalog.schema.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@
9494
"enum": [
9595
"inference",
9696
"fine-tuning",
97-
"http"
97+
"http",
98+
"agent"
9899
]
99100
},
100101
"model": {

internal/serviceoffercontroller/render_test.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,47 @@ func TestBuildActiveRegistrationDocument_KeepsOperatorDescription(t *testing.T)
301301
}
302302
}
303303

304+
func TestBuildActiveRegistrationDocument_PublishesAgentOfferMetadata(t *testing.T) {
305+
owner := &monetizeapi.ServiceOffer{
306+
ObjectMeta: metav1.ObjectMeta{Name: "demo-quant", Namespace: "agent-demo-quant"},
307+
Spec: monetizeapi.ServiceOfferSpec{
308+
Type: "agent",
309+
Path: "/services/demo-quant",
310+
Registration: monetizeapi.ServiceOfferRegistration{
311+
Enabled: true,
312+
Name: "demo-quant",
313+
Description: "Agent-backed chain analyst",
314+
Skills: []string{"ethereum-networks", "addresses"},
315+
Metadata: map[string]string{
316+
"runtime": "hermes",
317+
"model": "qwen3.5:9b",
318+
"pricingUnit": "agent-turn",
319+
"x402Price": "10",
320+
"x402Asset": "OBOL",
321+
"x402Network": "ethereum",
322+
},
323+
},
324+
},
325+
}
326+
327+
doc := buildActiveRegistrationDocument(owner, []*monetizeapi.ServiceOffer{owner}, "https://seller.example", "42")
328+
for k, want := range map[string]string{
329+
"runtime": "hermes",
330+
"model": "qwen3.5:9b",
331+
"pricingUnit": "agent-turn",
332+
"x402Price": "10",
333+
"x402Asset": "OBOL",
334+
"x402Network": "ethereum",
335+
} {
336+
if got := doc.Metadata[k]; got != want {
337+
t.Errorf("metadata[%s] = %q, want %q (full=%v)", k, got, want, doc.Metadata)
338+
}
339+
}
340+
if len(doc.Registrations) != 1 || doc.Registrations[0].AgentID != 42 {
341+
t.Errorf("registrations = %+v, want agentId 42", doc.Registrations)
342+
}
343+
}
344+
304345
// TestBuildActiveRegistrationDocument_FallsBackToModelDescriptionForInference
305346
// pins the *other* side of the description contract: when the operator does
306347
// not supply a description, inference offers should still get the
@@ -621,6 +662,62 @@ func TestBuildServiceCatalogJSON_Empty(t *testing.T) {
621662
}
622663
}
623664

665+
func TestBuildServiceCatalogJSON_AgentOfferUsesResolvedModel(t *testing.T) {
666+
offer := &monetizeapi.ServiceOffer{
667+
ObjectMeta: metav1.ObjectMeta{Name: "demo-quant", Namespace: "agent-demo-quant"},
668+
Spec: monetizeapi.ServiceOfferSpec{
669+
Type: "agent",
670+
Payment: monetizeapi.ServiceOfferPayment{
671+
Network: "ethereum",
672+
PayTo: "0x1111111111111111111111111111111111111111",
673+
Asset: monetizeapi.ServiceOfferAsset{
674+
Address: "0x2222222222222222222222222222222222222222",
675+
Symbol: "OBOL",
676+
Decimals: 18,
677+
TransferMethod: "permit2",
678+
EIP712Name: "OBOL",
679+
EIP712Version: "1",
680+
},
681+
Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "10"},
682+
},
683+
Registration: monetizeapi.ServiceOfferRegistration{
684+
Description: "Agent-backed chain analyst",
685+
},
686+
},
687+
Status: monetizeapi.ServiceOfferStatus{
688+
AgentResolution: &monetizeapi.ServiceOfferAgentResolution{
689+
Model: "qwen3.5:9b",
690+
Runtime: "hermes",
691+
},
692+
Conditions: []monetizeapi.Condition{{Type: "Ready", Status: "True"}},
693+
},
694+
}
695+
696+
jsonStr := buildServiceCatalogJSON([]*monetizeapi.ServiceOffer{offer}, "https://seller.example")
697+
assertServiceCatalogSchema(t, jsonStr)
698+
699+
var services []schemas.ServiceCatalogEntry
700+
if err := json.Unmarshal([]byte(jsonStr), &services); err != nil {
701+
t.Fatalf("invalid JSON: %v\n%s", err, jsonStr)
702+
}
703+
if len(services) != 1 {
704+
t.Fatalf("expected 1 service, got %d: %s", len(services), jsonStr)
705+
}
706+
svc := services[0]
707+
if svc.Type != "agent" {
708+
t.Errorf("type = %q, want agent", svc.Type)
709+
}
710+
if svc.Model != "qwen3.5:9b" {
711+
t.Errorf("model = %q, want qwen3.5:9b", svc.Model)
712+
}
713+
if svc.Price != "10 OBOL/request" {
714+
t.Errorf("price = %q, want 10 OBOL/request", svc.Price)
715+
}
716+
if svc.Endpoint != "https://seller.example/services/demo-quant" {
717+
t.Errorf("endpoint = %q", svc.Endpoint)
718+
}
719+
}
720+
624721
// TestBuildServiceCatalogJSON_ExcludesNonReady locks in the filter pipeline:
625722
// nil offers, paused offers, and offers with a DeletionTimestamp must never
626723
// leak onto the public storefront, even if they carry Ready=True.

internal/x402/serviceoffer_source_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,58 @@ func TestRoutesFromStore_IgnoresUnpublishedOffers(t *testing.T) {
115115
}
116116
}
117117

118+
func TestRouteRuleFromOffer_AgentResolutionAdvertisesRuntimeModelSkills(t *testing.T) {
119+
offer := &monetizeapi.ServiceOffer{
120+
ObjectMeta: metav1.ObjectMeta{Name: "demo-quant", Namespace: "agent-demo-quant"},
121+
Spec: monetizeapi.ServiceOfferSpec{
122+
Type: "agent",
123+
Agent: monetizeapi.ServiceOfferAgent{
124+
Ref: monetizeapi.ServiceOfferAgentRef{Name: "demo-quant", Namespace: "agent-demo-quant"},
125+
},
126+
Payment: monetizeapi.ServiceOfferPayment{
127+
Network: "ethereum",
128+
PayTo: "0x1111111111111111111111111111111111111111",
129+
Asset: monetizeapi.ServiceOfferAsset{
130+
Symbol: "OBOL",
131+
Decimals: 18,
132+
},
133+
Price: monetizeapi.ServiceOfferPriceTable{PerRequest: "10"},
134+
},
135+
},
136+
Status: monetizeapi.ServiceOfferStatus{
137+
AgentResolution: &monetizeapi.ServiceOfferAgentResolution{
138+
Model: "qwen3.5:9b",
139+
Runtime: "hermes",
140+
Skills: []string{"ethereum-networks", "addresses"},
141+
Endpoint: "http://hermes.agent-demo-quant.svc.cluster.local:8642",
142+
},
143+
},
144+
}
145+
146+
route, err := routeRuleFromOffer(offer, "")
147+
if err != nil {
148+
t.Fatalf("routeRuleFromOffer: %v", err)
149+
}
150+
if route.Price != "10" {
151+
t.Errorf("Price = %q, want 10", route.Price)
152+
}
153+
if route.AgentModel != "qwen3.5:9b" {
154+
t.Errorf("AgentModel = %q, want qwen3.5:9b", route.AgentModel)
155+
}
156+
if route.AgentRuntime != "hermes" {
157+
t.Errorf("AgentRuntime = %q, want hermes", route.AgentRuntime)
158+
}
159+
if len(route.AgentSkills) != 2 || route.AgentSkills[0] != "ethereum-networks" {
160+
t.Errorf("AgentSkills = %v", route.AgentSkills)
161+
}
162+
if route.UpstreamURL != "http://hermes.agent-demo-quant.svc.cluster.local:8642" {
163+
t.Errorf("UpstreamURL = %q", route.UpstreamURL)
164+
}
165+
if route.Pattern != "/services/demo-quant/*" {
166+
t.Errorf("Pattern = %q, want /services/demo-quant/*", route.Pattern)
167+
}
168+
}
169+
118170
func mustOfferObject(t *testing.T, offer monetizeapi.ServiceOffer) *unstructured.Unstructured {
119171
t.Helper()
120172
offer.TypeMeta = metav1.TypeMeta{

0 commit comments

Comments
 (0)