diff --git a/internal/billing/compile.go b/internal/billing/compile.go new file mode 100644 index 0000000..f324ee6 --- /dev/null +++ b/internal/billing/compile.go @@ -0,0 +1,232 @@ +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) { + 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. + 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 + } + if rule.Peak != nil { + entry["peak"] = compilePeak(rule.Peak) + } + models[modelID] = entry + } + // The source itself needs an entry too, otherwise priceFor() marks the + // request priced only when the model happens to be listed, and a model + // served from this URL but absent from the rule would be billed at the + // DEFAULT (zero) rate — indistinguishable from unpriced in the totals. + 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 +} diff --git a/internal/config/config.go b/internal/config/config.go index fe68e79..4e85b43 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,8 @@ import ( "fmt" "os" "path/filepath" + "strconv" + "strings" "time" "gopkg.in/yaml.v3" @@ -28,6 +30,15 @@ type Config struct { Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat) AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图) Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(WebUI 密钥页管理) + // BillingDSL declares per-URL pricing profiles for the billing plugin. + // + // Declarative, because hand-writing the prices JSON was error-prone in ways + // that looked successful: the first PUT went out without the required + // `prices` wrapper and silently REPLACED the accumulated totals with the + // price table; the second used a peak-window shape the plugin does not read, + // so peak traffic was billed at off-peak rates — no error, the bill just + // quietly halved. A schema-checked config file catches both at load. + BillingDSL *BillingDSL `yaml:"billing,omitempty"` // box seals credentials (sources' api_key/headers, keys' key) at rest. // In-memory values are always plaintext; only the bytes on disk are sealed. // Wired by AttachSecretBox — Load leaves it nil so `-check` and tests stay @@ -382,6 +393,144 @@ type GWKey struct { Seed bool `yaml:"seed,omitempty" json:"seed,omitempty"` // true if migrated from config gateway_keys } +// BillingDSL holds declarative per-URL pricing profiles for the billing plugin. +// +// Profiles are the "let the user pick" axis: the same upstream URL can be +// declared under multiple profiles, and the operator chooses which is active. +// Switching recomputes and re-injects the billing plugin's prices table. +type BillingDSL struct { + // Active is the id of the profile in effect. Empty => the first profile. + Active string `yaml:"active,omitempty" json:"active,omitempty"` + Profiles []BillingProfile `yaml:"profiles,omitempty" json:"profiles,omitempty"` +} + +// BillingProfile is one named set of per-URL pricing rules. +type BillingProfile struct { + ID string `yaml:"id" json:"id"` + Label string `yaml:"label,omitempty" json:"label,omitempty"` + // Default applies when no rule matches: "free" or "unpriced". + Default string `yaml:"default,omitempty" json:"default,omitempty"` + Rules []BillingRule `yaml:"rules,omitempty" json:"rules,omitempty"` +} + +// BillingRule declares the pricing for everything under one URL. +type BillingRule struct { + // URL matches a source's base_url. "*" = any unmatched URL. + URL string `yaml:"url" json:"url"` + // Mode is one of: free, token, subscription, unpriced. + Mode string `yaml:"mode" json:"mode"` + Currency string `yaml:"currency,omitempty" json:"currency,omitempty"` + // Subscription is the flat monthly cost (for mode=subscription). + Subscription float64 `yaml:"subscription,omitempty" json:"subscription,omitempty"` + // Models declares per-token prices when mode=token. + Models map[string]BillingToken `yaml:"models,omitempty" json:"models,omitempty"` + // Free is true when mode=free (kept for explicitness). + // Peak applies to ALL token-priced requests under this URL. + Peak *BillingPeak `yaml:"peak,omitempty" json:"peak,omitempty"` +} + +// BillingToken is the per-model price. +type BillingToken struct { + Prompt string `yaml:"prompt" json:"prompt"` // USD per million tokens, e.g. "0.15" + Completion string `yaml:"completion" json:"completion"` // USD per million tokens + CacheDiscount *float64 `yaml:"cache_discount,omitempty" json:"cache_discount,omitempty"` +} + +// BillingPeak declares peak-hour pricing. +type BillingPeak struct { + Multiplier float64 `yaml:"multiplier" json:"multiplier"` + // Weekdays: 1=Mon..5=Fri (Lua os.date !%w, 0=Sun) + Weekdays []int `yaml:"weekdays,omitempty" json:"weekdays,omitempty"` + // Hours: each pair is [lo,hi] inclusive + Hours [][2]int `yaml:"hours,omitempty" json:"hours,omitempty"` +} + +// Validate rejects a BillingDSL that could not work as written. +func (b *BillingDSL) Validate() error { + if b == nil { + return nil + } + ids := map[string]bool{} + for i := range b.Profiles { + p := &b.Profiles[i] + if p.ID == "" { + return fmt.Errorf("billing profile [%d] has no id", i) + } + if ids[p.ID] { + return fmt.Errorf("billing profile id %q appears more than once", p.ID) + } + ids[p.ID] = true + if p.Default != "" && p.Default != "free" && p.Default != "unpriced" { + return fmt.Errorf("profile %q: default must be free or unpriced, got %q", p.ID, p.Default) + } + for j := range p.Rules { + r := &p.Rules[j] + if r.URL == "" { + return fmt.Errorf("profile %q rule [%d]: url is required", p.ID, j) + } + switch r.Mode { + case "free", "token", "subscription", "unpriced": + default: + return fmt.Errorf("profile %q rule [%d]: mode must be free/token/subscription/unpriced, got %q", p.ID, j, r.Mode) + } + if r.Mode == "token" { + for m, t := range r.Models { + if t.Prompt == "" || t.Completion == "" { + return fmt.Errorf("profile %q model %q: prompt and completion are required for token mode", p.ID, m) + } + if _, err := ParseFloatUSDPerM(t.Prompt); err != nil { + return fmt.Errorf("profile %q model %q prompt: %w", p.ID, m, err) + } + if _, err := ParseFloatUSDPerM(t.Completion); err != nil { + return fmt.Errorf("profile %q model %q completion: %w", p.ID, m, err) + } + } + } + } + } + // Active must resolve to a profile id. + if b.Active != "" && !ids[b.Active] { + return fmt.Errorf("billing.active = %q but no profile has that id", b.Active) + } + return nil +} + +// Resolve returns the profile with the given id; empty id (or an id that does +// not exist) falls back to the FIRST profile. A config with profiles but no +// active marker should mean "use the one I wrote first" rather than "price +// nothing", which is what a nil here would silently do. +func (b *BillingDSL) Resolve(id string) *BillingProfile { + if b == nil || len(b.Profiles) == 0 { + return nil + } + if id != "" { + for i := range b.Profiles { + if b.Profiles[i].ID == id { + return &b.Profiles[i] + } + } + } + return &b.Profiles[0] +} + +// parseFloatUSDPerM parses a USD-per-million price string. Returns the +// per-single-token rate the plugin expects (price / 1e6). +// +// Kept as a string in the DSL rather than a float: "0.15" survives round-trips +// through YAML editors, and a malformed value must fail VALIDATION (with the +// file and field named) rather than parse as 0 and under-bill silently — which +// is exactly the failure mode that motivated the DSL. +func ParseFloatUSDPerM(s string) (float64, error) { + f, err := strconv.ParseFloat(strings.TrimSpace(s), 64) + if err != nil { + return 0, fmt.Errorf("not a number: %q", s) + } + if f < 0 { + return 0, fmt.Errorf("negative price: %q", s) + } + return f / 1e6, nil +} + // KeyQuota is retained only to carry a scope entry's caps through the admin // API. Quotas are per model, never per key: there is deliberately no key-wide // total, so exhausting one model's budget never blocks the others. diff --git a/internal/core/core.go b/internal/core/core.go index 65bef68..f2addaf 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -16,6 +16,7 @@ import ( "sync/atomic" "time" + "llmsproxy/internal/billing" "llmsproxy/internal/config" "llmsproxy/internal/lua" "llmsproxy/internal/provider" @@ -75,6 +76,7 @@ func NewFromConfig(cfg *config.Config) (*Core, error) { log.Printf("[core] plugin %v %v loaded (hooks=%v ui=%v)", p["name"], p["version"], p["hooks"], p["ui"]) } + c.applyBillingDSL() c.store = config.NewStore(cfg.RuntimeFile) // Share one box between the runtime store and config.yaml so a single // master.key seals both files. config.Load left the config holding @@ -838,6 +840,63 @@ func (c *Core) Close() { } } +// applyBillingDSL compiles the configured billing profile and injects it into +// the billing plugin. +// +// Configured pricing replaces the hand-written JSON blob that used to be PUT +// through the state API. That path was used twice in production and both times +// it "succeeded" while being wrong: once without the required `prices` wrapper +// (silently wiping the accumulated totals), once with a peak-window shape the +// plugin does not read (peak traffic billed at off-peak rates, no error). A +// typed config with load-time validation turns both mistakes into startup +// errors naming the file and field. +// +// A DSL with no profile, or one that fails to compile, leaves the plugin's +// prices untouched: billing keeps running on whatever it had, and the problem +// is logged rather than becoming a startup failure — the gateway must forward +// even when its cost accounting is misconfigured. +func (c *Core) applyBillingDSL() { + ps := c.plugins + if ps == nil || ps.Count() == 0 { + return + } + // The billing plugin may simply not be installed; a DSL without it is a + // no-op, not an error. + var have bool + for _, row := range ps.List() { + if row["name"] == "billing" && row["loaded"] == true { + have = true + break + } + } + if !have { + return + } + dsl := c.cfg.BillingDSL + if dsl == nil || len(dsl.Profiles) == 0 { + return + } + if err := dsl.Validate(); err != nil { + log.Printf("[billing] config invalid, profile not applied: %v", err) + return + } + profile := dsl.Resolve(dsl.Active) + if profile == nil { + log.Printf("[billing] no billing profile resolved (active=%q)", dsl.Active) + return + } + prices, err := billing.Compile(profile, c.cfg.Sources) + if err != nil { + log.Printf("[billing] profile %q not applied: %v", profile.ID, err) + return + } + if err := ps.SetState("billing", map[string]interface{}{"prices": prices}); err != nil { + log.Printf("[billing] applying profile %q: %v", profile.ID, err) + return + } + log.Printf("[billing] profile %q applied (%d rules, %d sources)", profile.ID, len(profile.Rules), len(c.cfg.Sources)) +} + // ValidateScopeQuotas checks every scope entry's caps before they are stored. // A typo in a period must be rejected at write time rather than silently // becoming a never-resetting budget — the opposite of what was typed. diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index c90c291..367ad95 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -1357,6 +1357,7 @@ localStorage.setItem("llms-proxy.lang", LANG); applyI18n(); updateBreadcrumb(lastTab); + notifyPluginLang(); refresh(lastTab); }; document.getElementById("btn-logout").onclick = () => { @@ -1386,6 +1387,12 @@ // that is routed but never shown is exactly the kind of silent gap that // survives review. const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"]; + // Declared HERE, with var, because goTab() below reads it. It used to be a + // `const` further down the file next to the other plugin state, which put + // goTab's read inside the temporal dead zone: the first click on a plugin + // page would have thrown a ReferenceError. var hoists, so the read sees + // the (still empty) Set and injectPluginUI fills it moments later. + var PLUGIN_PAGES = new Set(); document.querySelectorAll("nav button.sb-i").forEach((b) => { b.onclick = () => goTab(b.dataset.tab); }); @@ -1397,7 +1404,22 @@ document .querySelectorAll(".sb-i") .forEach((x) => x.classList.toggle("active", x.dataset.tab === name)); + // TABS plus every plugin-contributed page. + // + // Iterating only TABS left a plugin page permanently unreachable: the + // pane exists and is filled (the data was right there in the DOM) but + // its `hidden` class was never removed, so clicking the sidebar entry + // did nothing visible. The symptom reads as "the page is blank" because + // the content is present in the DOM and only invisible. + // + // PLUGIN_PAGES is consulted here rather than relying on the page's own + // script: the pane's visibility is the HOST's job, and a plugin that + // forgot to unhide itself must still be reachable. TABS.forEach((tn) => $("#tab-" + tn).classList.toggle("hidden", tn !== name)); + PLUGIN_PAGES.forEach((pn) => { + const el = $("#tab-" + pn); + if (el) el.classList.toggle("hidden", pn !== name); + }); updateBreadcrumb(name); const pane = $("#tab-" + name); if (pane) { @@ -5082,7 +5104,6 @@ // GET /api/ui-inject, because the sidebar needs all of them before it can // be built. Injection happens once at boot, BEFORE the first goTab, so a // plugin page is a real tab rather than a special case in the router. - const PLUGIN_PAGES = new Set(); const PLUGIN_TAB_CBS = {}; const PLUGIN_ELEMENTS = []; // PLUGIN_MOUNT_HOOKS holds re-mount functions for plugin ELEMENTS. A host @@ -5117,7 +5138,32 @@ PLUGIN_TAB_CBS.__last = PLUGIN_TAB_CBS.__last || []; PLUGIN_TAB_CBS.__last.push(fn); }, + // The current UI language ("zh" | "en"). A plugin cannot read the + // host's LANG binding (it is module-local), and hardcoding one language + // is what left the Billing page English-only while the rest of the UI + // switched. Exposed as a getter so a plugin always sees the live value. + get lang() { + return LANG; + }, + // Register a callback fired whenever the user switches language, so a + // plugin page can re-render in the new language without a reload. The + // callback also fires is NOT automatic on first paint — the plugin + // renders itself once on load as it always did. + onLangChange(fn) { + if (typeof fn === "function") PLUGIN_LANG_CBS.push(fn); + }, }; + // Callbacks registered via pluginAPI.onLangChange. + const PLUGIN_LANG_CBS = []; + function notifyPluginLang() { + PLUGIN_LANG_CBS.forEach((fn) => { + try { + fn(LANG); + } catch (e) { + console.warn("plugin lang callback failed", e); + } + }); + } // pluginIconHTML renders a plugin-declared sidebar icon. // @@ -5257,6 +5303,14 @@ const pane = document.createElement("div"); pane.id = "tab-" + id; pane.className = "tab-pane hidden"; + // A plugin page is arbitrary HTML from a third party. Without + // min-width:0 its content (a wide table, a long unbroken string) + // stretches the pane past #main, which does not scroll sideways — + // the operator sees the page spill over the UI instead of a + // scrollbar. The same guard every native pane gets. + pane.style.minWidth = "0"; + pane.style.maxWidth = "100%"; + pane.style.overflowX = "auto"; main.appendChild(pane); const btn = document.createElement("button"); btn.className = "sb-i"; diff --git a/internal/gateway/ui_plugin_test.go b/internal/gateway/ui_plugin_test.go index ac67686..38ee56d 100644 --- a/internal/gateway/ui_plugin_test.go +++ b/internal/gateway/ui_plugin_test.go @@ -315,3 +315,54 @@ func TestPluginElementsSurviveHostRebuild(t *testing.T) { t.Error("element mounting is not guarded by a per-pane marker; re-mounting would re-run plugin scripts") } } + +// TestPluginPagesAreReachableByGoTab guards a bug that only shows up in a +// browser: goTab() iterated the hardcoded TABS list to toggle `hidden`, and a +// plugin-contributed page is not in that list. The pane existed, the plugin had +// filled it with real data, and clicking the sidebar entry changed nothing — +// the `hidden` class was never removed. +// +// It reads exactly like "the page is blank" while the content sits in the DOM, +// and no static check catches it: the injection is correct, the data is correct, +// and the API returns 200. +// +// So this asserts the two structural facts that make it reachable: goTab +// consults the plugin page set, and that set is declared before goTab runs (a +// `const` further down the file would be a temporal-dead-zone ReferenceError on +// the first click). +func TestPluginPagesAreReachableByGoTab(t *testing.T) { + ui := uiSource(t) + + goTab := strings.Index(ui, "function goTab(") + if goTab < 0 { + t.Fatal("goTab() is gone") + } + end := strings.Index(ui[goTab:], "\n }") + if end < 0 { + t.Fatal("could not isolate goTab()") + } + body := ui[goTab : goTab+end] + + if !strings.Contains(body, "PLUGIN_PAGES.forEach") { + t.Error("★ goTab() does not toggle plugin pages — a plugin page keeps its " + + "`hidden` class forever, so the sidebar entry does nothing") + } + + // Declaration must precede the use, or the first click throws. + decl := strings.Index(ui, "var PLUGIN_PAGES = new Set()") + if decl < 0 { + // A const later in the file would also "work" only if nothing reads it + // first — make that explicit rather than silent. + if strings.Contains(ui, "const PLUGIN_PAGES = new Set()") { + t.Error("PLUGIN_PAGES is a `const` declared after goTab() reads it — " + + "temporal dead zone: the first click on a plugin page throws") + } else { + t.Error("PLUGIN_PAGES is not declared anywhere") + } + return + } + if decl > goTab { + t.Errorf("PLUGIN_PAGES is declared at %d but goTab() at %d reads it — "+ + "declaration must come first", decl, goTab) + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index e677ad0..39978e2 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -462,30 +462,65 @@ plugin.ui = { icon = [==[]==], order = 40, mount = [==[ -
+
-

