From fc53cf0fa80dc5b6ef7154e0679b44ffda1e35cd Mon Sep 17 00:00:00 2001 From: Alex Godo Date: Mon, 6 Jul 2026 13:49:35 -0700 Subject: [PATCH 1/3] broker: response-cost credit mode + per-IP identity cap on the HTTP credit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two additive knobs to the managed-HTTP credit budget so a meta-API like orthogonal (one master key fronting 851 endpoints, per-call price only known from the response, some 'dynamic') can be metered true to real usage: - cost_source:"response" — no up-front debit; a billable path (cost_credits>0) is refused with 402 only when the caller is already depleted, and after a 2xx the ACTUAL cost (CostField × cost_scale, micro-$) is settled, clamped to floor the balance at zero. Free control-plane paths always pass. Adds Store.Settle (atomic clamp-debit) to MemStore + SQLiteStore. - max_identities_per_ip / mint_cooldown_ms on CreditSpec — wires the existing Provision IP cap into the credit path (previously passed 0), so a depleted user can't farm a fresh $5 grant by minting a new pilot identity from one IP. Fixed-cost apps (agentphone etc.) are unchanged. Tests cover settle-from-response, free control-plane, 402-on-depletion, overshoot-clamps-to-zero, failed-call-free, and the per-IP cap. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/broker/broker.go | 79 +++++++-- internal/broker/registry.go | 54 +++++- internal/broker/store.go | 24 +++ internal/broker/store_sqlite.go | 30 ++++ internal/broker/zz_credit_respcost_test.go | 191 +++++++++++++++++++++ 5 files changed, 357 insertions(+), 21 deletions(-) create mode 100644 internal/broker/zz_credit_respcost_test.go diff --git a/internal/broker/broker.go b/internal/broker/broker.go index 559cea2..d95440b 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "io" + "math" "net/http" "strconv" "strings" @@ -125,7 +126,8 @@ func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { // the master key is ever used. Only a successful (2xx) call keeps the debit; // a failed call is refunded below, so users are billed for value, not errors. var ps ProvisionStore - var creditCost int + var creditCost int // fixed mode: the amount pre-debited (refunded on failure) + var billable bool // response mode: this method-path costs money (CostCredits > 0) if app.creditEnabled() { var ok bool if ps, ok = b.provStore(); !ok { @@ -133,26 +135,60 @@ func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } ip := clientIP(r.Header.Get, r.RemoteAddr, b.IPTrust) - if _, err := ps.Provision(appID, string(caller), ip, app.creditSeed, 0, 0, b.now()); err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "provision: " + err.Error()}) + // Seed the caller on first sight, enforcing the per-IP identity cap: once a + // caller depletes their budget (402 below) they can't farm a fresh $5 grant by + // minting a new pilot identity from the same IP — the (N+1)th distinct caller + // on that IP is refused here with 429. + if _, err := ps.Provision(appID, string(caller), ip, app.creditSeed, app.creditMaxPerIP, app.creditMintCooldown, b.now()); err != nil { + switch err { + case ErrIPCap: + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "per-IP identity cap reached — too many pilot identities have claimed a budget from this network"}) + case ErrCooldown: + writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "re-provision cooldown — retry shortly"}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "provision: " + err.Error()}) + } return } - 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()}) - return - } - if !admittedCredit { - w.Header().Set("X-Pilot-Credits-Remaining", strconv.Itoa(remaining)) - writeJSON(w, http.StatusPaymentRequired, map[string]any{ - "error": "insufficient credit — per-user budget exhausted", "credits_remaining": remaining, "credits_required": creditCost, - }) - return + if app.creditRespCost { + // RESPONSE-COST mode: no up-front debit — the true cost isn't known until + // the partner replies. A billable path (CostCredits > 0) is only refused + // when the caller is already depleted; free/unlisted paths always pass. + // The actual cost is settled after a 2xx (see below). + billable = app.costForCall(r.Method, mpath) > 0 + if billable { + bal, derr := ps.Credit(appID, string(caller)) + if derr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "credit: " + derr.Error()}) + return + } + if bal <= 0 { + w.Header().Set("X-Pilot-Credits-Remaining", strconv.Itoa(bal)) + writeJSON(w, http.StatusPaymentRequired, map[string]any{ + "error": "insufficient credit — per-user budget exhausted", "credits_remaining": bal, + }) + return + } + } + } else { + // FIXED mode: pre-flight reservation of a known per-call cost. + 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()}) + return + } + if !admittedCredit { + w.Header().Set("X-Pilot-Credits-Remaining", strconv.Itoa(remaining)) + writeJSON(w, http.StatusPaymentRequired, map[string]any{ + "error": "insufficient credit — per-user budget exhausted", "credits_remaining": remaining, "credits_required": creditCost, + }) + return + } } } - // refundCredit returns this call's debit (used on any non-success) so failures - // never burn budget. + // refundCredit returns this call's fixed-mode debit (used on any non-success) so + // failures never burn budget. Response mode does no up-front debit, so it's a no-op. refundCredit := func() { if app.creditEnabled() && creditCost > 0 { ps.Refund(appID, string(caller), creditCost) @@ -197,6 +233,15 @@ func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { refundCredit() } else if c := extractCost(rb, app.CostField); c > 0 { b.Store.AddCost(appID, string(caller), c) + // Response-cost mode: settle the ACTUAL cost the partner reported against the + // caller's micro-dollar budget (clamped so the balance floors at zero). This is + // what makes metering true to real usage for a meta-API whose per-call price is + // only known from the response and can be "dynamic". + if app.creditEnabled() && app.creditRespCost && billable { + if micros := int(math.Round(c * float64(app.creditCostScale))); micros > 0 { + _, _ = ps.Settle(appID, string(caller), micros) + } + } } // Surface the caller's remaining budget on every metered response. diff --git a/internal/broker/registry.go b/internal/broker/registry.go index 46bceb4..0d0403a 100644 --- a/internal/broker/registry.go +++ b/internal/broker/registry.go @@ -47,10 +47,14 @@ type AppEntry struct { allowPatterns [][]string // templated allow entries split on "/" ("{x}" matches any one segment) breaker *Breaker - creditSeed int // Credit.SeedCredits (0 ⇒ no budget) - creditDefault int // Credit.DefaultCost (micro-$ per call for un-listed paths) - creditExact map[string]int // exact method-path → micro-$ cost - creditPatterns []costPattern // templated method-path → micro-$ cost + creditSeed int // Credit.SeedCredits (0 ⇒ no budget) + creditDefault int // Credit.DefaultCost (micro-$ per call for un-listed paths) + creditExact map[string]int // exact method-path → micro-$ cost + creditPatterns []costPattern // templated method-path → micro-$ cost + creditMaxPerIP int // Credit.MaxIdentitiesPerIP (0 ⇒ unlimited): distinct callers seedable per source IP + creditMintCooldown time.Duration // Credit.MintCooldownMs: per-caller re-seed touch cooldown (0 ⇒ none) + creditRespCost bool // Credit.CostSource == "response": debit actual cost from the response, not a fixed pre-debit + creditCostScale int // Credit.CostScale: micro-$ per unit of CostField (response mode; default 1) deriveSecret []byte // provisioned: HMAC secret (current version) resolved from Provision.SecretEnv secretsByVersion map[byte][]byte // provisioned: {version: secret} accepted during a rotation grace window @@ -115,6 +119,38 @@ type CreditSpec struct { // 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) + + // MaxIdentitiesPerIP caps how many DISTINCT callers may be seeded a fresh budget + // from one source IP (0 = unlimited). This is the anti-Sybil guard that makes the + // free per-user seed safe: once a caller depletes their budget they get 402, and + // they cannot farm a new $5 grant by minting a fresh pilot identity from the same + // machine — the (N+1)th distinct caller on an IP is refused (429) at first sight. + // Source IP is read only from the broker's trusted proxy header (never client + // X-Forwarded-For). Same enforcement as ProvisionSpec.MaxIdentitiesPerIP, applied + // to the plain-HTTP credit path. + MaxIdentitiesPerIP int `json:"max_identities_per_ip"` + // MintCooldownMs is a per-caller re-touch cooldown in ms (0 = none); mirrors + // ProvisionSpec.MintCooldownMs to slow rapid re-provision churn. + MintCooldownMs int `json:"mint_cooldown_ms"` + + // CostSource selects how the per-call debit is computed: + // "" → FIXED (default): debit CostCredits[method-path] up front, refund + // on a non-2xx (the classic pre-flight reservation). + // "response" → RESPONSE-COST: the true cost is only known from the partner + // response (e.g. a meta-API that returns the price of the sub-call + // it dispatched, incl. "dynamic" endpoints priced only after the + // fact). No up-front debit; instead, a call whose CostCredits entry + // is > 0 is treated as BILLABLE and is refused with 402 when the + // caller's balance is already <= 0 (free/unlisted paths always pass), + // and after a 2xx the ACTUAL cost read from the response (AppEntry + // CostField × CostScale, in micro-$) is settled against the budget, + // clamped so the balance floors at zero. This keeps metering true to + // real usage when per-endpoint prices can't be tabulated in advance. + CostSource string `json:"cost_source"` + // CostScale converts one unit of the response CostField into micro-dollars + // (response mode only). E.g. a CostField reporting whole cents → 10000 + // (1¢ = $0.01 = 10000 micro-$); a field reporting dollars → 1000000. Default 1. + CostScale int `json:"cost_scale"` } // costPattern is a templated cost key split into segments (like allowPatterns), @@ -294,6 +330,16 @@ func ParseRegistry(raw []byte, getenv func(string) string) (*Registry, error) { if a.creditDefault < 0 { a.creditDefault = 0 } + a.creditMaxPerIP = a.Credit.MaxIdentitiesPerIP + if a.creditMaxPerIP < 0 { + a.creditMaxPerIP = 0 + } + a.creditMintCooldown = time.Duration(a.Credit.MintCooldownMs) * time.Millisecond + a.creditRespCost = a.Credit.CostSource == "response" + a.creditCostScale = a.Credit.CostScale + if a.creditCostScale <= 0 { + a.creditCostScale = 1 + } a.creditExact = map[string]int{} a.creditPatterns = nil for k, c := range a.Credit.CostCredits { diff --git a/internal/broker/store.go b/internal/broker/store.go index d4f7357..803cefb 100644 --- a/internal/broker/store.go +++ b/internal/broker/store.go @@ -52,6 +52,14 @@ type ProvisionStore interface { // what was debited in practice (callers only refund what they reserved). Refund(app, caller string, n int) + // Settle debits n credits AFTER the fact, clamped to the available balance so + // it never rejects and never drives the balance below zero. Unlike Debit (a + // pre-flight reservation that fails when funds are short), Settle is used by the + // response-cost credit path where the true cost is only known from the partner + // response: the call already ran, so the debit must always apply. It returns the + // remaining balance. n <= 0 and an unprovisioned caller are no-ops. + Settle(app, caller string, n int) (remaining int, err error) + // Get returns the caller's provision record (for reading the current rotation // counter + credit). exists is false for an unprovisioned caller. Get(app, caller string) (rec ProvisionRecord, exists bool, err error) @@ -169,6 +177,22 @@ func (s *MemStore) Refund(app, caller string, n int) { } } +func (s *MemStore) Settle(app, caller string, n int) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + rec := s.prov[key(app, caller)] + if rec == nil { + return 0, nil + } + if n > 0 { + if n > rec.Credits { + n = rec.Credits // clamp: floor the balance at zero + } + rec.Credits -= n + } + return rec.Credits, nil +} + func (s *MemStore) Get(app, caller string) (ProvisionRecord, bool, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/broker/store_sqlite.go b/internal/broker/store_sqlite.go index 6aac075..4145a69 100644 --- a/internal/broker/store_sqlite.go +++ b/internal/broker/store_sqlite.go @@ -214,6 +214,36 @@ func (s *SQLiteStore) Refund(app, caller string, n int) { _, _ = s.db.Exec(`UPDATE provision SET credits=credits+? WHERE app=? AND caller=?`, n, app, caller) } +// Settle debits n post-hoc, clamped to the balance (never below zero, never +// rejects). Serialized by the single-conn pool like the other ledger ops. +func (s *SQLiteStore) Settle(app, caller string, n int) (int, error) { + tx, err := s.db.Begin() + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback() }() + var credits int + err = tx.QueryRow(`SELECT credits FROM provision WHERE app=? AND caller=?`, app, caller).Scan(&credits) + if err == sql.ErrNoRows { + return 0, nil + } + if err != nil { + return 0, err + } + if n > credits { + n = credits // clamp to floor at zero + } + if n > 0 { + if _, err := tx.Exec(`UPDATE provision SET credits=credits-? WHERE app=? AND caller=?`, n, app, caller); err != nil { + return credits, err + } + } + if err := tx.Commit(); err != nil { + return credits, err + } + return credits - n, nil +} + func (s *SQLiteStore) Get(app, caller string) (ProvisionRecord, bool, error) { var ip string var firstSeen, lastMint int64 diff --git a/internal/broker/zz_credit_respcost_test.go b/internal/broker/zz_credit_respcost_test.go new file mode 100644 index 0000000..53e361e --- /dev/null +++ b/internal/broker/zz_credit_respcost_test.go @@ -0,0 +1,191 @@ +package broker + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" +) + +// respCostBroker builds a broker whose one app meters by RESPONSE cost: only the +// billable path (POST /v1/run) costs money, and the amount is read from the +// upstream response `priceCents` (real cents) × cost_scale (10000 micro-$ / cent). +// The upstream echoes a fixed priceCents so tests can assert the settled debit. +func respCostBroker(t *testing.T, priceCents, upStatus, seed int) (*Broker, *int) { + t.Helper() + var hits int + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + hits++ + w.WriteHeader(upStatus) + _, _ = w.Write([]byte(`{"success":true,"priceCents":` + strconv.Itoa(priceCents) + `,"data":{"ok":true}}`)) + })) + t.Cleanup(up.Close) + reg, err := ParseRegistry([]byte(`[{ + "id":"io.pilot.orthogonal","upstream":"`+up.URL+`","key_env":"ORTH_KEY", + "auth_header":"Authorization","auth_scheme":"Bearer", + "cost_field":"priceCents", + "allow":["/v1/search","/v1/run"], + "credit":{ + "seed_credits":`+strconv.Itoa(seed)+`,"default_cost":0, + "cost_source":"response","cost_scale":10000, + "cost_credits":{"POST /v1/run":1} + } + }]`), func(string) string { return "MASTERKEY" }) + if err != nil { + t.Fatalf("ParseRegistry: %v", err) + } + b := New(reg, NewMemStore()) + b.Verify = VerifyConfig{Now: fixedClock(time.Unix(1_800_000_000, 0))} + return b, &hits +} + +// TestRespCost_SettlesActualFromResponse: a $5 budget, a run whose response says +// priceCents:1 ($0.01), debits exactly 10000 micro-$ — the real cost, not a guess. +func TestRespCost_SettlesActualFromResponse(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + b, hits := respCostBroker(t, 1, 200, 5_000_000) + _, priv := newKey(t) + + rec := httptest.NewRecorder() + b.ServeHTTP(rec, signedReq(t, priv, "POST", "/io.pilot.orthogonal/v1/run", []byte(`{"api":"olostep","path":"/v1/scrapes"}`), now)) + if rec.Code != 200 { + t.Fatalf("run: status %d, want 200 (body %s)", rec.Code, rec.Body.String()) + } + if *hits != 1 { + t.Fatalf("upstream hits = %d, want 1", *hits) + } + if got := rec.Header().Get("X-Pilot-Credits-Remaining"); got != "4990000" { + t.Fatalf("remaining = %q, want 4990000 ($5 − $0.01)", got) + } +} + +// TestRespCost_FreeControlPlaneAlwaysPasses: an unlisted-cost path (/v1/search, +// default_cost 0) is free — never debited, and still allowed after the budget is +// depleted (so a broke user can still discover/price endpoints). +func TestRespCost_FreeControlPlaneAlwaysPasses(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + b, _ := respCostBroker(t, 1, 200, 10_000) // exactly one $0.01 run of budget + _, priv := newKey(t) + + // Free search does not debit the budget. + s1 := httptest.NewRecorder() + b.ServeHTTP(s1, signedReq(t, priv, "POST", "/io.pilot.orthogonal/v1/search", []byte(`{"prompt":"find email"}`), now)) + if s1.Code != 200 || s1.Header().Get("X-Pilot-Credits-Remaining") != "10000" { + t.Fatalf("free /v1/search: %d remaining %q, want 200/10000 (unchanged)", s1.Code, s1.Header().Get("X-Pilot-Credits-Remaining")) + } + // Deplete via a billable run. + run := httptest.NewRecorder() + b.ServeHTTP(run, signedReq(t, priv, "POST", "/io.pilot.orthogonal/v1/run", []byte(`{}`), now)) + if run.Code != 200 || run.Header().Get("X-Pilot-Credits-Remaining") != "0" { + t.Fatalf("run: %d remaining %q, want 200/0", run.Code, run.Header().Get("X-Pilot-Credits-Remaining")) + } + // Search still works at zero balance. + s2 := httptest.NewRecorder() + b.ServeHTTP(s2, signedReq(t, priv, "POST", "/io.pilot.orthogonal/v1/search", []byte(`{"prompt":"more"}`), now)) + if s2.Code != 200 { + t.Fatalf("free /v1/search at zero balance: status %d, want 200", s2.Code) + } +} + +// TestRespCost_402WhenDepleted: once the budget hits zero, a billable run is +// refused with 402 before the master key is ever used. +func TestRespCost_402WhenDepleted(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + // seed exactly one $0.01 call; the second must 402. + b, hits := respCostBroker(t, 1, 200, 10_000) + _, priv := newKey(t) + + first := httptest.NewRecorder() + b.ServeHTTP(first, signedReq(t, priv, "POST", "/io.pilot.orthogonal/v1/run", []byte(`{}`), now)) + if first.Code != 200 || first.Header().Get("X-Pilot-Credits-Remaining") != "0" { + t.Fatalf("first run: %d remaining %q, want 200/0", first.Code, first.Header().Get("X-Pilot-Credits-Remaining")) + } + before := *hits + second := httptest.NewRecorder() + b.ServeHTTP(second, signedReq(t, priv, "POST", "/io.pilot.orthogonal/v1/run", []byte(`{}`), now)) + if second.Code != http.StatusPaymentRequired { + t.Fatalf("depleted run: status %d, want 402", second.Code) + } + if *hits != before { + t.Fatal("402 run still reached the upstream / master key") + } +} + +// TestRespCost_OvershootClampsToZero: a single call costing more than the balance +// is allowed (price unknown up front) but the debit clamps the balance to zero — +// never negative — and the next billable call 402s. +func TestRespCost_OvershootClampsToZero(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + // balance $0.005 (5000 micro-$), a $0.01 call (10000 micro-$) overshoots. + b, _ := respCostBroker(t, 1, 200, 5_000) + _, priv := newKey(t) + + rec := httptest.NewRecorder() + b.ServeHTTP(rec, signedReq(t, priv, "POST", "/io.pilot.orthogonal/v1/run", []byte(`{}`), now)) + if rec.Code != 200 || rec.Header().Get("X-Pilot-Credits-Remaining") != "0" { + t.Fatalf("overshoot: %d remaining %q, want 200/0 (clamped, not negative)", rec.Code, rec.Header().Get("X-Pilot-Credits-Remaining")) + } + next := httptest.NewRecorder() + b.ServeHTTP(next, signedReq(t, priv, "POST", "/io.pilot.orthogonal/v1/run", []byte(`{}`), now)) + if next.Code != http.StatusPaymentRequired { + t.Fatalf("after overshoot: status %d, want 402", next.Code) + } +} + +// TestRespCost_FailedCallIsFree: a non-2xx run burns no budget (no up-front debit +// in response mode, nothing settled on failure). +func TestRespCost_FailedCallIsFree(t *testing.T) { + now := time.Unix(1_800_000_000, 0) + b, hits := respCostBroker(t, 10, 500, 5_000_000) + _, priv := newKey(t) + + rec := httptest.NewRecorder() + b.ServeHTTP(rec, signedReq(t, priv, "POST", "/io.pilot.orthogonal/v1/run", []byte(`{}`), now)) + if rec.Code != 500 { + t.Fatalf("status %d, want 500 relayed", rec.Code) + } + if *hits != 1 { + t.Fatal("upstream should have been hit once") + } + if got := rec.Header().Get("X-Pilot-Credits-Remaining"); got != "5000000" { + t.Fatalf("failed run remaining = %q, want 5000000 (unbilled)", got) + } +} + +// TestCreditPath_PerIPIdentityCap: with max_identities_per_ip=1, a second DISTINCT +// pilot identity from the same source IP cannot claim a fresh budget — this is the +// anti-Sybil guard that stops farming new $5 grants after depletion. Both callers +// share httptest's default RemoteAddr (192.0.2.1). +func TestCreditPath_PerIPIdentityCap(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.orthogonal","upstream":"`+up.URL+`","key_env":"ORTH_KEY", + "auth_header":"Authorization","auth_scheme":"Bearer","allow":["/v1/search"], + "credit":{"seed_credits":5000000,"default_cost":0,"max_identities_per_ip":1} + }]`), func(string) string { return "MASTERKEY" }) + if err != nil { + t.Fatalf("ParseRegistry: %v", err) + } + b := New(reg, NewMemStore()) + b.Verify = VerifyConfig{Now: fixedClock(now)} + + _, priv1 := newKey(t) + rec1 := httptest.NewRecorder() + b.ServeHTTP(rec1, signedReq(t, priv1, "POST", "/io.pilot.orthogonal/v1/search", []byte(`{}`), now)) + if rec1.Code != 200 { + t.Fatalf("first identity: status %d, want 200", rec1.Code) + } + // A different key = a different pilot identity from the same IP → capped. + _, priv2 := newKey(t) + rec2 := httptest.NewRecorder() + b.ServeHTTP(rec2, signedReq(t, priv2, "POST", "/io.pilot.orthogonal/v1/search", []byte(`{}`), now)) + if rec2.Code != http.StatusTooManyRequests { + t.Fatalf("second identity same IP: status %d, want 429 (IP cap)", rec2.Code) + } +} From 0d556c11f5b5baff583fdccdd52c11cd90a6db30 Mon Sep 17 00:00:00 2001 From: Alex Godo Date: Mon, 6 Jul 2026 14:21:41 -0700 Subject: [PATCH 2/3] =?UTF-8?q?submit=20io.pilot.orthogonal=20v0.1.0=20?= =?UTF-8?q?=E2=80=94=20managed=20meta-API,=20$5/user=20budget,=20response-?= =?UTF-8?q?cost=20metering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rich managed submission (backend.auth=managed, 9 methods, vendor+listing). Wraps Orthogonal's 58 APIs / 851 endpoints behind the managed-key broker: NL router (orthogonal.search) + discover→price→execute; only orthogonal.run bills, metered true-to-usage against a per-user $5 budget with a per-IP identity cap. Bundles published to R2 via the catalogue PR (not committed here). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../io.pilot.orthogonal/submission.json | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 submissions/io.pilot.orthogonal/submission.json diff --git a/submissions/io.pilot.orthogonal/submission.json b/submissions/io.pilot.orthogonal/submission.json new file mode 100644 index 0000000..3f2c72a --- /dev/null +++ b/submissions/io.pilot.orthogonal/submission.json @@ -0,0 +1,123 @@ +{ + "id": "io.pilot.orthogonal", + "version": "0.1.0", + "namespace": "orthogonal", + "description": "Connect your agent to Orthogonal — one key fronting 58 paid APIs / 851 endpoints (lead & contact enrichment, email & phone finding, web & social scraping, AI search, company/people/jobs data, weather, voice). Describe a task in plain English and Orthogonal's router returns the exact APIs to call; execute any of them through a single run, billed at the real per-call price and metered against a per-user $5 budget. Discovery, pricing and balance calls are free.", + "email": "apps@pilotprotocol.network", + "backend": { + "base_url": "https://api.orthogonal.com", + "auth": "managed", + "quota": 0, + "headers": [ + { "name": "Authorization", "value": "Bearer managed" } + ] + }, + "methods": [ + { + "name": "orthogonal.search", + "description": "★ Natural-language API router. Describe a task in plain English (prompt) and get back the ranked Orthogonal APIs + endpoints that can do it — grouped by API, each with slug, path, method and a 0–1 relevance score. FREE. Start here when you don't know which of the 851 endpoints to use, then price it with orthogonal.details and execute with orthogonal.run.", + "latency": "med", + "http": { "verb": "POST", "path": "/v1/search" }, + "params": [ + { "name": "prompt", "type": "string", "required": true, "in": "body", "description": "the task, e.g. 'find the work email for a person given name + company'" }, + { "name": "limit", "type": "int", "required": false, "in": "body", "description": "number of ranked results (default 10, max 50)" } + ] + }, + { + "name": "orthogonal.details", + "description": "Full request schema (path/query/body params with types + required flags) AND the exact price in dollars for one endpoint. FREE. Call this before orthogonal.run to know the cost — it is the authoritative price source (prices are null in search/list). Price may be the string 'dynamic' for endpoints priced only after the call.", + "latency": "fast", + "http": { "verb": "POST", "path": "/v1/details" }, + "params": [ + { "name": "api", "type": "string", "required": true, "in": "body", "description": "API slug from search/list, e.g. 'olostep'" }, + { "name": "path", "type": "string", "required": true, "in": "body", "description": "endpoint path, e.g. '/v1/scrapes'" } + ] + }, + { + "name": "orthogonal.integrate", + "description": "Ready-to-paste code snippets for one endpoint. FREE. format ∈ orth-sdk (default) | run-api | curl | x402-fetch | x402-python | all.", + "latency": "fast", + "http": { "verb": "POST", "path": "/v1/integrate" }, + "params": [ + { "name": "api", "type": "string", "required": true, "in": "body", "description": "API slug" }, + { "name": "path", "type": "string", "required": true, "in": "body", "description": "endpoint path" }, + { "name": "format", "type": "string", "required": false, "in": "body", "description": "snippet format (default 'orth-sdk')" } + ] + }, + { + "name": "orthogonal.list", + "description": "Browse the whole catalog — 58 APIs / 851 endpoints with descriptions and param schemas, paginated by limit/offset. FREE. Prices are null here; use orthogonal.details for the price of a specific endpoint.", + "latency": "med", + "http": { "verb": "GET", "path": "/v1/list-endpoints" }, + "params": [ + { "name": "limit", "type": "int", "required": false, "in": "query", "description": "page size (default 100, max 500)" }, + { "name": "offset", "type": "int", "required": false, "in": "query", "description": "page offset (default 0)" } + ] + }, + { + "name": "orthogonal.run", + "description": "★ Execute any of the 851 provider endpoints via a JSON payload {api, path, body?, query?} (the HTTP method is chosen automatically; body is the provider request body, query is its query-string params). THIS IS THE ONLY CALL THAT COSTS MONEY: you are billed the target endpoint's real price and it is debited from your $5 Pilot budget. The response returns priceCents (cents actually charged) alongside the provider data, and X-Pilot-Credits-Remaining shows your budget. Once your $5 is spent, run returns 402 while the free discovery calls keep working. Prices range $0.001–$3.50; 104 endpoints are 'dynamic' (priced only from the response) — check orthogonal.details first when you need the cost up front.", + "latency": "slow", + "timeout": "120s", + "http": { "verb": "POST", "path": "/v1/run" }, + "params": [ + { "name": "api", "type": "string", "required": true, "in": "body", "description": "API slug, e.g. 'serper', 'apollo', 'olostep'" }, + { "name": "path", "type": "string", "required": true, "in": "body", "description": "endpoint path, e.g. '/v1/people/match'" } + ] + }, + { + "name": "orthogonal.balance", + "description": "Orthogonal account credit balance (string '$X.XX'). FREE, read-only. Note: your per-user $5 Pilot budget is metered separately by the broker and is surfaced in the X-Pilot-Credits-Remaining header on every call — that header, not this account balance, is your personal remaining budget.", + "latency": "fast", + "http": { "verb": "GET", "path": "/v1/credits/balance" }, + "params": [] + }, + { + "name": "orthogonal.check", + "description": "Preflight the shared account: does it hold ≥ amountCents credits? (amountCents is in Orthogonal credit units = dollars × 100000, so $5 = 500000). FREE.", + "latency": "fast", + "http": { "verb": "POST", "path": "/v1/credits/check" }, + "params": [ + { "name": "amountCents", "type": "int", "required": true, "in": "body", "description": "dollars × 100000 (e.g. 500000 = $5)" } + ] + }, + { + "name": "orthogonal.transactions", + "description": "Account transaction ledger — purchases, API charges, bonuses, refunds — with amounts, direction and timestamps, paginated. FREE, read-only. Amounts are in the credits unit (dollars × 100000).", + "latency": "fast", + "http": { "verb": "GET", "path": "/v1/credits/transactions" }, + "params": [ + { "name": "limit", "type": "int", "required": false, "in": "query", "description": "page size (default 50)" }, + { "name": "offset", "type": "int", "required": false, "in": "query", "description": "page offset (default 0)" } + ] + }, + { + "name": "orthogonal.usage", + "description": "Per-call spend history plus totalSpent over a window (dollar strings), for the shared account. FREE, read-only.", + "latency": "fast", + "http": { "verb": "GET", "path": "/v1/credits/usage" }, + "params": [ + { "name": "days", "type": "int", "required": false, "in": "query", "description": "window size in days (default 30)" }, + { "name": "limit", "type": "int", "required": false, "in": "query", "description": "page size (default 50)" }, + { "name": "offset", "type": "int", "required": false, "in": "query", "description": "page offset (default 0)" } + ] + } + ], + "listing": { + "display_name": "Orthogonal", + "tagline": "One key, 851 paid APIs — described in English, metered per user", + "app_description": "# Orthogonal — a catalog of paid tools and APIs, for your agent\n\nOrthogonal is an **API marketplace / meta-API**: a single key fronts **58 third-party APIs across 851 endpoints** — lead & contact enrichment, work-email and phone finding, web & social scraping, AI web search, company / people / jobs data, weather, voice/phone, email inboxes, and more. You never sign up for the underlying providers and you never juggle their keys — you describe what you need, and pay Orthogonal per call. This Pilot app wraps Orthogonal's control plane behind the managed-key broker, so **your agent gets one metered, keyless surface** and a **per-user $5 budget**.\n\n## The workflow: discover → price → execute\n\n1. **Discover — `orthogonal.search`** (the natural-language router ★). Describe the task in plain English, e.g. *\"find the work email and phone for a person given their name and company\"*, and get back the ranked APIs and endpoints that can do it, grouped by API with a relevance score. This is the \"which API do I need?\" endpoint — you don't have to know the catalog.\n2. **Price — `orthogonal.details`.** Pass an `{api, path}` and get the full request schema (every path/query/body param, with types and required flags) **and the exact price in dollars**. This is the authoritative price source — search and list return `null` prices.\n3. **Execute — `orthogonal.run`.** Call `{api, path, body?, query?}` and Orthogonal dispatches to the provider, returns the provider's data, and reports the exact `priceCents` charged. This is the **only call that costs money**.\n\n`orthogonal.integrate` and `orthogonal.list` round out discovery (code snippets and full-catalog browse), and `orthogonal.balance` / `.usage` / `.transactions` / `.check` expose the account ledger — all free.\n\n## What you can do (representative)\n\n- **Contact & lead enrichment** — apollo, contactout, company-enrich, peopledatalabs, coresignal, aviato, crustdata, ocean-io, influencers-club.\n- **Work-email & phone finding** — tomba, icypeas, contactout, company-enrich, hunter.\n- **Web & AI search** — serper, linkup, tavily, exa/perplexity.\n- **Web & social scraping** — olostep, serper-scrape, scrapecreators (107 endpoints), scrapegraphai, fiber (92 endpoints).\n- **Company / firmographic / jobs data** — predictleads, fantastic-jobs, openfunnel, edges, context-dev, brand-dev.\n- **Weather, voice/phone, email inboxes** — precip, agentphone, agentmail.\n\nRun `orthogonal.search` (or `orthogonal.list`) for the live, complete set.\n\n## Pricing — how you're billed (true to real usage)\n\n- **Only `orthogonal.run` costs money.** Every discovery, pricing, and account call is **free**.\n- Each run is billed the **target endpoint's real price**, debited from your **$5 per-user budget** in micro-dollars. The `priceCents` in the run response is the exact amount charged (real cents); `X-Pilot-Credits-Remaining` on every response is your remaining budget in micro-dollars ($5 = 5000000).\n- Prices span **$0.001 – $3.50**. Distribution across the 851 endpoints: **11 free, ~612 fixed-price, 104 \"dynamic\"** (priced only after the call). Common tiers: Basic $0.001–0.01, Standard $0.01–0.10, Premium $0.10–1.00. Cheap endpoints to start with: serper ($0.002), olostep / tomba / linkup ($0.01).\n- For a **known** cost up front, call `orthogonal.details` first. For **\"dynamic\"** endpoints the price is only knowable from the run response — so metering is done on the actual charged amount, and a single call may spend the last of your budget; after that, `orthogonal.run` returns **402** (the free discovery calls keep working).\n\n## Per-user budget & fair use\n\nEach Pilot user is seeded **$5 of credit** on first use, metered by the broker against their signed pilot identity. When the budget is exhausted, billable runs return 402. To keep the shared master account fair, the broker also enforces a **per-IP identity cap**: a small number of distinct pilot identities may claim a fresh $5 from any one network, so a depleted user can't farm new budgets by minting new identities. The Orthogonal account itself is the ultimate backstop — if it runs dry, runs return 402 until it's topped up.\n\n## Good to know\n\n- **Auth is fully managed.** The `orth_live_` master key lives only in the broker; the installed adapter is keyless and signs each request with your pilot identity. Nothing to configure.\n- **The account is shared** across Pilot users (no per-user sub-accounts on Orthogonal), so `orthogonal.balance` / `.usage` / `.transactions` report the **account-wide** figures — your **personal** remaining budget is the `X-Pilot-Credits-Remaining` header.\n- Errors surface verbatim: 402 insufficient credit, 404 unknown api/path, 5xx upstream provider error, 429 rate-limited (back off).\n- `orthogonal.help` is the self-describing discovery contract: it lists every method with params, cost note, and latency class.", + "categories": ["data", "search", "enrichment", "scraping", "ai"], + "keywords": ["orthogonal", "api-marketplace", "enrichment", "lead-enrichment", "email-finder", "scraping", "web-search", "people-data", "company-data", "meta-api", "natural-language"], + "license": "MIT", + "homepage": "https://orthogonal.com", + "source_url": "https://github.com/pilot-protocol/app-template/tree/main/submissions/io.pilot.orthogonal" + }, + "vendor": { + "name": "Orthogonal", + "url": "https://orthogonal.com", + "contact": "apps@pilotprotocol.network", + "agent_usage": "Managed-key app: the broker holds one orth_live_ master key and injects it per request; each pilot user is metered a $5 budget in micro-dollars. Only POST /v1/run bills (debited the real priceCents from the response, incl. 'dynamic' endpoints); all discovery/pricing/account endpoints are free. Depleted users get 402; a per-IP identity cap (5) blocks budget farming.", + "capabilities": "One key fronting 58 third-party APIs / 851 endpoints: lead & contact enrichment, work-email/phone finding, web & social scraping, AI search, company/people/jobs data, weather, voice, email inboxes. Natural-language endpoint router (orthogonal.search) picks the right APIs for a task." + } +} From 53fd84a1162b301b39e0c4fa84bc6b2c0fc23b81 Mon Sep 17 00:00:00 2001 From: Alex Godo Date: Mon, 6 Jul 2026 14:30:13 -0700 Subject: [PATCH 3/3] broker: gofmt (comment alignment) --- internal/broker/broker.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/broker/broker.go b/internal/broker/broker.go index d95440b..545ffa5 100644 --- a/internal/broker/broker.go +++ b/internal/broker/broker.go @@ -126,8 +126,8 @@ func (b *Broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { // the master key is ever used. Only a successful (2xx) call keeps the debit; // a failed call is refunded below, so users are billed for value, not errors. var ps ProvisionStore - var creditCost int // fixed mode: the amount pre-debited (refunded on failure) - var billable bool // response mode: this method-path costs money (CostCredits > 0) + var creditCost int // fixed mode: the amount pre-debited (refunded on failure) + var billable bool // response mode: this method-path costs money (CostCredits > 0) if app.creditEnabled() { var ok bool if ps, ok = b.provStore(); !ok {