diff --git a/internal/gateway/chat.go b/internal/gateway/chat.go index 2a6bcf9..b84eda0 100644 --- a/internal/gateway/chat.go +++ b/internal/gateway/chat.go @@ -1020,9 +1020,17 @@ func (g *Gateway) fireEnd(rec *Req) { "completion_tokens": rec.Compl, "cache_hit_tokens": rec.CacheHit, "cache_miss_tokens": rec.CacheMiss, - "image_count": rec.ImageCount, - "error": rec.Err, - "time": rec.Time, + // Whether UPSTREAM reported a cache number at all. A plugin cannot + // infer this from cache_hit_tokens alone: zero hits because nothing was + // cached and zero hits because the provider never reports caching are + // the same value, and they mean opposite things when you are checking + // whether a cache discount is doing anything. The audit record already + // carried this (rec.CacheReported); the plugin payload did not, so any + // plugin-level cache accounting had to guess. + "cache_reported": rec.CacheReported, + "image_count": rec.ImageCount, + "error": rec.Err, + "time": rec.Time, // chain_walk: the AUTO tier-by-tier trace, when the request went // through the chain. Empty for a direct request and for a gateway with // no plugins loaded. Absent rather than empty so a plugin can tell diff --git a/internal/gateway/ui/index.html b/internal/gateway/ui/index.html index e40f114..c90c291 100644 --- a/internal/gateway/ui/index.html +++ b/internal/gateway/ui/index.html @@ -5119,6 +5119,95 @@ }, }; + // pluginIconHTML renders a plugin-declared sidebar icon. + // + // Text icons are escaped as before. An icon that looks like markup is + // accepted ONLY as a sanitized inline : a fixed tag allowlist, no + // \u2022'; + if (!/<[a-zA-Z!/]/.test(raw)) { + // Plain text (an emoji or a glyph). + return '' + esc(raw) + ""; + } + var cleaned = sanitizePluginSVG(raw); + if (!cleaned) { + // Markup that is not an acceptable SVG: fall back to a neutral dot + // rather than injecting it or showing raw tags. + return '\u2022'; + } + return ( + '' + + cleaned + + "" + ); + } + + // sanitizePluginSVG keeps only what an icon needs. + // + // Allowlist, not a denylist: anything not named here is dropped, so a new + // dangerous construct cannot slip through by default. Attributes are + // limited to geometry and paint (no href/src, no on*, no style with url()). + var SVG_OK_TAGS = { svg: 1, path: 1, circle: 1, rect: 1, line: 1, polyline: 1, polygon: 1, g: 1 }; + var SVG_OK_ATTRS = { + viewBox: 1, fill: 1, stroke: 1, "stroke-width": 1, "stroke-linecap": 1, + "stroke-linejoin": 1, d: 1, cx: 1, cy: 1, r: 1, x: 1, y: 1, rx: 1, ry: 1, + x1: 1, y1: 1, x2: 1, y2: 1, points: 1, width: 1, height: 1, opacity: 1, + }; + function sanitizePluginSVG(raw) { + var doc = new DOMParser().parseFromString("" + raw + "", "image/svg+xml"); + var svg = doc.documentElement; + if (!svg || svg.nodeName.toLowerCase() !== "svg" || doc.querySelector("parsererror")) { + return ""; + } + // The wrapper we build is the only we emit. A plugin's own + // is unwrapped, otherwise the output nests an empty inside + // ours — visible in the markup, and it also meant the viewBox was read + // from the WRAPPER (which never has one) rather than from the plugin's, + // so any icon declaring a non-default viewBox silently lost it. + var kept = []; + (function walk(node, depth) { + if (depth > 4) return; + for (var i = 0; i < node.children.length; i++) { + var el = node.children[i]; + var name = el.nodeName.toLowerCase(); + if (name === "svg") { + walk(el, depth + 1); // unwrap, do not emit + continue; + } + if (!SVG_OK_TAGS[name]) continue; + var attrs = ""; + for (var a = 0; a < el.attributes.length; a++) { + var at = el.attributes[a]; + var an = at.name.toLowerCase(); + // Reject anything that can fetch or execute, whatever it is called. + if (/^on/.test(an) || /href|src|xlink|formaction|style/.test(an)) continue; + if (!SVG_OK_ATTRS[an]) continue; + var val = String(at.value).replace(/[<>"'&]/g, ""); + attrs += " " + an + '="' + val + '"'; + } + kept.push("<" + name + attrs + ">"); + walk(el, depth + 1); + } + })(svg, 0); + if (!kept.length) return ""; + // Prefer the plugin's own viewBox; fall back to the 24px grid every + // native icon uses. + var innerSvg = svg.querySelector("svg"); + var vb = (innerSvg && innerSvg.getAttribute("viewBox")) || svg.getAttribute("viewBox") || "0 0 24 24"; + return ( + '' + + kept.join("") + + "" + ); + } + // remountPluginElements re-attaches plugin elements after a host page // rebuilt its DOM. Safe to call at any time: each mount is a no-op when // the wrapper is already present in the current build of the pane, so a @@ -5173,10 +5262,16 @@ btn.className = "sb-i"; btn.dataset.tab = id; btn.title = ui.page.title || id; - btn.innerHTML = - '' + - esc(ui.page.icon || "•") + - ""; + // A plugin icon may be plain text (an emoji, a glyph) or an inline + // SVG snippet. Native tabs use inline SVG styled with + // `stroke: currentColor`, so an emoji next to them renders at the + // wrong size and ignores the theme — that is what "the icon looks + // wrong" meant. + // + // The SVG form is allowed through RAW, which is only safe because + // it is strictly filtered: see pluginIconHTML. Escaping it (as this + // did) would print the markup as text instead. + btn.innerHTML = pluginIconHTML(ui.page.icon); btn.onclick = () => goTab(id); nav.appendChild(btn); PLUGIN_PAGES.add(id); diff --git a/internal/lua/billing_test.go b/internal/lua/billing_test.go index 119cfdc..a8c0dfa 100644 --- a/internal/lua/billing_test.go +++ b/internal/lua/billing_test.go @@ -676,3 +676,118 @@ func TestBillingPeakDoesNotDoubleCacheRead(t *testing.T) { got := stateOf(t, ps)["total"].(map[string]interface{})["cost"].(float64) approx(t, "cache-only cost (not doubled)", got, 1e6*1.5e-7*0.02) } + +// TestBillingTracksCacheUsage is the guard for the gap production exposed: the +// gateway had prompt_cache_hit_tokens and costFor() priced the cache leg, but no +// bucket recorded the number. On a gateway where 99.88% of prompt tokens were +// cache reads, the report showed a prompt_tokens figure with no way to tell that +// most of it was cached. +func TestBillingTracksCacheUsage(t *testing.T) { + ps, _ := billingVM(t) + + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "key": "k", "ok": true, + "prompt_tokens": 1000, "completion_tokens": 50, + "cache_hit_tokens": 900, "cache_reported": true, + }) + // A second request from a source that does not report caching at all. + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m2", "source": "s2", "ok": true, + "prompt_tokens": 100, "completion_tokens": 10, + }) + + st := ps.State("billing").(map[string]interface{}) + total := st["total"].(map[string]interface{}) + if total["cache_hit_tokens"] != float64(900) { + t.Errorf("total.cache_hit_tokens = %v, want 900", total["cache_hit_tokens"]) + } + if total["cache_fresh_tokens"] != float64(200) { + t.Errorf("total.cache_fresh_tokens = %v, want 200 (1000-900 + 100)", total["cache_fresh_tokens"]) + } + // Only the first request reported a cache number. + if total["cache_reported_reqs"] != float64(1) { + t.Errorf("★ total.cache_reported_reqs = %v, want 1 — a source that never "+ + "reports cache usage must be distinguishable from one reporting zero hits", + total["cache_reported_reqs"]) + } + + // Per-source separation. + bySrc := st["by_source"].(map[string]interface{}) + s1 := bySrc["s"].(map[string]interface{}) + if s1["cache_hit_tokens"] != float64(900) { + t.Errorf("by_source[s].cache_hit_tokens = %v, want 900", s1["cache_hit_tokens"]) + } + s2 := bySrc["s2"].(map[string]interface{}) + if s2["cache_reported_reqs"] != float64(0) { + t.Errorf("by_source[s2].cache_reported_reqs = %v, want 0", s2["cache_reported_reqs"]) + } + if s2["cache_fresh_tokens"] != float64(100) { + t.Errorf("by_source[s2].cache_fresh_tokens = %v, want 100", s2["cache_fresh_tokens"]) + } +} + +// TestBillingCacheBucketsSurviveOlderStateFiles: a state file written before these +// fields existed must not crash the hook. `nil + number` is an error in Lua, and +// a hook that throws stops accounting for that request entirely — which is how a +// billing gap turns into a silent one. +func TestBillingCacheBucketsSurviveOlderStateFiles(t *testing.T) { + ps, _ := billingVM(t) + // Simulate a state restored from an older build: buckets without the new keys. + legacy := map[string]interface{}{ + "total": map[string]interface{}{ + "cost": 1.0, "requests": float64(5), "prompt_tokens": float64(500), + "completion_tokens": float64(50), "failures": float64(0), + }, + "by_source": map[string]interface{}{ + "legacy": map[string]interface{}{"cost": float64(0), "requests": float64(5), + "prompt_tokens": float64(500), "completion_tokens": float64(50), "failures": float64(0)}, + }, + "by_model": map[string]interface{}{}, "by_key": map[string]interface{}{}, + "by_day": map[string]interface{}{}, "started": float64(0), + } + if err := ps.SetState("billing", legacy); err != nil { + t.Fatalf("SetState: %v", err) + } + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "legacy", "ok": true, + "prompt_tokens": 100, "completion_tokens": 10, + "cache_hit_tokens": 60, "cache_reported": true, + }) + if len(ps.HookErrors()) != 0 { + t.Fatalf("hook error on a legacy state: %v", ps.HookErrors()) + } + st := ps.State("billing").(map[string]interface{}) + tot := st["total"].(map[string]interface{}) + if tot["requests"] != float64(6) { + t.Errorf("requests = %v, want 6 (5 legacy + 1 new)", tot["requests"]) + } + if tot["cache_hit_tokens"] != float64(60) { + t.Errorf("cache_hit_tokens = %v, want 60", tot["cache_hit_tokens"]) + } + lg := st["by_source"].(map[string]interface{})["legacy"].(map[string]interface{}) + if lg["cache_hit_tokens"] != float64(60) { + t.Errorf("legacy bucket cache_hit_tokens = %v, want 60", lg["cache_hit_tokens"]) + } +} + +// TestBillingCacheHitClampedInStats: costFor clamps the cache leg, so the +// recorded numbers must be clamped the same way. A provider that reports more +// cache hits than prompt tokens must not produce negative fresh tokens. +func TestBillingCacheHitClampedInStats(t *testing.T) { + ps, _ := billingVM(t) + ps.Fire(StageRequestEnd, map[string]interface{}{ + "model": "m", "source": "s", "ok": true, + "prompt_tokens": 100, "completion_tokens": 5, + "cache_hit_tokens": 5000, "cache_reported": true, + }) + st := ps.State("billing").(map[string]interface{}) + tot := st["total"].(map[string]interface{}) + if tot["cache_hit_tokens"] != float64(100) { + t.Errorf("★ cache_hit_tokens = %v, want 100 (clamped to prompt_tokens)", + tot["cache_hit_tokens"]) + } + if tot["cache_fresh_tokens"] != float64(0) { + t.Errorf("★ cache_fresh_tokens = %v, want 0, never negative", + tot["cache_fresh_tokens"]) + } +} diff --git a/internal/lua/billing_ui_test.go b/internal/lua/billing_ui_test.go new file mode 100644 index 0000000..877cb42 --- /dev/null +++ b/internal/lua/billing_ui_test.go @@ -0,0 +1,329 @@ +package lua + +import ( + "encoding/json" + "os" + "os/exec" + "regexp" + "strings" + "testing" +) + +// The billing page rendered EMPTY in production while its data endpoint returned +// 200 with real numbers. The cause was one line: render(st) referenced an +// undefined `s` for two KPI cells, so the ReferenceError aborted the whole +// render and every table stayed at its initial empty innerHTML. +// +// Nothing in the build, the tests or the API surfaced it. This file is the guard +// for the whole class: a plugin's injected UI that references an undefined name, +// or that depends on a container the page does not provide, fails silently. + +// billingUI returns the injected markup for the billing plugin: the full page +// mount and the status-page element mount. +func billingUI(t *testing.T) (page string, statusElement string) { + t.Helper() + src, err := os.ReadFile("plugins/billing.lua") + if err != nil { + t.Fatalf("read billing.lua: %v", err) + } + // Located BY CONTENT, not by index. The plugin also uses a long string for + // its inline SVG icon, so "the first long string" is the icon and "the + // second" is the page — which is exactly the kind of positional assumption + // that breaks the next time an icon or a description is added. + page = longStringContaining(t, string(src), "billing-root") + statusElement = longStringContaining(t, string(src), "billing-status-tile") + return page, statusElement +} + +// longStringContaining returns the [==[ ... ]==] body that contains marker. +func longStringContaining(t *testing.T, src, marker string) string { + t.Helper() + re := regexp.MustCompile(`(?s)\[==\[(.*?)\]==\]`) + for _, m := range re.FindAllStringSubmatch(src, -1) { + if strings.Contains(m[1], marker) { + return m[1] + } + } + t.Fatalf("no long string contains %q", marker) + return "" +} + +// TestBillingMountScriptExecutes is the guard for the production bug. +// +// The Billing page rendered empty while its data endpoint returned 200 with real +// numbers. Cause: render(st) referenced an undefined `s` for two KPI cells, the +// ReferenceError aborted the render, and every table kept its initial empty +// innerHTML. Nothing in the build or the API surfaced it. +// +// Two earlier attempts at a static check were both wrong: a "declared names" +// scan flagged every CSS class inside the inline HTML strings (class, div, td), +// and a CSS-selector parse of the stylesheet reported the stylesheet itself as +// broken. Static analysis of JS embedded in HTML strings is the wrong tool. +// +// So this actually RUNS the script, in node, against a minimal DOM stub, and +// fails on any thrown error. Skipped when node is unavailable, with the reason +// printed — never silently passing as if it had checked. +func TestBillingMountScriptExecutes(t *testing.T) { + page, el := billingUI(t) + scripts := extractScripts(page) + if len(scripts) == 0 { + t.Fatal("no `) + var out []string + for _, m := range re.FindAllStringSubmatch(html, -1) { + out = append(out, m[1]) + } + return out +} + +// TestBillingPageCoversItsData is the other half: the page declares tables for +// per-source / per-model / per-key / per-day and must actually render into them. +// A table id that is never written to is exactly how "the page loads and shows +// nothing" happens without an error. +func TestBillingPageCoversItsData(t *testing.T) { + page, _ := billingUI(t) + for _, id := range []string{ + "billing-kpis", "billing-by-source", "billing-by-model", + "billing-by-key", "billing-by-day", + } { + if !strings.Contains(page, `id="`+id+`"`) { + t.Errorf("the page has no container #%s", id) + } + } + // Every container must be written to by the script, not just declared. + scripts := extractScripts(page) + all := strings.Join(scripts, "\n") + for _, id := range []string{"billing-kpis", "billing-by-source", "billing-by-model", "billing-by-key", "billing-by-day"} { + if !strings.Contains(all, `getElementById("`+id+`")`) { + t.Errorf("#%s is declared but never read by the script — it stays empty forever", id) + } + } +} + +// TestPluginIconIsNotARawEmoji guards the sidebar icon. The billing plugin +// declared icon = "💰" and the WebUI drops that verbatim into the button, while +// every native tab uses an inline SVG styled with `stroke: currentColor`. An +// emoji there renders at the wrong size and ignores the theme, so it does not +// match its neighbours — which is what the operator reported. +func TestPluginIconIsNotARawEmoji(t *testing.T) { + icon := billingIcon(mustBillingSource(t)) + if icon == "" { + t.Fatal("billing declares no icon; the sidebar entry would be blank") + } + if isEmojiIcon(icon) { + t.Errorf("page icon is the raw emoji %q — the WebUI sidebar renders "+ + "native tabs as inline SVG (stroke: currentColor), so an emoji is the "+ + "wrong size and ignores the theme. Use an inline SVG path instead.", icon) + } +} + +// isEmojiIcon reports whether s is a pictographic emoji rather than markup or a +// text glyph. Codepoints in the pictographic blocks, plus the regional-indicator +// pair used by flags. +func isEmojiIcon(s string) bool { + r := []rune(s) + if len(r) == 0 { + return false + } + // Anything containing '<' is markup (an inline ), which is the fix. + if strings.ContainsRune(s, '<') { + return false + } + for _, c := range r { + switch { + case c >= 0x1F300 && c <= 0x1FAFF, // pictographs, symbols, supplemental + c >= 0x1F000 && c <= 0x1F2FF, // mahjong/domino/cards + c >= 0x2600 && c <= 0x27BF, // misc symbols + dingbats + c >= 0x2B00 && c <= 0x2BFF, // arrows/misc symbols + c == 0xFE0F, // variation selector-16 + c >= 0x1F1E6 && c <= 0x1F1FF: // regional indicators (flags) + return true + } + } + return false +} + +func mustBillingSource(t *testing.T) string { + t.Helper() + b, err := os.ReadFile("plugins/billing.lua") + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// billingIcon extracts the declared page icon. It accepts BOTH a quoted string +// and a [==[ ... ]==] long string, because an inline SVG cannot be written as a +// Lua short string without escaping every quote in it. +func billingIcon(src string) string { + if m := regexp.MustCompile(`(?m)^\s*icon\s*=\s*"([^"]*)"`).FindStringSubmatch(src); m != nil { + return m[1] + } + if m := regexp.MustCompile(`(?s)\bicon\s*=\s*\[==\[(.*?)\]==\]`).FindStringSubmatch(src); m != nil { + return m[1] + } + return "" +} + +// TestBillingTableHeaderMatchesRowColumns catches column drift. +// +// row() gained cache columns (fresh / cache / cache%) while the header row did +// not, in the same edit. The result is a table whose cells are shifted one +// column left from "fresh" onward — so "cache%" sits under "completion" and the +// last cell has no label. It renders, it has data, and it is wrong in a way that +// takes a careful read to notice. +func TestBillingTableHeaderMatchesRowColumns(t *testing.T) { + page, _ := billingUI(t) + js := strings.Join(extractScripts(page), "\n") + if !strings.Contains(js, "") { + t.Fatal("no table header found in the billing page script") + } + hStart := strings.Index(js, "function tableFor") + if hStart < 0 { + t.Fatal("no tableFor in the billing page script") + } + header := js[hStart:] + th := strings.Count(header, "") + + rStart := strings.Index(js, "function row") + rEnd := strings.Index(js, "function tableFor") + if rStart < 0 || rEnd < 0 || rEnd <= rStart { + t.Fatal("could not isolate row()") + } + row := js[rStart:rEnd] + // Each cell closes with ; the first cell uses .. so + // counting is exact. + td := strings.Count(row, "") + + if th != td { + t.Errorf("★ header declares %d columns but row() emits %d cells — the "+ + "table is misaligned from the first differing column on", th, td) + } +} diff --git a/internal/lua/plugins/billing.lua b/internal/lua/plugins/billing.lua index 314da66..e677ad0 100644 --- a/internal/lua/plugins/billing.lua +++ b/internal/lua/plugins/billing.lua @@ -98,8 +98,25 @@ plugin.cache_discount = 0.1 -- Sorted top-N lists are maintained incrementally rather than re-sorted on -- every request: this hook runs once per request on the hot path, so it does -- map updates only. The sort happens when state is READ. +-- CACHE ACCOUNTING (added after production showed the gap): +-- the gateway extracts prompt_cache_hit_tokens from upstream usage and puts it +-- in the request_end payload, and costFor() already used it to price the cache +-- leg — but no bucket recorded it. So a gateway where 99.88% of prompt tokens +-- were cache reads showed a prompt_tokens number with no indication of that, +-- and there was no way to see cache hit rate per source/model/key at all. +-- +-- cache_hit_tokens hits, as reported by upstream +-- cache_fresh_tokens prompt tokens that were NOT cache reads +-- cache_reported_reqs requests where upstream gave a cache number at all. +-- Kept separate from a zero: "upstream does not report cache usage" and +-- "upstream reported zero hits" look identical in a hit total, and they mean +-- opposite things when you are trying to work out whether a cache discount is +-- doing anything. local function emptyBucket() - return { cost = 0, requests = 0, prompt_tokens = 0, completion_tokens = 0, failures = 0 } + return { + cost = 0, requests = 0, prompt_tokens = 0, completion_tokens = 0, failures = 0, + cache_hit_tokens = 0, cache_fresh_tokens = 0, cache_reported_reqs = 0, + } end plugin.state = { @@ -120,12 +137,21 @@ local function bucket(tbl, k) return b end -local function add(b, cost, prompt, completion, ok) +local function add(b, cost, prompt, completion, ok, cacheHit, cacheReported) b.cost = b.cost + cost b.requests = b.requests + 1 b.prompt_tokens = b.prompt_tokens + prompt b.completion_tokens = b.completion_tokens + completion if not ok then b.failures = b.failures + 1 end + -- Backfill guards a bucket that predates these fields (a state file written + -- by an older build, or one restored from disk): nil + number is an error in + -- Lua, and a hook that throws stops accounting for that request entirely. + if b.cache_hit_tokens == nil then b.cache_hit_tokens = 0 end + if b.cache_fresh_tokens == nil then b.cache_fresh_tokens = 0 end + if b.cache_reported_reqs == nil then b.cache_reported_reqs = 0 end + b.cache_hit_tokens = b.cache_hit_tokens + (cacheHit or 0) + b.cache_fresh_tokens = b.cache_fresh_tokens + ((prompt or 0) - (cacheHit or 0)) + if cacheReported then b.cache_reported_reqs = b.cache_reported_reqs + 1 end end -- ---------- pricing ---------- @@ -393,15 +419,25 @@ function plugin.on_request_end(payload) -- once, whereas the walk may contain several skipped tiers. if payload.degraded then s.degraded_reqs = s.degraded_reqs + 1 end - add(s.total, cost, prompt, completion, ok) + local cacheHit = tonumber(payload.cache_hit_tokens) or 0 + if cacheHit < 0 then cacheHit = 0 end + if cacheHit > prompt then cacheHit = prompt end + -- cache_reported is the gateway's own signal that UPSTREAM gave a cache + -- number. Without it a source that never reports cache usage is + -- indistinguishable from one that always reports zero hits. + local cacheReported = payload.cache_reported and true or false + local C = cacheHit + local R = cacheReported + + add(s.total, cost, prompt, completion, ok, C, R) if payload.source ~= nil and payload.source ~= "" then - add(bucket(s.by_source, payload.source), cost, prompt, completion, ok) + add(bucket(s.by_source, payload.source), cost, prompt, completion, ok, C, R) end if payload.model ~= nil and payload.model ~= "" then - add(bucket(s.by_model, payload.model), cost, prompt, completion, ok) + add(bucket(s.by_model, payload.model), cost, prompt, completion, ok, C, R) end if payload.key ~= nil and payload.key ~= "" then - add(bucket(s.by_key, payload.key), cost, prompt, completion, ok) + add(bucket(s.by_key, payload.key), cost, prompt, completion, ok, C, R) end -- Daily rollup, so the dashboard can draw a trend without the browser @@ -410,7 +446,7 @@ function plugin.on_request_end(payload) local ts = payload.time if ts ~= nil and ts > 0 then if ts > 1000000000000 then ts = ts / 1000 end -- kernel sends unix MILLIseconds - add(bucket(s.by_day, dayKey(ts)), cost, prompt, completion, ok) + add(bucket(s.by_day, dayKey(ts)), cost, prompt, completion, ok, C, R) end return nil -- last stage: nobody downstream would read a return value end @@ -423,7 +459,7 @@ plugin.ui = { page = { page_id = "billing", title = "Billing", - icon = "💰", + icon = [==[]==], order = 40, mount = [==[
@@ -464,9 +500,25 @@ plugin.ui = { return { "&": "&", "<": "<", ">": ">", '"': """ }[c]; }); } + // Cache hit rate, with the reporting caveat made visible. + // + // A bucket whose upstream never reports cache usage would render as "0%" from + // a 0/0 and read as "the cache is not working", when the truth is "this + // provider does not tell us". "n/r" keeps those apart. + function cacheRate(b) { + var prompt = b.prompt_tokens || 0; + var hit = b.cache_hit_tokens || 0; + if (!prompt) return "\u2014"; + if (!b.cache_reported_reqs) return "n/r"; + return ((hit / prompt) * 100).toFixed(1) + "%"; + } function row(name, b, cur) { + var fresh = (b.cache_fresh_tokens === undefined) ? (b.prompt_tokens || 0) : b.cache_fresh_tokens; return "" + esc(name) + "" + money(b.cost, cur) + "" + (b.requests || 0) + "" + (b.prompt_tokens || 0) + + "" + fresh + + "" + (b.cache_hit_tokens || 0) + + "" + esc(cacheRate(b)) + "" + (b.completion_tokens || 0) + ""; } function tableFor(el, obj, cur, empty) { @@ -475,7 +527,8 @@ plugin.ui = { keys.sort(function (a, b) { return (obj[b].cost || 0) - (obj[a].cost || 0); }); var h = "" + "" + - ""; + "" + + ""; for (var i = 0; i < keys.length; i++) { var k = keys[i]; h += "" + row(k, obj[k], cur) + ""; @@ -489,8 +542,8 @@ plugin.ui = { document.getElementById("billing-kpis").innerHTML = [ ["Total", money(t.cost, cur)], ["Requests", t.requests || 0], - ["Degraded", s.degraded_reqs || 0], - ["Unpriced", s.unpriced_reqs || 0], + ["Degraded", st.degraded_reqs || 0], + ["Unpriced", st.unpriced_reqs || 0], ["Prompt tokens", t.prompt_tokens || 0], ["Completion tokens", t.completion_tokens || 0], ["Failures", t.failures || 0] @@ -510,7 +563,14 @@ plugin.ui = { if (!r.ok) return; var j = await r.json(); render(j.state); - } catch (e) { /* the pane is optional decoration; never break the page */ } + } catch (e) { + // Swallowing this is what made the production bug invisible: render() threw + // a ReferenceError on an undefined `s`, the catch ate it, every table kept + // its empty placeholder, and the page looked fine in the network tab while + // showing nothing. Still must not THROW (the pane is decoration and must + // never break the host page) — but it must leave a trace. + if (window.console && console.error) console.error("[billing] render failed", e); + } } window.__billingRefresh = refresh; refresh(); @@ -560,7 +620,12 @@ plugin.ui = { } document.getElementById("billing-status-sub").textContent = (st.total.requests || 0) + " requests" + (parts.length ? " · top: " + parts.join(" · ") : ""); - } catch (e) { /* decoration only */ } + } catch (e) { + // Same reasoning as the Billing page: decoration must never break the + // host page, but a silent catch turns a broken widget into "the plugin + // just doesn't show anything" with no way to tell why. + if (window.console && console.error) console.error("[billing] status tile refresh failed", e); + } } if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(tick); tick();
namecostreqspromptcompletion
promptfreshcachecache%completion