fix(billing): 插件页不可达(真·空白根因)+ i18n + 溢出 + 状态页 tile

## ★ 用户报告「Billing 页还是空白」—— 上一轮的验证有漏洞
上一轮我用 goTab('billing') 直接调用验证,显示"有数据、无错误"就下了结论。
但用户是**点侧栏按钮**。真实点击路径走 goTab,而 goTab 只遍历硬编码的 TABS
常量来切换 `hidden` 类 —— 插件页不在 TABS 里,所以 #tab-billing 的 hidden
**永远不会被移除**。内容一直躺在 DOM 里(KPI/表格都填好了),只是不可见。

这个 bug 没有任何报错:注入正确、数据正确、API 200,唯一的问题是宿主页的
路由逻辑没把插件页纳入。而它是上一轮「TABS 收敛为单一常量」时留下的:
收敛让三处共用一个常量,却没让插件页进入它。

修法:goTab 同时遍历 PLUGIN_PAGES。PLUGIN_PAGES 从 const 改为 var 并**提前到
goTab 之前声明** —— const 在文件后部声明的话,goTab 的读取落在 TDZ 里,
第一次点击插件页就会抛 ReferenceError(同类问题这个文件里已是第二次)。

判据 TestPluginPagesAreReachableByGoTab 锁两件事:goTab 遍历插件页集合 +
声明在使用之前。变异验证:删掉遍历 → 红;var 改回 const → 红。

## 插件 UI 不跟随多语言
宿主的 applyI18n/data-i 只覆盖**宿主渲染的标记**;插件注入的 HTML 对它不可见,
所以整个 UI 切中文时 Billing 页还是英文。

pluginAPI 增加 lang(getter,实时值)与 onLangChange(切换回调)。
billing 页所有文案改走双语字典:KPI、表头(fresh/cache/cache%)、区块标题
(占位后由脚本填)、空态、状态页 tile 标签。切换时立即重渲染标题,
不用等下一次 fetch。

## 部分页面超出 UI 区域
#main 只有 overflow-y,插件页内容(8 列表格 min-width、长字符串)会横向撑破。
两层修:插件 pane 统一 min-width:0/max-width:100%/overflow-x:auto(第三方
任意 HTML 的兜底,与原生 pane 一致);billing 的宽表格在自身容器内滚动。

## 状态页 tile 的 TypeError(每次重绘都报)
tile 的 tick() 在 await 之后直接 getElementById(...).textContent = ...,
但状态页每次刷新都整体重建 pane,元素可能已不存在 → null 属性赋值。
await 之后重新取元素并判空。

## 顺手补的缺口
上一轮加了缓存表格列,但 KPI 卡片漏了(那次替换 assert 失败后重试只重做了
表格)—— 缓存命中率在表格里有、KPI 里没有。本次补上。

## 验证
真实浏览器(禁缓存、真实点击侧栏按钮):pane 可见、KPI 9 项、表头双语、
语言双向切换正确(Per source ⇄ 按源)、无水平溢出、无 billing 控制台错误。
生产数据:Total USD 0.566798 / 732 请求 / 降级 231 / 2.09 亿 prompt tokens。
387+ 测试全绿。

## DSL(进行中,未完)
config.BillingDSL(active + profiles + rules,rule 按 url 匹配 mode=free/
token/subscription/unpriced)与 internal/billing.Compile(url 规则 → 插件
prices 表,含峰谷窗口的形状编译 —— 之前手写 JSON 两次弄错的正是这个形状)
已落地并通过校验/编译;core 启动接线已写。profile 切换 API 与 WebUI 选择器
未做,生产 config.yaml 也尚未写 billing 段 —— 下一轮继续。
This commit is contained in:
JianFeeeee
2026-10-02 11:47:24 +08:00
parent fbdf0dea10
commit aa10ee8c27
6 changed files with 647 additions and 25 deletions

232
internal/billing/compile.go Normal file
View File

@ -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
}

View File

@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
"time" "time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@ -28,6 +30,15 @@ type Config struct {
Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat) Auto []ModelScope `yaml:"auto,omitempty"` // AUTO 调度链规则(WebUI 优先级页编辑,chat)
AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图) AutoImage []ModelScope `yaml:"auto_image,omitempty"` // AUTO 生图调度链规则(WebUI 优先级页·生图)
Keys []GWKey `yaml:"keys,omitempty"` // 网关密钥(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. // box seals credentials (sources' api_key/headers, keys' key) at rest.
// In-memory values are always plaintext; only the bytes on disk are sealed. // 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 // 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 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 // 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 // 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. // total, so exhausting one model's budget never blocks the others.

View File

