Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions docs/MANAGED-KEY.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,19 +174,28 @@ and, once spent, return **`402 Payment Required`**. Add a `credit` block:
"allow": ["/v1/messages", "/v1/calls", "/v1/calls/{id}", "/v1/numbers"],
"credit": {
"seed_credits": 5000000,
"default_cost": 1000,
"cost_credits": { "/v1/numbers": 3000000, "/v1/calls": 50000, "/v1/messages": 10000 }
"default_cost": 0,
"cost_credits": {
"POST /v1/numbers": 3000000,
"POST /v1/calls": 50000,
"POST /v1/messages": 10000
}
}
}]
```

- **Unit is micro-dollars** (1 = $0.000001), so `5000000` = **$5**. Match the
`cost_credits` to the partner's real prices (here: $3 to buy a number, $0.05 a
call, $0.01 a text); any allowed path not listed costs `default_cost`.
call, $0.01 a text); any call not matched costs `default_cost`.
- **`default_cost: 0` makes reads free** — only the priced calls debit, so polling
for status/replies never burns budget.
- **Cost keys can be method-specific** (`"POST /v1/numbers"`) or any-method
(`"/v1/usage"`), and the path may be templated (`"/v1/calls/{id}"`). This matters
when one path is both a free read and a paid write — `GET /v1/numbers` (list,
free) vs `POST /v1/numbers` (buy, $3). A method-specific key wins over any-method.
- **How it works:** on a caller's first call the broker seeds `seed_credits`,
then debits the path's cost **before** touching the master key. A call that
would overdraw is refused with `402` (the master key is never used). Costs
support templated paths (`/v1/calls/{id}`), matched like the allow-list.
then debits the call's cost **before** touching the master key. A call that
would overdraw is refused with `402` (the master key is never used).
- **Only successful (2xx) calls burn credit** — a failed/`4xx`/`5xx` call is
refunded, so users pay for value, not errors.
- **Every metered response carries `X-Pilot-Credits-Remaining`** (micro-dollars),
Expand Down
2 changes: 1 addition & 1 deletion internal/broker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "provision: " + err.Error()})
return
}
creditCost = app.costForPath(mpath)
creditCost = app.costForCall(r.Method, mpath)
admittedCredit, remaining, derr := ps.Debit(appID, string(caller), creditCost)
if derr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "debit: " + derr.Error()})
Expand Down
73 changes: 55 additions & 18 deletions internal/broker/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,32 +107,65 @@ const (
// "/v1/calls/{id}" allowed, matched the same way as the allow-list); a path not
// listed costs DefaultCost.
type CreditSpec struct {
SeedCredits int `json:"seed_credits"` // per-caller starting balance in micro-$ (e.g. 5000000 = $5)
CostCredits map[string]int `json:"cost_credits"` // method-path → micro-$ debited per successful call
DefaultCost int `json:"default_cost"` // debit for paths not in CostCredits (default 1)
SeedCredits int `json:"seed_credits"` // per-caller starting balance in micro-$ (e.g. 5000000 = $5)
// CostCredits maps a method-path to the micro-$ debited per successful call.
// A key may be method-specific ("POST /v1/numbers") or any-method ("/v1/usage");
// paths may be templated ("/v1/calls/{id}"). This matters when one path serves
// a free read and a paid write — e.g. GET /v1/numbers (list, free) vs
// POST /v1/numbers (buy, $3). Method-specific keys win over any-method keys.
CostCredits map[string]int `json:"cost_credits"`
DefaultCost int `json:"default_cost"` // debit for calls not matched above (default 1)
}

// costPattern is a templated cost key split into segments (like allowPatterns).
// costPattern is a templated cost key split into segments (like allowPatterns),
// optionally scoped to one HTTP method ("" = any method).
type costPattern struct {
segs []string
cost int
method string
segs []string
cost int
}

// creditEnabled reports whether this app meters a per-caller micro-dollar budget.
func (a *AppEntry) creditEnabled() bool { return a.creditSeed > 0 }

// costForPath returns the micro-dollar cost of a call to path: an exact cost
// entry wins, else the first matching templated pattern, else DefaultCost.
func (a *AppEntry) costForPath(path string) int {
if c, ok := a.creditExact[path]; ok {
// costKey is the exact-map key for a (method, path) cost entry ("" method = any).
func costKey(method, path string) string { return method + " " + path }

// parseCostKey splits a cost_credits key into (method, path): "POST /v1/x" →
// ("POST","/v1/x"); a bare "/v1/x" (leading slash) → ("","/v1/x") = any method.
func parseCostKey(k string) (method, path string) {
if i := strings.IndexByte(k, ' '); i > 0 && k[0] != '/' {
return k[:i], k[i+1:]
}
return "", k
}

// costForCall returns the micro-$ cost of a METHOD call to path. Resolution order:
// method-specific exact → any-method exact → method-specific pattern → any-method
// pattern → DefaultCost.
func (a *AppEntry) costForCall(method, path string) int {
if c, ok := a.creditExact[costKey(method, path)]; ok {
return c
}
if c, ok := a.creditExact[costKey("", path)]; ok {
return c
}
if len(a.creditPatterns) > 0 {
segs := strings.Split(path, "/")
anyCost, anyOK := 0, false
for _, p := range a.creditPatterns {
if segmentsMatch(p.segs, segs) {
return p.cost
if !segmentsMatch(p.segs, segs) {
continue
}
if p.method == method {
return p.cost // method-specific wins immediately
}
if p.method == "" && !anyOK {
anyCost, anyOK = p.cost, true
}
}
if anyOK {
return anyCost
}
}
return a.creditDefault
Expand Down Expand Up @@ -254,17 +287,21 @@ func ParseRegistry(raw []byte, getenv func(string) string) (*Registry, error) {
return nil, fmt.Errorf("registry: app %s: `credit` (HTTP budget) and `provision` (cloud) are mutually exclusive", a.ID)
}
a.creditSeed = a.Credit.SeedCredits
// default_cost 0 is meaningful: unlisted paths are FREE (e.g. reads),
// so only explicitly-priced method-paths debit. A negative value is a
// typo — clamp to 0.
a.creditDefault = a.Credit.DefaultCost
if a.creditDefault <= 0 {
a.creditDefault = defaultCostCredits
if a.creditDefault < 0 {
a.creditDefault = 0
}
a.creditExact = map[string]int{}
a.creditPatterns = nil
for p, c := range a.Credit.CostCredits {
if strings.Contains(p, "{") {
a.creditPatterns = append(a.creditPatterns, costPattern{segs: strings.Split(p, "/"), cost: c})
for k, c := range a.Credit.CostCredits {
method, path := parseCostKey(k)
if strings.Contains(path, "{") {
a.creditPatterns = append(a.creditPatterns, costPattern{method: method, segs: strings.Split(path, "/"), cost: c})
} else {
a.creditExact[p] = c
a.creditExact[costKey(method, path)] = c
}
}
}
Expand Down
41 changes: 41 additions & 0 deletions internal/broker/zz_credit_gate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,44 @@ func TestCreditGate_MutuallyExclusiveWithProvision(t *testing.T) {
t.Fatal("expected credit+provision to be rejected")
}
}

// TestCreditGate_MethodSpecificCost: one path can be a free read AND a paid write
// (GET /v1/numbers list = free, POST /v1/numbers buy = $3). A method-scoped cost
// key prices only the write; the GET is not charged.
func TestCreditGate_MethodSpecificCost(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
t.Cleanup(up.Close)
reg, err := ParseRegistry([]byte(`[{
"id":"io.pilot.test","upstream":"`+up.URL+`","key_env":"TEST_KEY",
"auth_header":"Authorization","auth_scheme":"Bearer","allow":["/v1/numbers"],
"credit":{"seed_credits":5000000,"default_cost":0,"cost_credits":{"POST /v1/numbers":3000000}}
}]`), func(string) string { return "MASTERKEY" })
if err != nil {
t.Fatalf("ParseRegistry: %v", err)
}
b := New(reg, NewMemStore())
b.Verify = VerifyConfig{Now: fixedClock(now)}
_, priv := newKey(t)

do := func(method string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
b.ServeHTTP(rec, signedReq(t, priv, method, "/io.pilot.test/v1/numbers", []byte(`{}`), now))
return rec
}
// GET is a free read → balance unchanged at 5000000.
if rec := do("GET"); rec.Code != 200 || rec.Header().Get("X-Pilot-Credits-Remaining") != "5000000" {
t.Fatalf("GET /v1/numbers: %d remaining %q, want 200/5000000 (free read)", rec.Code, rec.Header().Get("X-Pilot-Credits-Remaining"))
}
// POST buys a number → debit $3 → remaining $2.
if rec := do("POST"); rec.Code != 200 || rec.Header().Get("X-Pilot-Credits-Remaining") != "2000000" {
t.Fatalf("POST /v1/numbers: %d remaining %q, want 200/2000000 ($3 debited)", rec.Code, rec.Header().Get("X-Pilot-Credits-Remaining"))
}
// A second $3 buy can't fit in the remaining $2 → 402.
if rec := do("POST"); rec.Code != http.StatusPaymentRequired {
t.Fatalf("second POST: %d, want 402", rec.Code)
}
}
Loading