mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
按 issue #1 的修复方向(维护者评论):内核不再持有任何计费语义,billing 逻辑 全部在 `plugins/billing.lua`;内核只负责解 config.yaml、校验 schema、把 profile + 源 base_url 作为**数据**交给插件,再把插件算出的价目通过 `prices` 侧车注入。计费账目仍持久化在插件状态文件(`plugin_dir/.billing.state.json`)。 ## 变更 - **删除** `internal/billing`(compile.go 359 行 + 其 Go 测试); - 新增 `core.compileBillingPrices`:把 profile/sources JSON 化后调用插件导出 `compile_prices`,返回价目表 + 未匹配规则清单(供错误日志点名 URL); - `applyBillingDSL` / 规则编辑器改为走同一个插件编译入口(编辑器 lenient, 半成品规则可保存、可报告); - `billing.lua` 新增 `compile_prices`:URL 归一匹配、首条命中优先、 free/subscription/unpriced/token、USD/M→per-token、peak 窗口、 long_context 边际价、reasoning 可选价、default 策略、未匹配清单。 错误通过载荷 `error` 字段回传(插件导出第二返回值通道不可靠)。 ## 与 PR #2(valuelesser)合并后的语义 - **token 规则不产生任何源条目**:未列价的模型落到 unpriced 统计(可见), 不再"静默计 $0"。比 issue 正文里的 `{per_request:0}` 标记更收口; - `default: free` 由**编译期**给未匹配源写 `{per_request:0}` 标记; `unpriced`/缺省不写。删除无人读取的 `default_mode` 死键; - 保留 `unmatched_rules` 清单(只用于降级应用时点名过期 URL)。 ## 判据(原 Go 单测平移到 Lua + PR #2 的用例) - `internal/lua/billing_compile_test.go`:通配符不标记源、token 规则无源条目、 任意源条目不得携带 token 价(根因直防)、free/subscription 保留有意零价、 reasoning 缺席、long_context 三种形态、peak 形状、URL 归一+首条优先、 lenient 未匹配清单、硬错误 5 例、default 两态; - `internal/lua/billing_test.go`:编译输出驱动真实 priceFor 的 A/B 用例 (旧形状 cost=0 / 新形状 0.75 且 unpriced=0)、default 编译期语义 e2e; - `internal/gateway`:token 规则断言"无源条目"、模型价 per-token 量级; - `internal/core`:过期规则 URL 降级应用(有效规则仍生效)。
290 lines
9.2 KiB
Go
290 lines
9.2 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"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.
|
|
//
|
|
// The compilation itself runs in the BILLING PLUGIN (see
|
|
// core.compileBillingPrices): the kernel only decodes config.yaml and passes
|
|
// the profile + source URLs as data, so the "what does this rule mean"
|
|
// semantics live in exactly one place. Editor saves are lenient: a
|
|
// half-finished rule (URL typed before its source exists) must be saveable
|
|
// and reported, not rejected with an error the operator can only resolve by
|
|
// guessing.
|
|
prices, _, err := g.core.CompileBillingPrices(next.Resolve(next.Active), 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
|
|
}
|