mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
规则此前只能写在 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",于是被我误读成"判据漏放"。 改用可编译的变异写法后确认该变异确实被捕获。**判据报错先怀疑判据和工具。**
283 lines
8.8 KiB
Go
283 lines
8.8 KiB
Go
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
|
|
}
|