mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-05 07:02:29 +00:00
feat(billing): 计费规则可在线增删改,真实落盘并注入插件
规则此前只能写在 config.yaml 里,重启才生效。现在 admin 可通过 API 增删改, 规则写回同一份 config.yaml、重新编译、注入 billing 插件,立即生效。 ## 为什么单独开一套端点 GET/POST/PUT/DELETE /api/plugins/billing/rules 价格虽然存在插件 state 里,但它是**可评审的配置**,不是用户数据。走通用 /state 会让任意 admin key 顺手把累计账目一起重置。这里服务端掌管形状: 校验 → 落盘 → 重编译 → 注入,运维只编辑规则,插件只存数字,两者永不在同一 个 payload 里混。 "运维输入什么,config.yaml 就存什么"是刻意的:下周发现的计费错误,必须能 追溯到一个可评审的文件,而不是插件 sidecar 里的一坨 blob。 ## 路由必须前置拦截 /api/plugins/billing/rules 会被 /api/plugins/ 通配路由吞掉并 404,必须在 handlePluginsAPI 之前判断。 ## 两个真实缺陷(判据抓到的,不是想出来的) 1. **保存顺序反了**:先 cfg.Save() 再赋值 cfg.BillingDSL,于是每次编辑都 "成功",写回的配置里却没有 billing 段——运维的编辑在重启后消失,而 API 响应里什么异常都没有。现在先赋值、先编译(编译失败则整体回滚,不留半应用 状态)、最后落盘。 2. **GET /state 不返回生效价格**:编辑器无法显示当前真正在用的价目表,只能从 配置重建——而配置可能早已与实际漂移。新增 Plugins.Prices(),编辑页显示的 就是计费真正在用的那张表。 ## 宽松编译 Compile 启动时对"URL 匹配不到任何 source"直接报错是对的(静默不计费更糟)。 但编辑器要允许存草稿:正在新建的源、正在改的 URL 不该把人堵死。新增 CompileOpts(lenient):宽松模式下该规则**留在配置里**(可评审、可恢复), 只是不进编译结果,并由 API 返回 warning 明确报出来。 ## 判据(5 条 + 9 个变异) CRUD、同 URL 重复添加必须替换而非追加(两条规则会因"先匹配先生效"而让第一条 静默失效)、未知 URL 必须警告、非法规则被拒且不落盘、admin 限制、YAML 往返 不丢价格字符串、注入的价格必须是每 token 量级(防 per-million/per-token 差 1e6 倍)。 变异验证抓出判据两处无效断言:删掉落盘、删掉注入,GET 响应都照样回显内存里 的规则,判据全绿。补了「重读磁盘配置」和「读插件实际生效价格」两条才抓住。 过程中还发现一个测试工具自身的坑:某个变异改法导致 Go 编译失败 (declared and not used),grep 匹配不到 "--- FAIL",于是被我误读成"判据漏放"。 改用可编译的变异写法后确认该变异确实被捕获。**判据报错先怀疑判据和工具。**
This commit is contained in:
@ -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 {
|
||||
|
||||
282
internal/gateway/billing_rules_api.go
Normal file
282
internal/gateway/billing_rules_api.go
Normal file
@ -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
|
||||
}
|
||||
281
internal/gateway/billing_rules_api_test.go
Normal file
281
internal/gateway/billing_rules_api_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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.
|
||||
|
||||
Reference in New Issue
Block a user