mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
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:
232
internal/billing/compile.go
Normal file
232
internal/billing/compile.go
Normal 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
|
||||||
|
}
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user