66 "strings"
77 "testing"
88
9+ "github.com/ObolNetwork/obol-stack/internal/agentruntime"
910 "github.com/ObolNetwork/obol-stack/internal/buy"
11+ "github.com/ObolNetwork/obol-stack/internal/schemas"
1012)
1113
1214func TestBudgetToBaseUnits (t * testing.T ) {
@@ -59,6 +61,61 @@ func TestBudgetToBaseUnits(t *testing.T) {
5961 }
6062}
6163
64+ func TestResolveBudgetEnforcesExplicitCap (t * testing.T ) {
65+ t .Parallel ()
66+
67+ pricePerAuth := big .NewInt (1000 )
68+ tests := []struct {
69+ name string
70+ flag string
71+ authCount int
72+ want * big.Int
73+ wantErr string
74+ }{
75+ {
76+ name : "no explicit budget returns exact signed spend" ,
77+ authCount : 5 ,
78+ want : big .NewInt (5000 ),
79+ },
80+ {
81+ name : "explicit budget equal to signed spend passes" ,
82+ flag : "0.005" ,
83+ authCount : 5 ,
84+ want : big .NewInt (5000 ),
85+ },
86+ {
87+ name : "explicit budget above signed spend still returns exact signed spend" ,
88+ flag : "0.01" ,
89+ authCount : 5 ,
90+ want : big .NewInt (5000 ),
91+ },
92+ {
93+ name : "explicit budget below count cost errors" ,
94+ flag : "0.004" ,
95+ authCount : 5 ,
96+ wantErr : "below the requested pre-authorization cost" ,
97+ },
98+ }
99+ for _ , tc := range tests {
100+ t .Run (tc .name , func (t * testing.T ) {
101+ t .Parallel ()
102+ got , err := resolveBudget (tc .flag , "USDC" , tc .authCount , pricePerAuth )
103+ if tc .wantErr != "" {
104+ if err == nil || ! strings .Contains (err .Error (), tc .wantErr ) {
105+ t .Fatalf ("resolveBudget err = %v, want substring %q" , err , tc .wantErr )
106+ }
107+ return
108+ }
109+ if err != nil {
110+ t .Fatalf ("resolveBudget unexpected err: %v" , err )
111+ }
112+ if got .Cmp (tc .want ) != 0 {
113+ t .Fatalf ("resolveBudget = %s, want %s" , got , tc .want )
114+ }
115+ })
116+ }
117+ }
118+
62119func TestBuildBuyPyArgv (t * testing.T ) {
63120 tests := []struct {
64121 name string
@@ -72,15 +129,15 @@ func TestBuildBuyPyArgv(t *testing.T) {
72129 Seller : "https://demo.example/services/x" ,
73130 BudgetMicro : "10000000" ,
74131 },
75- want : []string {
76- hermesPython , hermesBuyPyPath , "buy" , "default-paid" ,
132+ want : append (buy .BuyPyCommand (agentruntime .Hermes , "buy" , "default-paid" ),
77133 "--endpoint" , "https://demo.example/services/x" ,
78134 "--budget" , "10000000" ,
79- } ,
135+ ) ,
80136 },
81137 {
82138 name : "all optional flags" ,
83139 opts : buyPyOptions {
140+ Runtime : agentruntime .Hermes ,
84141 Name : "demo" ,
85142 Seller : "https://s.example" ,
86143 Model : "qwen3.5:9b" ,
@@ -92,8 +149,7 @@ func TestBuildBuyPyArgv(t *testing.T) {
92149 SetDefault : true ,
93150 Force : true ,
94151 },
95- want : []string {
96- hermesPython , hermesBuyPyPath , "buy" , "demo" ,
152+ want : append (buy .BuyPyCommand (agentruntime .Hermes , "buy" , "demo" ),
97153 "--endpoint" , "https://s.example" ,
98154 "--budget" , "5000000" ,
99155 "--model" , "qwen3.5:9b" ,
@@ -103,22 +159,20 @@ func TestBuildBuyPyArgv(t *testing.T) {
103159 "--cost-cap" , "150000" ,
104160 "--set-default" ,
105161 "--force" ,
106- } ,
162+ ) ,
107163 },
108164 {
109- name : "cost cap without auto-refill is still emitted (host owns intent) " ,
165+ name : "cost cap without auto-refill is suppressed " ,
110166 opts : buyPyOptions {
111167 Name : "demo" ,
112168 Seller : "https://s.example" ,
113169 BudgetMicro : "1000000" ,
114170 CostCap : big .NewInt (42 ),
115171 },
116- want : []string {
117- hermesPython , hermesBuyPyPath , "buy" , "demo" ,
172+ want : append (buy .BuyPyCommand (agentruntime .Hermes , "buy" , "demo" ),
118173 "--endpoint" , "https://s.example" ,
119174 "--budget" , "1000000" ,
120- "--cost-cap" , "42" ,
121- },
175+ ),
122176 },
123177 {
124178 name : "cost cap of zero is suppressed" ,
@@ -128,11 +182,10 @@ func TestBuildBuyPyArgv(t *testing.T) {
128182 BudgetMicro : "1000000" ,
129183 CostCap : big .NewInt (0 ),
130184 },
131- want : []string {
132- hermesPython , hermesBuyPyPath , "buy" , "demo" ,
185+ want : append (buy .BuyPyCommand (agentruntime .Hermes , "buy" , "demo" ),
133186 "--endpoint" , "https://s.example" ,
134187 "--budget" , "1000000" ,
135- } ,
188+ ) ,
136189 },
137190 {
138191 name : "explicit count emits --count" ,
@@ -142,12 +195,11 @@ func TestBuildBuyPyArgv(t *testing.T) {
142195 BudgetMicro : "5000000000000000000" ,
143196 Count : 5000 ,
144197 },
145- want : []string {
146- hermesPython , hermesBuyPyPath , "buy" , "demo" ,
198+ want : append (buy .BuyPyCommand (agentruntime .Hermes , "buy" , "demo" ),
147199 "--endpoint" , "https://s.example" ,
148200 "--budget" , "5000000000000000000" ,
149201 "--count" , "5000" ,
150- } ,
202+ ) ,
151203 },
152204 {
153205 name : "auto-refill without explicit counts" ,
@@ -157,12 +209,11 @@ func TestBuildBuyPyArgv(t *testing.T) {
157209 BudgetMicro : "1000000" ,
158210 AutoRefill : true ,
159211 },
160- want : []string {
161- hermesPython , hermesBuyPyPath , "buy" , "demo" ,
212+ want : append (buy .BuyPyCommand (agentruntime .Hermes , "buy" , "demo" ),
162213 "--endpoint" , "https://s.example" ,
163214 "--budget" , "1000000" ,
164215 "--auto-refill" ,
165- } ,
216+ ) ,
166217 },
167218 {
168219 name : "auto-refill off does not emit refill flags" ,
@@ -174,11 +225,10 @@ func TestBuildBuyPyArgv(t *testing.T) {
174225 RefillThreshold : 3 ,
175226 RefillCount : 7 ,
176227 },
177- want : []string {
178- hermesPython , hermesBuyPyPath , "buy" , "demo" ,
228+ want : append (buy .BuyPyCommand (agentruntime .Hermes , "buy" , "demo" ),
179229 "--endpoint" , "https://s.example" ,
180230 "--budget" , "1000000" ,
181- } ,
231+ ) ,
182232 },
183233 {
184234 name : "model whitespace trimmed" ,
@@ -188,12 +238,24 @@ func TestBuildBuyPyArgv(t *testing.T) {
188238 Model : " qwen3.5:9b " ,
189239 BudgetMicro : "1000000" ,
190240 },
191- want : []string {
192- hermesPython , hermesBuyPyPath , "buy" , "demo" ,
241+ want : append (buy .BuyPyCommand (agentruntime .Hermes , "buy" , "demo" ),
193242 "--endpoint" , "https://s.example" ,
194243 "--budget" , "1000000" ,
195244 "--model" , "qwen3.5:9b" ,
245+ ),
246+ },
247+ {
248+ name : "openclaw runtime uses openclaw skill mount" ,
249+ opts : buyPyOptions {
250+ Runtime : agentruntime .OpenClaw ,
251+ Name : "demo" ,
252+ Seller : "https://s.example" ,
253+ BudgetMicro : "1000000" ,
196254 },
255+ want : append (buy .BuyPyCommand (agentruntime .OpenClaw , "buy" , "demo" ),
256+ "--endpoint" , "https://s.example" ,
257+ "--budget" , "1000000" ,
258+ ),
197259 },
198260 }
199261
@@ -407,7 +469,8 @@ func TestResolveBuyModel(t *testing.T) {
407469
408470// resolveCostCap: explicit flag wins (in atomic units OR human decimal),
409471// otherwise we apply the costCapMarkupBps markup over price when
410- // auto-refill is on. No auto-refill means no auto cap.
472+ // auto-refill is on. Explicit --cost-cap without auto-refill is rejected so
473+ // it cannot accidentally create an in-pod auto-refill policy.
411474func TestResolveCostCap (t * testing.T ) {
412475 t .Parallel ()
413476 price := big .NewInt (1000 )
@@ -424,8 +487,9 @@ func TestResolveCostCap(t *testing.T) {
424487 {name : "no flag + no auto-refill = nil" , autoRefill : false , price : price , wantNil : true },
425488 {name : "no flag + auto-refill = 150% markup" , autoRefill : true , price : price , want : big .NewInt (1500 )},
426489 {name : "atomic-unit flag wins" , flag : "42" , autoRefill : true , price : price , want : big .NewInt (42 )},
427- {name : "human-decimal USDC fallback" , flag : "0.001" , token : "USDC" , autoRefill : false , price : price , want : big .NewInt (1000 )},
428- {name : "invalid flag errors" , flag : "not-a-number" , token : "USDC" , autoRefill : false , price : price , wantErr : "not a valid number" },
490+ {name : "human-decimal USDC fallback" , flag : "0.001" , token : "USDC" , autoRefill : true , price : price , want : big .NewInt (1000 )},
491+ {name : "cost cap without auto-refill errors" , flag : "42" , token : "USDC" , autoRefill : false , price : price , wantErr : "requires --auto-refill" },
492+ {name : "invalid flag errors" , flag : "not-a-number" , token : "USDC" , autoRefill : true , price : price , wantErr : "not a valid number" },
429493 }
430494
431495 for _ , tc := range tests {
@@ -454,6 +518,70 @@ func TestResolveCostCap(t *testing.T) {
454518 }
455519}
456520
521+ func TestAuthCapAndCapacityLabel (t * testing.T ) {
522+ t .Parallel ()
523+
524+ permit2Entry := & buy.CatalogEntry {Asset : & schemas.ServiceCatalogAsset {TransferMethod : "permit2" }}
525+ eip3009Entry := & buy.CatalogEntry {Asset : & schemas.ServiceCatalogAsset {TransferMethod : "eip3009" }}
526+
527+ tests := []struct {
528+ name string
529+ entry * buy.CatalogEntry
530+ requested int
531+ wantAuths int
532+ wantCapped bool
533+ wantReason string
534+ wantLabel string
535+ }{
536+ {
537+ name : "permit2 perMTok can cap below one natural unit" ,
538+ entry : permit2Entry ,
539+ requested : defaultInteractivePerMTokCount * schemas .ApproxTokensPerRequest ,
540+ wantAuths : permit2SafeAuthCount ,
541+ wantCapped : true ,
542+ wantReason : "Permit2 storage limit" ,
543+ wantLabel : "0.5 million tokens" ,
544+ },
545+ {
546+ name : "non-permit2 still mirrors buy.py max auth count" ,
547+ entry : eip3009Entry ,
548+ requested : defaultInteractivePerMTokCount * schemas .ApproxTokensPerRequest ,
549+ wantAuths : maxBuyPyAuthCount ,
550+ wantCapped : true ,
551+ wantReason : "buy.py signing limit" ,
552+ wantLabel : "1 million tokens" ,
553+ },
554+ {
555+ name : "small request is not capped" ,
556+ entry : permit2Entry ,
557+ requested : 25 ,
558+ wantAuths : 25 ,
559+ wantCapped : false ,
560+ wantLabel : "25 requests" ,
561+ },
562+ }
563+
564+ for _ , tc := range tests {
565+ t .Run (tc .name , func (t * testing.T ) {
566+ t .Parallel ()
567+ got , capped , reason := applyAuthCap (tc .entry , tc .requested )
568+ if got != tc .wantAuths || capped != tc .wantCapped || reason != tc .wantReason {
569+ t .Fatalf ("applyAuthCap = (%d, %v, %q), want (%d, %v, %q)" ,
570+ got , capped , reason , tc .wantAuths , tc .wantCapped , tc .wantReason )
571+ }
572+ unit := "perRequest"
573+ multiplier := 1
574+ if strings .Contains (tc .wantLabel , "million tokens" ) {
575+ unit = "perMTok"
576+ multiplier = schemas .ApproxTokensPerRequest
577+ }
578+ if label := capacityLabel (got , multiplier , unit ); label != tc .wantLabel {
579+ t .Fatalf ("capacityLabel = %q, want %q" , label , tc .wantLabel )
580+ }
581+ })
582+ }
583+ }
584+
457585// looksLikeURL keeps the positional URL detection in sync with the error
458586// message we surface when users pass a name-shaped positional.
459587func TestLooksLikeURL (t * testing.T ) {
0 commit comments