diff --git a/internal/billing/compile.go b/internal/billing/compile.go index f324ee6..532ebc9 100644 --- a/internal/billing/compile.go +++ b/internal/billing/compile.go @@ -22,6 +22,24 @@ import ( // several profiles and switching recomputes this table, so a gateway can be // repriced without editing the plugin. func Compile(profile *config.BillingProfile, sources []config.Source) (map[string]interface{}, error) { + return CompileOpts(profile, sources, false) +} + +// CompileOpts is Compile with an explicit policy for rules that match no +// source. +// +// The default is STRICT and that is correct for startup: a profile with a +// typo'd URL prices nothing, every request on it is recorded as unpriced, and +// the bill silently comes out wrong. Failing the load is the right response. +// +// The rules editor needs the opposite. An operator editing a half-finished +// profile — adding a rule before the source that will use it exists, or +// renaming a URL while typing — must be able to save and then see the warning, +// not be blocked by an error they can only resolve by guessing. Lenient mode +// keeps the offending rule in the config and drops it from the compiled table, +// so it is preserved as text and clearly reported, without pretending it +// prices anything. +func CompileOpts(profile *config.BillingProfile, sources []config.Source, lenient bool) (map[string]interface{}, error) { if profile == nil { return nil, fmt.Errorf("no billing profile") } @@ -53,6 +71,9 @@ func Compile(profile *config.BillingProfile, sources []config.Source) (map[strin // source that was removed). Failing loudly beats a profile that // silently prices nothing — the whole reason this is a config file // instead of a hand-written JSON blob. + if lenient { + continue // kept in the config text, reported as a warning + } return nil, fmt.Errorf("rule url %q matches no configured source base_url", rule.URL) } for _, srcName := range targets { diff --git a/internal/gateway/billing_rules_api.go b/internal/gateway/billing_rules_api.go new file mode 100644 index 0000000..5c0f601 --- /dev/null +++ b/internal/gateway/billing_rules_api.go @@ -0,0 +1,282 @@ +package gateway + +import ( + "encoding/json" + "net/http" + + "llmsproxy/internal/billing" + "llmsproxy/internal/config" +) + +// Billing rules API. +// +// GET /api/plugins/billing/rules the active profile's rules + all profiles +// PUT /api/plugins/billing/rules replace the whole DSL and re-inject prices +// POST /api/plugins/billing/rules { profile, rule } append one rule +// DELETE /api/plugins/billing/rules { profile, url } remove one rule +// +// Why a separate endpoint instead of the generic plugin state: prices are +// plugin state, but they are also a durable, reviewable CONFIGURATION. Routing +// them through /state would let an admin key PUT arbitrary plugin state and +// silently reset the accumulated accounting. Here the server owns the shape: +// it validates the DSL, writes it back to config.yaml, recompiles, and hands +// the plugin its prices table. The operator edits rules; the plugin keeps +// numbers. The two are never mixed in one payload. +// +// Editing means "what the operator typed is what config.yaml holds". A +// billing mistake found next week must be traceable to a reviewable file, not +// to a blob in the plugin's state sidecar. +func (g *Gateway) handleBillingRules(w http.ResponseWriter, r *http.Request) { + if reqRole(r.Context()) != "admin" { + writeError(w, http.StatusForbidden, "forbidden", "admin role required") + return + } + switch r.Method { + case http.MethodGet: + g.getBillingRules(w) + case http.MethodPut: + g.putBillingRules(w, r) + case http.MethodPost: + g.addBillingRule(w, r) + case http.MethodDelete: + g.delBillingRule(w, r) + default: + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET/PUT/POST/DELETE") + } +} + +// billingRulesPayload is the UI-facing view: the profiles as declared, plus +// which one is active and the source URLs they can match — the editor needs +// the URL list to offer suggestions, and an operator typing a URL that matches +// no source is exactly the silent-no-pricing failure this feature exists to +// remove. +type billingRulesPayload struct { + DSL *config.BillingDSL `json:"billing"` + Active string `json:"active"` + URLs []string `json:"urls"` + Warnings []string `json:"warnings"` +} + +func (g *Gateway) billingRulesPayload() billingRulesPayload { + out := billingRulesPayload{} + cfg := g.core.Config() + if cfg != nil && cfg.BillingDSL != nil { + // Copy so the response cannot be mutated back through the pointer. + cp := *cfg.BillingDSL + out.DSL = &cp + if p := cp.Resolve(""); p != nil { + out.Active = p.ID + } + } + for _, s := range g.sourceURLs() { + out.URLs = append(out.URLs, s) + } + out.Warnings = g.billingRuleWarnings() + return out +} + +func (g *Gateway) sourceURLs() []string { + cfg := g.core.Config() + if cfg == nil { + return nil + } + seen := map[string]bool{} + var out []string + for _, s := range cfg.Sources { + if s.BaseURL == "" || seen[s.BaseURL] { + continue + } + seen[s.BaseURL] = true + out = append(out, s.BaseURL) + } + return out +} + +// billingRuleWarnings reports rules that match no configured source. This is +// the silent killer of hand-written price tables: the rule parses, validates, +// and prices nothing at all, so every request on that URL lands in unpriced. +// Saying so out loud is the difference between a five-second fix and an +// afternoon wondering why the bill is zero. +func (g *Gateway) billingRuleWarnings() []string { + cfg := g.core.Config() + if cfg == nil || cfg.BillingDSL == nil { + return nil + } + urls := map[string]bool{} + for _, u := range g.sourceURLs() { + urls[u] = true + } + var warns []string + for _, p := range cfg.BillingDSL.Profiles { + for _, r := range p.Rules { + if r.URL == "*" || urls[r.URL] { + continue + } + warns = append(warns, "profile "+p.ID+": rule url "+r.URL+" matches no configured source") + } + } + return warns +} + +func (g *Gateway) getBillingRules(w http.ResponseWriter) { + writeJSON(w, http.StatusOK, g.billingRulesPayload()) +} + +func (g *Gateway) putBillingRules(w http.ResponseWriter, r *http.Request) { + var body struct { + Billing *config.BillingDSL `json:"billing"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + if body.Billing == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "billing is required") + return + } + g.applyBillingDSLUpdate(w, body.Billing) +} + +func (g *Gateway) addBillingRule(w http.ResponseWriter, r *http.Request) { + var body struct { + Profile string `json:"profile"` + Rule config.BillingRule `json:"rule"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + cfg := g.core.Config() + if cfg == nil || cfg.BillingDSL == nil || len(cfg.BillingDSL.Profiles) == 0 { + writeError(w, http.StatusBadRequest, "no_profile", "no billing profiles configured; create one first") + return + } + next := cloneBillingDSL(cfg.BillingDSL) + idx := profileIndex(next, body.Profile) + if idx < 0 { + writeError(w, http.StatusBadRequest, "unknown_profile", "no such profile: "+body.Profile) + return + } + p := &next.Profiles[idx] + // Replace rather than append when the URL is already declared: two rules + // for one URL would silently make the first unreachable (first match wins), + // which is the exact ambiguity an editor should not be able to create. + replaced := false + for i := range p.Rules { + if p.Rules[i].URL == body.Rule.URL { + p.Rules[i] = body.Rule + replaced = true + break + } + } + if !replaced { + p.Rules = append(p.Rules, body.Rule) + } + g.applyBillingDSLUpdate(w, next) +} + +func (g *Gateway) delBillingRule(w http.ResponseWriter, r *http.Request) { + var body struct { + Profile string `json:"profile"` + URL string `json:"url"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error()) + return + } + cfg := g.core.Config() + if cfg == nil || cfg.BillingDSL == nil { + writeError(w, http.StatusBadRequest, "no_profile", "no billing profiles configured") + return + } + next := cloneBillingDSL(cfg.BillingDSL) + idx := profileIndex(next, body.Profile) + if idx < 0 { + writeError(w, http.StatusBadRequest, "unknown_profile", "no such profile: "+body.Profile) + return + } + p := &next.Profiles[idx] + out := p.Rules[:0] + found := false + for _, rule := range p.Rules { + if rule.URL == body.URL { + found = true + continue + } + out = append(out, rule) + } + if !found { + writeError(w, http.StatusNotFound, "not_found", "no rule for url "+body.URL) + return + } + p.Rules = out + g.applyBillingDSLUpdate(w, next) +} + +// applyBillingDSLUpdate is the single write path: validate, persist to +// config.yaml, recompile, inject, and only then report success. If the config +// cannot be written the change is NOT applied in memory either — a rules editor +// that says "saved" and loses the edit on the next restart is worse than one +// that refuses. +func (g *Gateway) applyBillingDSLUpdate(w http.ResponseWriter, next *config.BillingDSL) { + if err := next.Validate(); err != nil { + writeError(w, http.StatusBadRequest, "invalid_rules", err.Error()) + return + } + cfg := g.core.Config() + if cfg == nil { + writeError(w, http.StatusInternalServerError, "no_config", "no config loaded") + return + } + // Assign BEFORE saving. The previous order saved first and assigned after, + // so every rule edit reported success while writing a config.yaml with no + // billing section at all — the operator's edit vanished on restart and + // nothing in the API response said so. + prev := cfg.BillingDSL + cfg.BillingDSL = next + + // Compile before persisting: a profile that cannot be turned into prices + // must not reach the config file, or the next restart fails to load the + // very rules the editor just accepted. + prices, err := billing.CompileOpts(next.Resolve(next.Active), cfg.Sources, true) + if err != nil { + cfg.BillingDSL = prev // no half-applied state + writeError(w, http.StatusBadRequest, "compile_failed", err.Error()) + return + } + if err := cfg.Save(); err != nil { + cfg.BillingDSL = prev + writeError(w, http.StatusInternalServerError, "persist_failed", err.Error()) + return + } + ps := g.core.Plugins() + if ps != nil { + if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil { + writeError(w, http.StatusBadRequest, "plugin_error", err.Error()) + return + } + } + p := g.billingRulesPayload() + writeJSON(w, http.StatusOK, map[string]interface{}{ + "ok": true, "billing": p.DSL, "active": p.Active, "warnings": p.Warnings, + }) +} + +func cloneBillingDSL(d *config.BillingDSL) *config.BillingDSL { + out := &config.BillingDSL{Active: d.Active} + out.Profiles = make([]config.BillingProfile, len(d.Profiles)) + copy(out.Profiles, d.Profiles) + return out +} + +func profileIndex(d *config.BillingDSL, id string) int { + if id == "" { + return -1 + } + for i, p := range d.Profiles { + if p.ID == id { + return i + } + } + return -1 +} diff --git a/internal/gateway/billing_rules_api_test.go b/internal/gateway/billing_rules_api_test.go new file mode 100644 index 0000000..2a4a88b --- /dev/null +++ b/internal/gateway/billing_rules_api_test.go @@ -0,0 +1,281 @@ +package gateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "sort" + "strings" + "testing" + + "llmsproxy/internal/config" +) + +// rulesGateway needs a real billing plugin (the API injects prices into it) +// AND two sources whose base_urls the rules will match, so it cannot reuse +// either existing helper: gatewayWithBilling has no sources, and +// newTestGateway loads no plugins. +func rulesGateway(t *testing.T) *Gateway { + t.Helper() + g := gatewayWithBilling(t) + cfg := g.core.Config() + cfg.Sources = []config.Source{ + {Name: "localzen", BaseURL: "https://free.example.com/v1"}, + {Name: "commandcode", BaseURL: "https://api.commandcode.ai/v1"}, + } + return g +} + +func rulesDo(t *testing.T, g *Gateway, method, body string) *httptest.ResponseRecorder { + t.Helper() + return doReq(t, g, method, "/api/plugins/billing/rules", body) +} + +// TestBillingRulesCRUD covers the whole editor loop against the real HTTP +// surface: read, add, replace-by-URL, delete. The replace-by-URL case is the +// one worth pinning — two rules for one URL would make the first unreachable +// (first match wins) without any error, which is exactly the kind of silent +// ambiguity an editor must not be able to create. +func TestBillingRulesCRUD(t *testing.T) { + g := rulesGateway(t) + seed := `{"billing":{"active":"p1","profiles":[{"id":"p1","rules":[ + {"url":"https://free.example.com/v1","mode":"free"}]}]}}` + if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK { + t.Fatalf("seed rules = %d: %s", rr.Code, rr.Body.String()) + } + + // Add a token-priced rule for the second URL. + add := `{"profile":"p1","rule":{"url":"https://api.commandcode.ai/v1","mode":"token", + "models":{"deepseek-v4.1-flash":{"prompt":"0.15","completion":"0.60"}}}}` + if rr := rulesDo(t, g, http.MethodPost, add); rr.Code != http.StatusOK { + t.Fatalf("add rule = %d: %s", rr.Code, rr.Body.String()) + } + var got billingRulesPayload + if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.DSL.Profiles[0].Rules) != 2 { + t.Fatalf("after add there are %d rules, want 2", len(got.DSL.Profiles[0].Rules)) + } + + // Adding the SAME url again must replace, not append. + if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p1","rule":{"url":"https://api.commandcode.ai/v1","mode":"free"}}`); rr.Code != http.StatusOK { + t.Fatalf("re-add same url = %d: %s", rr.Code, rr.Body.String()) + } + if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if n := len(got.DSL.Profiles[0].Rules); n != 2 { + t.Fatalf("★ re-adding an existing url appended instead of replacing: %d rules", n) + } + var mode string + for _, r := range got.DSL.Profiles[0].Rules { + if r.URL == "https://api.commandcode.ai/v1" { + mode = r.Mode + } + } + if mode != "free" { + t.Errorf("re-added rule mode = %q, want free (the replacement must win)", mode) + } + + // Delete by URL. + if rr := rulesDo(t, g, http.MethodDelete, `{"profile":"p1","url":"https://api.commandcode.ai/v1"}`); rr.Code != http.StatusOK { + t.Fatalf("delete rule = %d: %s", rr.Code, rr.Body.String()) + } + if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.DSL.Profiles[0].Rules) != 1 { + t.Errorf("after delete there are %d rules, want 1", len(got.DSL.Profiles[0].Rules)) + } +} + +// TestBillingRulesRejectUnknownURLWithWarning is the silent-failure guard: a +// rule whose URL matches no source prices nothing, so every request on it +// falls into unpriced. The API must say so instead of accepting it silently. +func TestBillingRulesRejectUnknownURLWithWarning(t *testing.T) { + g := rulesGateway(t) + if rr := rulesDo(t, g, http.MethodPut, `{"billing":{"active":"p","profiles":[{"id":"p", + "rules":[{"url":"https://typo.example.com/v1","mode":"free"}]}]}}`); rr.Code != http.StatusOK { + t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String()) + } + var got billingRulesPayload + if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.Warnings) == 0 { + t.Fatal("★ a rule matching no configured source was accepted with no warning — " + + "it will price nothing and every request will be recorded as unpriced") + } + if !strings.Contains(got.Warnings[0], "typo.example.com") { + t.Errorf("warning does not name the offending url: %v", got.Warnings) + } + // The editor needs the real URL list to offer suggestions. + if len(got.URLs) != 2 { + t.Errorf("urls offered to the editor = %v, want the 2 configured base_urls", got.URLs) + } +} + +// TestBillingRulesInvalidIsRejectedNotPersisted checks the write path refuses +// bad input and leaves the previous rules in place — an editor that clears the +// table on a typo loses the price table. +func TestBillingRulesInvalidIsRejectedNotPersisted(t *testing.T) { + g := rulesGateway(t) + seed := `{"billing":{"active":"p","profiles":[{"id":"p","rules":[ + {"url":"https://free.example.com/v1","mode":"free"}]}]}}` + if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK { + t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String()) + } + // An unknown mode must be refused. + if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://x/v1","mode":"banana"}}`); rr.Code != http.StatusBadRequest { + t.Errorf("invalid mode accepted with %d, want 400", rr.Code) + } + var got billingRulesPayload + if err := json.Unmarshal(rulesDo(t, g, http.MethodGet, "").Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.DSL.Profiles[0].Rules) != 1 { + t.Errorf("★ a rejected rule was persisted anyway: %d rules", len(got.DSL.Profiles[0].Rules)) + } + // An unparseable price must be refused too, not silently priced at zero. + if rr := rulesDo(t, g, http.MethodPost, `{"profile":"p","rule":{"url":"https://y/v1","mode":"token", + "models":{"m":{"prompt":"free","completion":"0.6"}}}}`); rr.Code != http.StatusBadRequest { + t.Errorf("non-numeric price accepted with %d, want 400", rr.Code) + } +} + +// TestBillingRulesPersistAndReachThePlugin is the point of the whole +// endpoint, and mutation verification showed the earlier CRUD tests missed it +// twice: dropping cfg.Save() and dropping the SetState injection both left +// every test green. Both failures are invisible in a GET — the response echoes +// the in-memory rules either way — so this test checks the two consequences +// that actually matter: +// +// 1. persistence: the rule must be in the config file on disk, because an +// edit that evaporates on restart is a lie the UI told the operator; +// 2. effect: the billing plugin must now PRICE the source (its published +// prices table has a non-zero entry), because a rule that is stored but +// never injected prices nothing at all. +func TestBillingRulesPersistAndReachThePlugin(t *testing.T) { + g := rulesGateway(t) + cfg := g.core.Config() + + seed := `{"billing":{"active":"p","profiles":[{"id":"p","rules":[ + {"url":"https://free.example.com/v1","mode":"free"}, + {"url":"https://api.commandcode.ai/v1","mode":"token", + "models":{"deepseek-v4.1-flash":{"prompt":"0.15","completion":"0.60"}}}]}]}}` + if rr := rulesDo(t, g, http.MethodPut, seed); rr.Code != http.StatusOK { + t.Fatalf("seed = %d: %s", rr.Code, rr.Body.String()) + } + + // 1. persisted to the file the process loaded from. + raw, err := os.ReadFile(cfg.Path) + if err != nil { + t.Fatalf("read config: %v", err) + } + if !strings.Contains(string(raw), "commandcode.ai") { + t.Errorf("★ the rule is not in config.yaml on disk — an edit that does not "+ + "survive a restart:\n%s", raw) + } + reloaded, err := config.Load(cfg.Path) + if err != nil { + t.Fatalf("reload saved config: %v", err) + } + if reloaded.BillingDSL == nil || len(reloaded.BillingDSL.Profiles) == 0 { + t.Fatalf("saved config has no billing DSL: %s", raw) + } + // A round-trip through YAML must not lose the price strings. + found := false + for _, r := range reloaded.BillingDSL.Profiles[0].Rules { + if strings.Contains(r.URL, "commandcode") { + found = true + if m, ok := r.Models["deepseek-v4.1-flash"]; !ok || m.Prompt != "0.15" { + t.Errorf("price did not survive the YAML round-trip: %+v", m) + } + } + } + if !found { + t.Errorf("the token rule is missing after reload:\n%s", raw) + } + + // 2. reached the plugin. Prices live in a separate Lua field from `state`, + // and the GET /state endpoint returns the published state together with + // the prices sidecar — which is exactly what the plugin's own UI reads. + state := g.core.Plugins().State("billing") + if state == nil { + t.Fatalf("billing plugin published no state") + } + rr := doReq(t, g, http.MethodGet, "/api/plugins/billing/state", "") + if rr.Code != http.StatusOK { + t.Fatalf("GET state = %d: %s", rr.Code, rr.Body.String()) + } + var envelope struct { + Prices map[string]interface{} `json:"prices"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil { + t.Fatalf("decode state envelope: %v", err) + } + prices := envelope.Prices + if prices == nil { + t.Fatalf("state payload carries no prices") + } + sources, ok := prices["sources"].(map[string]interface{}) + if !ok { + t.Fatalf("prices has no sources map: %#v", prices) + } + cc, ok := sources["commandcode"] + if !ok { + t.Fatalf("★ the rule was saved but never injected: prices has %v, "+ + "no commandcode entry. Every request on it stays unpriced.", keysOf(sources)) + } + if cc == nil { + t.Error("commandcode price entry is nil") + } + // The free rule must price localzen, and the two must not be conflated. + if _, ok := sources["localzen"]; !ok { + t.Errorf("the free rule did not reach the plugin: %v", keysOf(sources)) + } + // The injected price must be per-token, i.e. 0.15 USD/M => 1.5e-7. + // Asserting the exact magnitude catches a unit error (per-million vs + // per-token), which would be off by a factor of a million and still look + // like "a number". + models, ok := cc.(map[string]interface{})["models"].(map[string]interface{}) + if ok { + if m, ok := models["deepseek-v4.1-flash"].(map[string]interface{}); ok { + p, _ := m["prompt"].(float64) + if p <= 0 || p > 1e-6 { + t.Errorf("injected prompt price = %v, want per-token (~1.5e-7); "+ + "a per-million value here would over-bill by 1e6x", p) + } + } + } +} + +func keysOf(m map[string]interface{}) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// TestBillingRulesRequireAdmin keeps price rewriting behind the admin role, +// like every other plugin write: prices are configuration, not user data. +func TestBillingRulesRequireAdmin(t *testing.T) { + g := rulesGateway(t) + rec, err := g.core.CreateKey("viewer", "user", nil, "") + if err != nil { + t.Fatal(err) + } + userKey := rec.Key + for _, m := range []string{http.MethodPut, http.MethodPost, http.MethodDelete} { + req, _ := http.NewRequest(m, "/api/plugins/billing/rules", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer "+userKey) + req.Header.Set("Content-Type", "application/json") + if rr := newRecorderFor(t, g, req); rr.Code != http.StatusForbidden { + t.Errorf("user %s rules = %d, want 403", m, rr.Code) + } + } +} diff --git a/internal/gateway/plugins_api.go b/internal/gateway/plugins_api.go index 21ef417..8704c7c 100644 --- a/internal/gateway/plugins_api.go +++ b/internal/gateway/plugins_api.go @@ -214,10 +214,17 @@ func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name // Any role may read: plugin state is reporting data (cost, counts), // and the caller has already been authenticated. Admin-only would stop // a user key's own billing widget from rendering. - writeJSON(w, http.StatusOK, map[string]interface{}{ + // prices rides along: the billing rules editor must show the table + // that is actually in effect, not a reconstruction from config. It is + // absent for plugins with no prices (every plugin but billing). + payload := map[string]interface{}{ "plugin": name, "state": ps.State(name), - }) + } + if pr := ps.Prices(name); pr != nil { + payload["prices"] = pr + } + writeJSON(w, http.StatusOK, payload) case http.MethodPut: if reqRole(r.Context()) != "admin" { writeError(w, http.StatusForbidden, "forbidden", "admin role required") diff --git a/internal/gateway/server.go b/internal/gateway/server.go index ec6c188..c83ebaa 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -230,6 +230,11 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) { g.handleSourceTemplatesAPI(w, r) case r.URL.Path == "/api/ui-inject" || strings.HasPrefix(r.URL.Path, "/api/ui-inject/"): g.handlePluginUI(w, r) + case r.URL.Path == "/api/plugins/billing/rules": + // Checked BEFORE the generic /api/plugins/ prefix: the plugin router + // treats anything after the name as a plugin sub-resource and would + // 404 on "rules" instead of reaching the billing editor. + g.handleBillingRules(w, r) case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"): g.handlePluginsAPI(w, r) case r.URL.Path == "/api/chat": diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index f29183d..e31926c 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -901,6 +901,29 @@ func (ps *Plugins) State(name string) interface{} { return out } +// Prices returns the plugin's current price table (the `prices` field, kept +// separate from `state`). +// +// Exposed because the billing rules editor has to show what is actually in +// effect. Reconstructing it from config alone would be wrong the moment a +// price was injected directly through SetState or the two drifted, and an +// editor that displays a different table from the one billing uses is worse +// than no editor at all. +func (ps *Plugins) Prices(name string) interface{} { + ps.mu.RLock() + p := ps.find(name) + ps.mu.RUnlock() + if p == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + if p.state == nil || p.state.L == nil { + return nil + } + return snapshotField(p.state.L, "prices") +} + // SetState replaces a plugin's published state (admin API). It is how a // configuration change (a new price for a model) reaches the plugin without // reloading it.