@ -16,6 +16,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"llmsproxy/internal/billing"
"llmsproxy/internal/config" "llmsproxy/internal/config"
"llmsproxy/internal/lua" "llmsproxy/internal/lua"
"llmsproxy/internal/provider" "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)", log.Printf("[core] plugin %v %v loaded (hooks=%v ui=%v)",
p["name"], p["version"], p["hooks"], p["ui"]) p["name"], p["version"], p["hooks"], p["ui"])
} }
c.applyBillingDSL()
c.store = config.NewStore(cfg.RuntimeFile) c.store = config.NewStore(cfg.RuntimeFile)
// Share one box between the runtime store and config.yaml so a single // Share one box between the runtime store and config.yaml so a single
// master.key seals both files. config.Load left the config holding // 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. // 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 // 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. // becoming a never-resetting budget — the opposite of what was typed.

View File

@ -1357,6 +1357,7 @@
localStorage.setItem("llms-proxy.lang", LANG); localStorage.setItem("llms-proxy.lang", LANG);
applyI18n(); applyI18n();
updateBreadcrumb(lastTab); updateBreadcrumb(lastTab);
notifyPluginLang();
refresh(lastTab); refresh(lastTab);
}; };
document.getElementById("btn-logout").onclick = () => { document.getElementById("btn-logout").onclick = () => {
@ -1386,6 +1387,12 @@
// that is routed but never shown is exactly the kind of silent gap that // that is routed but never shown is exactly the kind of silent gap that
// survives review. // survives review.
const TABS = ["status", "chat", "keys", "sort", "sources", "adapters", "plugins"]; 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) => { document.querySelectorAll("nav button.sb-i").forEach((b) => {
b.onclick = () => goTab(b.dataset.tab); b.onclick = () => goTab(b.dataset.tab);
}); });
@ -1397,7 +1404,22 @@
document document
.querySelectorAll(".sb-i") .querySelectorAll(".sb-i")
.forEach((x) => x.classList.toggle("active", x.dataset.tab === name)); .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)); 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); updateBreadcrumb(name);
const pane = $("#tab-" + name); const pane = $("#tab-" + name);
if (pane) { if (pane) {
@ -5082,7 +5104,6 @@
// GET /api/ui-inject, because the sidebar needs all of them before it can // 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 // 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. // 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_TAB_CBS = {};
const PLUGIN_ELEMENTS = []; const PLUGIN_ELEMENTS = [];
// PLUGIN_MOUNT_HOOKS holds re-mount functions for plugin ELEMENTS. A host // 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 = PLUGIN_TAB_CBS.__last || [];
PLUGIN_TAB_CBS.__last.push(fn); 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. // pluginIconHTML renders a plugin-declared sidebar icon.
// //
@ -5257,6 +5303,14 @@
const pane = document.createElement("div"); const pane = document.createElement("div");
pane.id = "tab-" + id; pane.id = "tab-" + id;
pane.className = "tab-pane hidden"; 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); main.appendChild(pane);
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.className = "sb-i"; btn.className = "sb-i";

View File

@ -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") 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)
}
}

View File