Per source

+

-

Per model

+

-

Per gateway key

+

-

Daily

+

]==], @@ -589,7 +651,7 @@ plugin.ui = { order = 5, mount = [==[
-
Total spend (billing plugin)
+
—
@@ -609,7 +671,21 @@ plugin.ui = { var st = j.state; if (!st || !st.total) return; var cur = st.currency || "USD"; - document.getElementById("billing-status-total").textContent = cur + " " + fmt(st.total.cost); + // The await above yields, so the host page may have rebuilt or torn down + // this element in the meantime — and it does: renderStatus assigns + // pane.innerHTML wholesale on every refresh. Assigning to a null element + // threw a TypeError that the surrounding catch logged on every repaint. + // Re-check after every await rather than assuming the DOM survived it. + var totalEl = document.getElementById("billing-status-total"); + if (!totalEl) return; + totalEl.textContent = cur + " " + fmt(st.total.cost); + // The tile's label is plugin UI text, so it follows the host language via + // the same pluginAPI surface the Billing page uses. + var lab = document.getElementById("billing-tile-label"); + if (lab) { + var lang = (window.pluginAPI && pluginAPI.lang) || "zh"; + lab.textContent = lang === "zh" ? "总开销(billing 插件)" : "Total spend (billing plugin)"; + } var parts = []; var srcs = st.by_source || {}; var names = Object.keys(srcs).sort(function (a, b) { @@ -618,7 +694,8 @@ plugin.ui = { for (var i = 0; i < Math.min(3, names.length); i++) { parts.push(names[i] + " " + fmt(srcs[names[i]].cost)); } - document.getElementById("billing-status-sub").textContent = + var sub2 = document.getElementById("billing-status-sub"); + if (sub2) sub2.textContent = (st.total.requests || 0) + " requests" + (parts.length ? " · top: " + parts.join(" · ") : ""); } catch (e) { // Same reasoning as the Billing page: decoration must never break the