mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
上一提交把链路打通后,reasoning_price 只能靠手改 prices 生效 —— 而 prices 是
编译产物,不是人能编辑的东西。这条补上让它真正可用:
- BillingToken 加 reasoning 字段(USD/百万,与 prompt/completion 同单位)
- compile 只在规则显式声明时写 reasoning_price,缺省时字段不出现
- 规则编辑器的模型行加「思考 $/M」输入框,含新增行的模板
缺省时「不出现」是刻意的:写 0 会被 billing 插件读成「思考免费」,
每个来自该模型的请求都会静默少算。判据专门钉住这一点,变异成「缺省写 0」
即变红。
输入框 placeholder 写明「留空=按输出价计(OpenAI/DeepSeek/Anthropic 都这样)」,
因为一个没有解释的空输入框会被当成「必填但我不知道填什么」。
判据还要求 m-r 出现两次 —— 只改已有行的模板而不改「+ 模型」新建的行,
会让新加的模型在下一次保存时静默丢掉这个价。
生产验证:PUT 一个 reasoning: "1.80" 的规则,编译产物里出现
reasoning_price: 1.8000000000000001e-06(USD/token)。测完已把生产配置恢复为
6 条规则(备份 config.yaml.bak-billing-20261004-122151 为基准重建,
20 个源数量核对一致)。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit b93799a919)
277 lines
10 KiB
Go
277 lines
10 KiB
Go
package billing
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"llmsproxy/internal/config"
|
|
)
|
|
|
|
// Compile turns one billing profile into the prices table the billing plugin
|
|
// expects, resolving URL rules against the gateway's actual sources.
|
|
//
|
|
// WHY URL RULES NEED RESOLVING AT ALL: the operator declares pricing by URL
|
|
// because that is what a provider's price list is keyed on, and because several
|
|
// sources can point at the same URL. The plugin, however, looks up by
|
|
// `payload.source` (a source NAME) and by model — it has no idea what URL a
|
|
// request went to. So the URL match happens here, at compile time, where the
|
|
// config's name->base_url mapping is known, and the result is expressed in the
|
|
// dimensions the plugin already supports.
|
|
//
|
|
// Profiles are the "let the user choose" axis: the same URL can appear in
|
|
// 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")
|
|
}
|
|
prices := map[string]interface{}{
|
|
"currency": "USD",
|
|
"default": map[string]interface{}{"prompt": 0.0, "completion": 0.0, "per_request": 0.0},
|
|
"sources": map[string]interface{}{},
|
|
"models": map[string]interface{}{},
|
|
"keys": map[string]interface{}{},
|
|
}
|
|
if profile.Default != "" {
|
|
prices["default_mode"] = profile.Default
|
|
}
|
|
|
|
// Currency is a profile-level statement; a rule may override it.
|
|
for i := range profile.Rules {
|
|
if c := profile.Rules[i].Currency; c != "" {
|
|
prices["currency"] = c
|
|
break
|
|
}
|
|
}
|
|
|
|
matched := map[string]bool{}
|
|
for i := range profile.Rules {
|
|
rule := &profile.Rules[i]
|
|
targets := matchSources(rule.URL, sources)
|
|
if len(targets) == 0 && rule.URL != "*" {
|
|
// A rule for a URL no source uses is almost always a typo (or a
|
|
// 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 {
|
|
if matched[srcName] {
|
|
// First rule wins. Two rules matching one source is ambiguous,
|
|
// and silently letting the later one win makes the file's
|
|
// meaning depend on ordering the operator cannot see.
|
|
continue
|
|
}
|
|
matched[srcName] = true
|
|
if err := applyRule(prices, rule, srcName, sources); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sources no rule matched fall through to the profile default, which the
|
|
// plugin applies via prices.default. Recording them explicitly means the
|
|
// UI can say "this source is unpriced" instead of leaving the operator to
|
|
// infer it from a zero.
|
|
var unmatched []string
|
|
for _, src := range sources {
|
|
if !matched[src.Name] {
|
|
unmatched = append(unmatched, src.Name)
|
|
}
|
|
}
|
|
if len(unmatched) > 0 {
|
|
prices["unmatched_sources"] = unmatched
|
|
}
|
|
return prices, nil
|
|
}
|
|
|
|
// matchSources returns the source names whose base_url matches pattern.
|
|
// "*" matches every source (used as a catch-all default rule).
|
|
func matchSources(pattern string, sources []config.Source) []string {
|
|
var out []string
|
|
if pattern == "*" {
|
|
for _, s := range sources {
|
|
out = append(out, s.Name)
|
|
}
|
|
return out
|
|
}
|
|
want := normalizeURL(pattern)
|
|
for _, s := range sources {
|
|
if normalizeURL(s.BaseURL) == want {
|
|
out = append(out, s.Name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalizeURL compares URLs the way an operator expects: trailing slashes and
|
|
// case in the host are differences the provider's price list does not care
|
|
// about, and requiring an exact byte match would make the config brittle.
|
|
func normalizeURL(u string) string {
|
|
u = strings.TrimSpace(u)
|
|
u = strings.TrimRight(u, "/")
|
|
return strings.ToLower(u)
|
|
}
|
|
|
|
// applyRule writes one rule's pricing for one source into the prices table.
|
|
func applyRule(prices map[string]interface{}, rule *config.BillingRule, srcName string, sources []config.Source) error {
|
|
switch rule.Mode {
|
|
case "free":
|
|
// Explicitly priced at zero. This is NOT the same as unpriced: a source
|
|
// the operator says is free must not appear in the unpriced warnings,
|
|
// or those warnings become noise and stop being read.
|
|
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
|
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
|
}
|
|
return nil
|
|
|
|
case "subscription":
|
|
// A fixed monthly commitment: the per-request MARGINAL cost is zero, and
|
|
// the flat fee is reported separately. Spreading a monthly fee across
|
|
// requests would invent a per-request number the provider never charges,
|
|
// and it would change every time traffic did.
|
|
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
|
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
|
}
|
|
fixed, _ := prices["subscriptions"].(map[string]interface{})
|
|
if fixed == nil {
|
|
fixed = map[string]interface{}{}
|
|
prices["subscriptions"] = fixed
|
|
}
|
|
cur := "USD"
|
|
if rule.Currency != "" {
|
|
cur = rule.Currency
|
|
}
|
|
fixed[srcName] = map[string]interface{}{
|
|
"monthly": rule.Subscription, "currency": cur,
|
|
}
|
|
return nil
|
|
|
|
case "unpriced":
|
|
// Deliberately left out of `sources` so the plugin's unpriced_models /
|
|
// unpriced_reqs counters catch it. That is the point: a subscription
|
|
// plan whose credits cannot be converted to tokens must be VISIBLE as
|
|
// unbilled, not quietly estimated.
|
|
return nil
|
|
|
|
case "token":
|
|
if len(rule.Models) == 0 {
|
|
return fmt.Errorf("rule for url %q: mode token requires at least one model", rule.URL)
|
|
}
|
|
models := prices["models"].(map[string]interface{})
|
|
for modelID, t := range rule.Models {
|
|
entry := map[string]interface{}{}
|
|
prompt, err := usdPerM(t.Prompt)
|
|
if err != nil {
|
|
return fmt.Errorf("model %q prompt: %w", modelID, err)
|
|
}
|
|
completion, err := usdPerM(t.Completion)
|
|
if err != nil {
|
|
return fmt.Errorf("model %q completion: %w", modelID, err)
|
|
}
|
|
entry["prompt"] = prompt
|
|
entry["completion"] = completion
|
|
if t.CacheDiscount != nil {
|
|
entry["cache_discount"] = *t.CacheDiscount
|
|
}
|
|
// reasoning is optional and stays ABSENT (not nil, not 0) when the
|
|
// rule does not set it, which is what makes the billing plugin skip
|
|
// the repricing branch entirely. Writing an explicit 0 here would
|
|
// mean "thinking is free" and silently under-bill every request
|
|
// from a provider that bills it like ordinary output.
|
|
if strings.TrimSpace(t.Reasoning) != "" {
|
|
rp, err := usdPerM(t.Reasoning)
|
|
if err != nil {
|
|
return fmt.Errorf("model %q reasoning: %w", modelID, err)
|
|
}
|
|
entry["reasoning_price"] = rp
|
|
}
|
|
if rule.Peak != nil {
|
|
entry["peak"] = compilePeak(rule.Peak)
|
|
}
|
|
models[modelID] = entry
|
|
}
|
|
// A specific-URL token rule needs a source entry so a model served
|
|
// from THIS url but absent from `rule.Models` is still recognised as
|
|
// belonging to a priced provider.
|
|
//
|
|
// A WILDCARD rule ("*") must NOT get one. "*" is a model catalogue — it
|
|
// says "these model ids have these prices, wherever they come from" —
|
|
// and marking every source priced at {0,0} would make priceFor() set
|
|
// priced=true for every model the catalogue does NOT list. Paid
|
|
// sources (commandcode, alittokenplan, …) would then record $0 instead
|
|
// of showing up in unpriced_reqs, which is precisely the silent
|
|
// zero-billing failure this whole plugin is built to make visible.
|
|
// The model lookup p.models[payload.model] already marks a listed
|
|
// model priced on its own, so the wildcard loses nothing.
|
|
if rule.URL != "*" {
|
|
if _, ok := prices["sources"].(map[string]interface{})[srcName]; !ok {
|
|
prices["sources"].(map[string]interface{})[srcName] = map[string]interface{}{
|
|
"prompt": 0.0, "completion": 0.0, "per_request": 0.0,
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return fmt.Errorf("unknown mode %q", rule.Mode)
|
|
}
|
|
|
|
// usdPerM converts a USD-per-million string into the per-token rate the plugin
|
|
// expects. Delegates to config.ParseFloatUSDPerM so the DSL validator and the
|
|
// compiler agree byte-for-byte on what a valid price is — a value that passes
|
|
// Validate() but fails here (or vice versa) would be the worst kind of drift.
|
|
func usdPerM(s string) (float64, error) {
|
|
return config.ParseFloatUSDPerM(s)
|
|
}
|
|
|
|
// compilePeak renders the peak window in the shape the plugin reads:
|
|
// { multiplier, windows = { { days = {...}, hours = { {lo,hi}, ... } } } }.
|
|
//
|
|
// The plugin reads `days` / `hours` pairs. The first version of the deployed
|
|
// price table used { start, end, weekdays } — a shape nothing reads — so peak
|
|
// traffic was billed at off-peak rates with no error anywhere. Compiling from
|
|
// typed config fields removes the chance of writing the wrong key names by hand.
|
|
func compilePeak(p *config.BillingPeak) map[string]interface{} {
|
|
win := map[string]interface{}{}
|
|
if len(p.Weekdays) > 0 {
|
|
days := make([]interface{}, 0, len(p.Weekdays))
|
|
for _, d := range p.Weekdays {
|
|
days = append(days, d)
|
|
}
|
|
win["days"] = days
|
|
}
|
|
if len(p.Hours) > 0 {
|
|
hours := make([]interface{}, 0, len(p.Hours))
|
|
for _, h := range p.Hours {
|
|
hours = append(hours, []interface{}{h[0], h[1]})
|
|
}
|
|
win["hours"] = hours
|
|
}
|
|
out := map[string]interface{}{"multiplier": p.Multiplier}
|
|
if len(win) > 0 {
|
|
out["windows"] = []interface{}{win}
|
|
}
|
|
return out
|
|
}
|