@ -462,30 +462,65 @@ plugin.ui = {
icon = [==[<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5a3 3 0 0 0-2.5-1.3c-1.4 0-2.4.7-2.4 1.8 0 2.6 5.2 1.4 5.2 4 0 1.1-1 1.8-2.5 1.8-1.1 0-2.1-.4-2.7-1.2"/><path d="M12 6.4v11.2"/></svg>]==], icon = [==[<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5a3 3 0 0 0-2.5-1.3c-1.4 0-2.4.7-2.4 1.8 0 2.6 5.2 1.4 5.2 4 0 1.1-1 1.8-2.5 1.8-1.1 0-2.1-.4-2.7-1.2"/><path d="M12 6.4v11.2"/></svg>]==],
order = 40, order = 40,
mount = [==[ mount = [==[
<div id="billing-root" style="padding:16px"> <div id="billing-root" style="padding:16px;min-width:0;max-width:100%;overflow-x:auto">
<div class="kpis" id="billing-kpis" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:18px"></div> <div class="kpis" id="billing-kpis" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:18px"></div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px">
<div class="card" style="padding:14px"> <div class="card" style="padding:14px">
<h3 style="margin:0 0 10px;font-size:14px">Per source</h3> <h3 id="billing-h-src" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-source"></div> <div id="billing-by-source"></div>
</div> </div>
<div class="card" style="padding:14px"> <div class="card" style="padding:14px">
<h3 style="margin:0 0 10px;font-size:14px">Per model</h3> <h3 id="billing-h-model" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-model"></div> <div id="billing-by-model"></div>
</div> </div>
<div class="card" style="padding:14px"> <div class="card" style="padding:14px">
<h3 style="margin:0 0 10px;font-size:14px">Per gateway key</h3> <h3 id="billing-h-key" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-key"></div> <div id="billing-by-key"></div>
</div> </div>
</div> </div>
<div class="card" style="padding:14px;margin-top:16px"> <div class="card" style="padding:14px;margin-top:16px">
<h3 style="margin:0 0 10px;font-size:14px">Daily</h3> <h3 id="billing-h-day" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-day"></div> <div id="billing-by-day"></div>
</div> </div>
</div> </div>
<script> <script>
(function () { (function () {
var ROOT = "billing"; var ROOT = "billing";
// UI strings, bilingual. The host page i18n (applyI18n/data-i) only covers
// markup the HOST renders; a plugin's injected markup is invisible to it, so
// the Billing page stayed English while the rest of the UI switched. These go
// through pluginAPI.lang / onLangChange, the small surface the host exposes
// for exactly this.
var STR = {
en: {
total: "Total", requests: "Requests", degraded: "Degraded",
unpriced: "Unpriced", prompt: "Prompt tokens",
completion: "Completion tokens", failures: "Failures",
cacheRate: "Cache hit rate", cacheTokens: "Cache read tokens",
perSource: "Per source", perModel: "Per model",
perKey: "Per gateway key", perDay: "Daily",
thName: "name", thCost: "cost", thReqs: "reqs", thPrompt: "prompt",
thFresh: "fresh", thCache: "cache", thCachePct: "cache%",
thCompletion: "completion",
noData: "no data yet",
},
zh: {
total: "总开销", requests: "请求数", degraded: "降级",
unpriced: "未定价", prompt: "输入 tokens",
completion: "输出 tokens", failures: "失败",
cacheRate: "缓存命中率", cacheTokens: "缓存读取 tokens",
perSource: "按源", perModel: "按模型",
perKey: "按网关密钥", perDay: "按天",
thName: "名称", thCost: "开销", thReqs: "请求", thPrompt: "输入",
thFresh: "新鲜", thCache: "缓存", thCachePct: "缓存%",
thCompletion: "输出",
noData: "暂无数据",
},
};
function L() {
var lang = (window.pluginAPI && pluginAPI.lang) || "zh";
return STR[lang] || STR.zh;
}
function fmt(n) { function fmt(n) {
if (n === null || n === undefined) return "-"; if (n === null || n === undefined) return "-";
n = Number(n); n = Number(n);
@ -525,37 +560,56 @@ plugin.ui = {
var keys = Object.keys(obj || {}); var keys = Object.keys(obj || {});
if (!keys.length) { el.innerHTML = '<div class="muted">' + empty + "</div>"; return; } if (!keys.length) { el.innerHTML = '<div class="muted">' + empty + "</div>"; return; }
keys.sort(function (a, b) { return (obj[b].cost || 0) - (obj[a].cost || 0); }); keys.sort(function (a, b) { return (obj[b].cost || 0) - (obj[a].cost || 0); });
var h = "<table style='width:100%;border-collapse:collapse;font-size:13px'>" + var TH = L();
"<tr style='text-align:left;opacity:.65'><th>name</th><th>cost</th><th>reqs</th>" + var h = "<table style='width:100%;border-collapse:collapse;font-size:13px;min-width:560px'>" +
"<th>prompt</th><th>fresh</th><th>cache</th><th>cache%</th>" + "<tr style='text-align:left;opacity:.65'><th>" + TH.thName + "</th><th>" + TH.thCost +
"<th>completion</th></tr>"; "</th><th>" + TH.thReqs + "</th><th>" + TH.thPrompt +
"</th><th>" + TH.thFresh + "</th><th>" + TH.thCache + "</th><th>" + TH.thCachePct +
"</th><th>" + TH.thCompletion + "</th></tr>";
for (var i = 0; i < keys.length; i++) { for (var i = 0; i < keys.length; i++) {
var k = keys[i]; var k = keys[i];
h += "<tr style='border-top:1px solid rgba(120,90,150,.14)'>" + row(k, obj[k], cur) + "</tr>"; h += "<tr style='border-top:1px solid rgba(120,90,150,.14)'>" + row(k, obj[k], cur) + "</tr>";
} }
el.innerHTML = h + "</table>"; el.innerHTML = h + "</table>";
} }
function renderTitles() {
var T = L();
var m = { "billing-h-src": T.perSource, "billing-h-model": T.perModel,
"billing-h-key": T.perKey, "billing-h-day": T.perDay };
for (var id in m) {
var el = document.getElementById(id);
if (el) el.textContent = m[id];
}
}
function render(st) { function render(st) {
if (!st) return; if (!st) return;
renderTitles();
var T = L();
var cur = (st.currency || "USD"); var cur = (st.currency || "USD");
var t = st.total || {}; var t = st.total || {};
document.getElementById("billing-kpis").innerHTML = [ document.getElementById("billing-kpis").innerHTML = [
["Total", money(t.cost, cur)], [T.total, money(t.cost, cur)],
["Requests", t.requests || 0], [T.requests, t.requests || 0],
["Degraded", st.degraded_reqs || 0], [T.degraded, st.degraded_reqs || 0],
["Unpriced", st.unpriced_reqs || 0], [T.unpriced, st.unpriced_reqs || 0],
["Prompt tokens", t.prompt_tokens || 0], [T.prompt, t.prompt_tokens || 0],
["Completion tokens", t.completion_tokens || 0], [T.completion, t.completion_tokens || 0],
["Failures", t.failures || 0] [T.failures, t.failures || 0],
// Cache KPIs: last session added the table columns but the KPI cards
// were left out — the edit's assert failed and the retry only re-did the
// tables. The numbers existed in state and nowhere in the UI.
[T.cacheRate, cacheRate(t)],
[T.cacheTokens, t.cache_hit_tokens || 0]
].map(function (kv) { ].map(function (kv) {
return "<div class='card' style='padding:12px'><div style='font-size:11px;opacity:.65'>" + return "<div class='card' style='padding:12px'><div style='font-size:11px;opacity:.65'>" +
kv[0] + "</div><div style='font-size:19px;font-weight:600;margin-top:4px'>" + kv[0] + "</div><div style='font-size:19px;font-weight:600;margin-top:4px'>" +
esc(kv[1]) + "</div></div>"; esc(kv[1]) + "</div></div>";
}).join(""); }).join("");
tableFor(document.getElementById("billing-by-source"), st.by_source, cur, "no per-source data yet"); var TD = L();
tableFor(document.getElementById("billing-by-model"), st.by_model, cur, "no per-model data yet"); tableFor(document.getElementById("billing-by-source"), st.by_source, cur, TD.noData);
tableFor(document.getElementById("billing-by-key"), st.by_key, cur, "no per-key data yet"); tableFor(document.getElementById("billing-by-model"), st.by_model, cur, TD.noData);
tableFor(document.getElementById("billing-by-day"), st.by_day, cur, "no daily data yet"); tableFor(document.getElementById("billing-by-key"), st.by_key, cur, TD.noData);
tableFor(document.getElementById("billing-by-day"), st.by_day, cur, TD.noData);
} }
async function refresh() { async function refresh() {
try { try {
@ -574,7 +628,15 @@ plugin.ui = {
} }
window.__billingRefresh = refresh; window.__billingRefresh = refresh;
refresh(); refresh();
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(refresh); if (window.pluginAPI) {
if (pluginAPI.onTabShown) pluginAPI.onTabShown(refresh);
if (pluginAPI.onLangChange) {
pluginAPI.onLangChange(function () {
renderTitles();
refresh();
});
}
}
})(); })();
</script> </script>
]==], ]==],
@ -589,7 +651,7 @@ plugin.ui = {
order = 5, order = 5,
mount = [==[ mount = [==[
<div class="card" id="billing-status-tile" style="padding:12px;margin-bottom:12px"> <div class="card" id="billing-status-tile" style="padding:12px;margin-bottom:12px">
<div style="font-size:11px;opacity:.65">Total spend (billing plugin)</div> <div style="font-size:11px;opacity:.65" id="billing-tile-label"></div>
<div id="billing-status-total" style="font-size:22px;font-weight:600;margin-top:4px">—</div> <div id="billing-status-total" style="font-size:22px;font-weight:600;margin-top:4px">—</div>
<div id="billing-status-sub" style="font-size:12px;opacity:.65;margin-top:2px"></div> <div id="billing-status-sub" style="font-size:12px;opacity:.65;margin-top:2px"></div>
</div> </div>
@ -609,7 +671,21 @@ plugin.ui = {
var st = j.state; var st = j.state;
if (!st || !st.total) return; if (!st || !st.total) return;
var cur = st.currency || "USD"; 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 parts = [];
var srcs = st.by_source || {}; var srcs = st.by_source || {};
var names = Object.keys(srcs).sort(function (a, b) { 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++) { for (var i = 0; i < Math.min(3, names.length); i++) {
parts.push(names[i] + " " + fmt(srcs[names[i]].cost)); 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(" · ") : ""); (st.total.requests || 0) + " requests" + (parts.length ? " · top: " + parts.join(" · ") : "");
} catch (e) { } catch (e) {
// Same reasoning as the Billing page: decoration must never break the // Same reasoning as the Billing page: decoration must never break the