From a073423867a7d5006d3e564931eb71122b898e0a Mon Sep 17 00:00:00 2001 From: root Date: Tue, 20 Oct 2026 19:09:44 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E8=A1=A5=E9=BD=90=20main=20=E7=9A=84?= =?UTF-8?q?=20internal/billing=20=E5=88=A0=E9=99=A4=EF=BC=88=E5=9B=9E?= =?UTF-8?q?=E6=B5=81=E6=97=B6=20modify/delete=20=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E9=81=97=E7=95=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/billing/compile.go | 323 ------------------------------ internal/billing/wildcard_test.go | 214 -------------------- 2 files changed, 537 deletions(-) delete mode 100644 internal/billing/compile.go delete mode 100644 internal/billing/wildcard_test.go diff --git a/internal/billing/compile.go b/internal/billing/compile.go deleted file mode 100644 index 184a28b..0000000 --- a/internal/billing/compile.go +++ /dev/null @@ -1,323 +0,0 @@ -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 lc := t.LongContext; lc != nil { - if lc.Above <= 0 { - return fmt.Errorf("model %q long_context: above must be > 0", modelID) - } - above, err := longContextRate(lc, prompt) - if err != nil { - return fmt.Errorf("model %q long_context: %w", modelID, err) - } - entry["long_context"] = map[string]interface{}{ - "above": lc.Above, - // Marginal, not blended. The plugin charges the tokens past the - // threshold at this rate and the rest at the base rate, so the - // threshold composes with a cache discount (which applies to the - // cached portion) instead of forcing the plugin to guess how - // the two interact. - "prompt": above, - } - } - 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 -} - -// longContextRate resolves the marginal per-token rate for the tokens ABOVE a -// threshold, given the model's base per-million prompt price. -// -// Two ways to express it are supported because providers publish both: an -// absolute rate (OpenAI quotes \$3.00/M past 200k) and a multiple (DeepSeek -// quotes exactly 2x past 128k). Neither may be missing: a step with no rate is a -// silent no-op, and an operator who wrote one meant something. -func longContextRate(lc *config.BillingLongContext, basePerToken float64) (float64, error) { - hasPrompt := strings.TrimSpace(lc.Prompt) != "" - hasMult := lc.Multiplier > 0 - switch { - case hasPrompt && hasMult: - p, err := usdPerM(lc.Prompt) - if err != nil { - return 0, err - } - // Both given: the absolute rate wins, because it is the one the provider - // actually quotes. Silently multiplying instead would produce a number - // the operator never wrote. - return p, nil - case hasPrompt: - return usdPerM(lc.Prompt) - case hasMult: - return basePerToken * lc.Multiplier, nil - default: - return 0, fmt.Errorf("needs either prompt or multiplier") - } -} diff --git a/internal/billing/wildcard_test.go b/internal/billing/wildcard_test.go deleted file mode 100644 index 512bba6..0000000 --- a/internal/billing/wildcard_test.go +++ /dev/null @@ -1,214 +0,0 @@ -package billing - -import ( - "testing" - - "llmsproxy/internal/config" -) - -// The wildcard token rule must not blanket-mark every source as priced. -// -// Why this is a hazard and not a style question: priceFor() sets priced=true as -// soon as the request's SOURCE has an entry in the price table, and that flag -// is what keeps a request OUT of unpriced_reqs. A "*" rule is a model -// catalogue — it prices specific model ids wherever they come from — but -// compiling it into a {0,0,0} entry on all 21 sources made every model the -// catalogue does not list look "priced at zero". Paid providers (commandcode, -// alittokenplan, …) then stopped appearing in the unpriced warnings, which is -// exactly the silent zero-billing failure the plugin exists to surface. -func TestWildcardTokenRuleDoesNotMarkEverySourcePriced(t *testing.T) { - sources := []config.Source{ - {Name: "free1", BaseURL: "https://free1.example.com/v1"}, - {Name: "paid1", BaseURL: "https://paid1.example.com/v1"}, - {Name: "paid2", BaseURL: "https://paid2.example.com/v1"}, - } - profile := &config.BillingProfile{ - ID: "p", - Rules: []config.BillingRule{ - {URL: "https://free1.example.com/v1", Mode: "free"}, - { - URL: "*", Mode: "token", - Models: map[string]config.BillingToken{ - "only-catalogued-model": {Prompt: "1.00", Completion: "2.00"}, - }, - }, - }, - } - prices, err := Compile(profile, sources) - if err != nil { - t.Fatalf("compile: %v", err) - } - srcs := prices["sources"].(map[string]interface{}) - - // The explicitly free source keeps its entry (free is a deliberate 0). - if _, ok := srcs["free1"]; !ok { - t.Error("the explicit free rule lost its source entry") - } - // Paid sources must NOT be marked, or their unlisted models bill $0. - for _, name := range []string{"paid1", "paid2"} { - if _, ok := srcs[name]; ok { - t.Errorf("wildcard token rule marked %q as priced at 0 — its unlisted "+ - "models will bill $0 instead of appearing as unpriced", name) - } - } - // The catalogue itself must still be present, or the rule prices nothing. - models := prices["models"].(map[string]interface{}) - if _, ok := models["only-catalogued-model"]; !ok { - t.Error("the wildcard rule did not publish its model price") - } -} - -// The inverse guard: a token rule for a SPECIFIC url still marks that source, -// because it genuinely declares pricing for the models served from it. -func TestSpecificURLTokenRuleStillMarksItsSource(t *testing.T) { - sources := []config.Source{ - {Name: "mine", BaseURL: "https://mine.example.com/v1"}, - {Name: "other", BaseURL: "https://other.example.com/v1"}, - } - profile := &config.BillingProfile{ - ID: "p", - Rules: []config.BillingRule{{ - URL: "https://mine.example.com/v1", Mode: "token", - Models: map[string]config.BillingToken{ - "m": {Prompt: "1.00", Completion: "2.00"}, - }, - }}, - } - prices, err := Compile(profile, sources) - if err != nil { - t.Fatalf("compile: %v", err) - } - srcs := prices["sources"].(map[string]interface{}) - if _, ok := srcs["mine"]; !ok { - t.Error("a specific-url token rule must still mark its own source priced") - } - if _, ok := srcs["other"]; ok { - t.Error("an unrelated source was marked priced by a specific-url rule") - } -} - -// TestReasoningPriceIsOmittedUnlessSet pins the absence convention for the -// thinking-token rate. -// -// A rule that says nothing about reasoning must produce a price entry with NO -// reasoning_price key, so the billing plugin skips the repricing branch and -// bills thinking at the ordinary output rate — which is what every provider -// actually charges. Writing an explicit 0 instead would read as "thinking is -// free" and silently under-bill every request from that model. -func TestReasoningPriceIsOmittedUnlessSet(t *testing.T) { - sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}} - - // Case 1: no reasoning declared → the key must not appear. - p1 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{ - URL: "https://up.example/v1", Mode: "token", - Models: map[string]config.BillingToken{ - "m": {Prompt: "1.00", Completion: "2.00"}, - }, - }}} - prices, err := Compile(p1, sources) - if err != nil { - t.Fatal(err) - } - entry := prices["models"].(map[string]interface{})["m"].(map[string]interface{}) - if _, present := entry["reasoning_price"]; present { - t.Errorf("reasoning_price present (%v) without a rule; the plugin would treat 0 as free", - entry["reasoning_price"]) - } - - // Case 2: declared → present, and converted to per-token like every other rate. - p2 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{ - URL: "https://up.example/v1", Mode: "token", - Models: map[string]config.BillingToken{ - "m": {Prompt: "1.00", Completion: "2.00", Reasoning: "6.00"}, - }, - }}} - prices2, err := Compile(p2, sources) - if err != nil { - t.Fatal(err) - } - e2 := prices2["models"].(map[string]interface{})["m"].(map[string]interface{}) - rp, ok := e2["reasoning_price"].(float64) - if !ok { - t.Fatalf("reasoning_price missing or wrong type: %#v", e2["reasoning_price"]) - } - if rp != 6e-6 { - t.Errorf("reasoning_price = %v, want 6e-6 (USD per TOKEN)", rp) - } - // A malformed rate must be rejected, not silently ignored: an operator who - // typed "6 USD" expects either that value or an error, never a rule that - // looks applied and bills nothing. - p3 := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{ - URL: "https://up.example/v1", Mode: "token", - Models: map[string]config.BillingToken{ - "m": {Prompt: "1.00", Completion: "2.00", Reasoning: "six dollars"}, - }, - }}} - if _, err := Compile(p3, sources); err == nil { - t.Error("a non-numeric reasoning rate must fail the compile") - } -} - -// TestLongContextStepCompiles pins the long-context step's two accepted forms -// and its rejection of a step that would silently do nothing. -// -// Providers publish both shapes — DeepSeek says "2x past 128k", OpenAI quotes -// an absolute "$3.00/M past 200k" — so both must compile. Neither may be -// missing: a step with no rate is a rule that LOOKS like it handles long -// context and does not, which under-bills without any visible sign. -func TestLongContextStepCompiles(t *testing.T) { - sources := []config.Source{{Name: "up", BaseURL: "https://up.example/v1"}} - - compile := func(lc config.BillingLongContext) (map[string]interface{}, error) { - p := &config.BillingProfile{ID: "p", Rules: []config.BillingRule{{ - URL: "https://up.example/v1", Mode: "token", - Models: map[string]config.BillingToken{ - "m": {Prompt: "1.00", Completion: "2.00", LongContext: &lc}, - }, - }}} - prices, err := Compile(p, sources) - if err != nil { - return nil, err - } - e := prices["models"].(map[string]interface{})["m"].(map[string]interface{}) - return e["long_context"].(map[string]interface{}), nil - } - - // Absolute rate (OpenAI's published shape). - lc1, err := compile(config.BillingLongContext{Above: 200000, Prompt: "3.00"}) - if err != nil { - t.Fatalf("absolute long_context rate rejected: %v", err) - } - if lc1["above"] != 200000 || lc1["prompt"] != 3e-6 { - t.Errorf("absolute step = %#v, want above=200000 prompt=3e-6", lc1) - } - - // Multiplier (DeepSeek's shape): 2x of the 1.00/M base. - lc2, err := compile(config.BillingLongContext{Above: 128000, Multiplier: 2}) - if err != nil { - t.Fatalf("multiplier long_context rejected: %v", err) - } - if lc2["prompt"] != 2e-6 { - t.Errorf("multiplier step = %#v, want prompt=2e-6", lc2) - } - - // Both given: the absolute rate wins, because that is the number the - // provider quotes. Multiplying instead would produce a figure the operator - // never wrote. - lc3, err := compile(config.BillingLongContext{Above: 128000, Prompt: "5.00", Multiplier: 2}) - if err != nil { - t.Fatal(err) - } - if lc3["prompt"] != 5e-6 { - t.Errorf("both given = %#v, want the quoted absolute rate 5e-6", lc3) - } - - // Neither: a no-op step must fail, not compile into a rule that silently - // bills long context at the base rate. - if _, err := compile(config.BillingLongContext{Above: 128000}); err == nil { - t.Error("a step with neither prompt nor multiplier must be rejected") - } - // A zero threshold would make every request "long". - if _, err := compile(config.BillingLongContext{Above: 0, Multiplier: 2}); err == nil { - t.Error("above=0 must be rejected") - } -}