diff --git a/README.md b/README.md index 95d4c25..b6cd9db 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,32 @@ The extension will automatically attempt to locate the `codexbar` binary in comm Certain vendors have specific fields in Codexbar CLI, whose interpretation may not have been implemented so this is a great opportunity for you to implement support (if you want) and do a PR. +### API Keys (e.g. OpenRouter) + +Some providers (OpenRouter with `--source api`) authenticate the CodexBar CLI via an environment variable, e.g. `OPENROUTER_API_KEY`, instead of a token cached on disk. Setting that variable in `~/.zshrc`, `~/.bashrc`, or similar is **not enough**: GNOME Shell is started by your login/display manager, not by an interactive shell, so it never sources your shell's dotfiles, and any command the extension runs (as a child process of GNOME Shell) inherits GNOME Shell's environment, not your terminal's. + +To make the variable visible to the extension, add it to your systemd user environment instead: + +```bash +mkdir -p ~/.config/environment.d +echo 'OPENROUTER_API_KEY=sk-or-v1-...' > ~/.config/environment.d/codexbar.conf +chmod 600 ~/.config/environment.d/codexbar.conf +``` + +Then log out and log back in. `environment.d` files are only read once, when your `systemd --user` manager starts — if it's still running from before you added the file (e.g. lingering is enabled: `loginctl show-user $USER | grep Linger`), a normal logout/login won't pick it up. In that case, apply it to the running instance once, then log out/in as usual: + +```bash +systemctl --user import-environment OPENROUTER_API_KEY +``` + +You can confirm GNOME Shell actually has the variable with: + +```bash +tr '\0' '\n' < /proc/$(pgrep -x gnome-shell)/environ | grep OPENROUTER_API_KEY +``` + +Without it, `codexbar --provider openrouter --source api` still returns valid JSON, but with a top-level `error` field and no `usage.details` — so the OpenRouter tab shows only a bare, empty tier instead of the Credits / API key / Spend history breakdown. + ### Display Mode You can choose how metrics are displayed: diff --git a/extension.js b/extension.js index a0a0b1c..8d382d9 100644 --- a/extension.js +++ b/extension.js @@ -4,13 +4,19 @@ import Gio from "gi://Gio"; import GLib from "gi://GLib"; import St from "gi://St"; import Clutter from "gi://Clutter"; +import Pango from "gi://Pango"; import { Extension, gettext as _, } from "resource:///org/gnome/shell/extensions/extension.js"; import * as PanelMenu from "resource:///org/gnome/shell/ui/panelMenu.js"; import * as Main from "resource:///org/gnome/shell/ui/main.js"; -import { calculateUsagePace, UsageApiClient } from "./usageApi.js"; +import { + calculateUsagePace, + deriveCreditsPercent, + normalizeDetailSections, + UsageApiClient, +} from "./usageApi.js"; import { loadToken, nullTokenSchema } from "./secret.js"; function logDev(msg) { @@ -121,6 +127,7 @@ export default class CodexBarExtension extends Extension { "changed::display-mode", () => this._updateUI(), "changed::show-logos", () => this._updateUI(), "changed::show-pacing-info", () => this._updateUI(), + "changed::show-provider-details", () => this._updateUI(), "changed::first-run", () => this._updateUI(), "changed::dev-custom-output-enabled", () => this._onSettingsChanged(), "changed::dev-custom-output-provider-name", () => this._onSettingsChanged(), @@ -536,13 +543,25 @@ export default class CodexBarExtension extends Extension { let tierCount = 0; const activeData = this._providersData[this._activeProviderIndex]; + // A tier with usedPercent:0 and no windowSeconds isn't a real usage window + // (e.g. OpenRouter's balance placeholder) - derive a meaningful percent from + // the Credits detail section instead, or drop the tier if none is available. + const creditsPercent = deriveCreditsPercent( + normalizeDetailSections(activeData?.data?.usage?.details), + ); + const isDegenerateTier = (tierData) => + !!tierData && !tierData.windowSeconds && tierData.usedPercent === 0; + if (activeData && activeData.data && activeData.data.usage) { const usage = activeData.data.usage; const tiers = ["primary", "secondary", "tertiary", "quaternary"]; tiers.forEach((tier) => { if (usage[tier] && usage[tier].usedPercent !== undefined) { - let p = this._normalizePercent(usage[tier].usedPercent); + if (isDegenerateTier(usage[tier]) && creditsPercent === null) return; + let p = isDegenerateTier(usage[tier]) + ? creditsPercent + : this._normalizePercent(usage[tier].usedPercent); totalPercent += displayMode === "remaining" ? 100 - p : p; tierCount++; } @@ -742,19 +761,31 @@ export default class CodexBarExtension extends Extension { this._contentBox.add_child(accountBox); } + this._renderDetailSections(usage); + // Usage bars for each tier // Barras de uso para cada nivel const tiers = ["primary", "secondary", "tertiary", "quaternary"]; const discoveredLabels = activeData.labels || []; let hasTiers = false; - const usageEntries = tiers.map((tier, tierIdx) => ({ - data: usage[tier], - showPace: true, - title: + const usageEntries = tiers.map((tier, tierIdx) => { + let tierData = usage[tier]; + let title = discoveredLabels[tierIdx] || - tier.charAt(0).toUpperCase() + tier.slice(1), - })); + tier.charAt(0).toUpperCase() + tier.slice(1); + + if (isDegenerateTier(tierData)) { + if (creditsPercent === null) { + tierData = null; + } else { + tierData = { ...tierData, usedPercent: creditsPercent }; + title = _("Credits"); + } + } + + return { data: tierData, showPace: true, title }; + }); usageEntries.push({ data: usage.codeReview, showPace: false, @@ -936,6 +967,78 @@ export default class CodexBarExtension extends Extension { } } + /** + * Render provider-supplied detail sections (codexbar `usage.details`). + * @param {object} usage + */ + _renderDetailSections(usage) { + if (!this._settings.get_boolean("show-provider-details")) return; + + const sections = normalizeDetailSections(usage?.details); + if (sections.length === 0) return; + + const detailsBox = new St.BoxLayout({ + vertical: true, + style_class: "codexbar-details-section", + x_expand: true, + }); + + sections.forEach((section) => { + const groupBox = new St.BoxLayout({ + vertical: true, + style_class: "codexbar-detail-group", + x_expand: true, + }); + + if (section.title) { + groupBox.add_child( + new St.Label({ + text: section.title, + style_class: "codexbar-detail-title", + }), + ); + } + + section.rows.forEach((row) => { + const rowBox = new St.BoxLayout({ vertical: false, x_expand: true }); + + const labelWidget = new St.Label({ + text: row.label, + style_class: "codexbar-detail-label", + }); + labelWidget.clutter_text.ellipsize = Pango.EllipsizeMode.END; + labelWidget.opacity = 200; + rowBox.add_child(labelWidget); + + const valueWidget = new St.Label({ + text: row.value, + style_class: "codexbar-detail-value", + x_align: Clutter.ActorAlign.END, + x_expand: true, + }); + valueWidget.clutter_text.ellipsize = Pango.EllipsizeMode.END; + rowBox.add_child(valueWidget); + + groupBox.add_child(rowBox); + + if (row.secondaryValue) { + const noteWidget = new St.Label({ + text: row.secondaryValue, + style_class: "codexbar-detail-note", + }); + noteWidget.clutter_text.line_wrap = true; + noteWidget.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + noteWidget.opacity = 160; + groupBox.add_child(noteWidget); + } + }); + + detailsBox.add_child(groupBox); + }); + + this._contentBox.add_child(detailsBox); + } + /** * Get provider logo as an St.Icon. * Obtener el logo del proveedor como un St.Icon. diff --git a/prefs.js b/prefs.js index f361bb0..98efa08 100644 --- a/prefs.js +++ b/prefs.js @@ -434,6 +434,21 @@ const CodexBarPrefsPage = GObject.registerClass( ); group.add(showPacingInfoRow); + const showProviderDetailsRow = new Adw.SwitchRow({ + title: _("Show Provider Details"), + subtitle: _( + "Display the extra detail sections reported by the provider CLI (credits, spend history, rate limits)", + ), + active: this._settings.get_boolean("show-provider-details"), + }); + this._settings.bind( + "show-provider-details", + showProviderDetailsRow, + "active", + Gio.SettingsBindFlags.DEFAULT, + ); + group.add(showProviderDetailsRow); + return group; } diff --git a/schemas/org.gnome.shell.extensions.codexbar.gschema.xml b/schemas/org.gnome.shell.extensions.codexbar.gschema.xml index 02e6fe2..059355b 100644 --- a/schemas/org.gnome.shell.extensions.codexbar.gschema.xml +++ b/schemas/org.gnome.shell.extensions.codexbar.gschema.xml @@ -30,6 +30,10 @@ true Whether to show weekly usage pacing details + + true + Whether to show provider-supplied detail sections + false Enable developer custom output simulation diff --git a/stylesheet.css b/stylesheet.css index 67fa7e4..f94c2dd 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -91,6 +91,39 @@ margin-top: 2px; } +.codexbar-details-section { + margin-bottom: 8px; +} + +.codexbar-detail-group { + margin-bottom: 10px; +} + +.codexbar-detail-title { + font-size: 0.9em; + font-weight: bold; + margin-bottom: 4px; +} + +.codexbar-detail-label { + font-size: 0.8em; + margin-bottom: 1px; +} + +.codexbar-detail-value { + font-size: 0.8em; + font-weight: bold; + margin-bottom: 1px; + padding-left: 12px; +} + +.codexbar-detail-note { + font-size: 0.75em; + font-style: italic; + margin-left: 10px; + margin-bottom: 3px; +} + .codexbar-footer { padding: 10px; border-top: 1px solid rgba(255, 255, 255, 0.1); diff --git a/test_all_providers.js b/test_all_providers.js index b12b229..5cb721e 100644 --- a/test_all_providers.js +++ b/test_all_providers.js @@ -1,6 +1,8 @@ import { calculateUsagePace, + deriveCreditsPercent, formatResetDescription, + normalizeDetailSections, UsageApiClient, } from "./usageApi.js"; @@ -44,18 +46,53 @@ const codexSparkUsage = { ], }; +// OpenRouter CLI (v0.55.0): `codexbar --provider openrouter --source api --format json`. +// Hoisted to module scope so the testCases entry below and the top-level +// normalizeDetailSections assertions share the same fixture. +const openRouterDetailsPayload = { + loginMethod: "Balance: $0.82", + primary: { usedPercent: 0 }, + details: [ + { + title: "Credits", + rows: [ + { label: "Remaining", value: "$0.82" }, + { label: "Used", value: "$9.18" }, + { label: "Total added", value: "$10.00" }, + ], + }, + { + title: "API key", + rows: [ + { label: "API key budget", value: "$5.00" }, + { label: "API key remaining", value: "$5.00" }, + { label: "API key used", value: "$0.94" }, + { label: "Reset window", value: "weekly" }, + { label: "Today", value: "$0.12" }, + { label: "This week", value: "$0.45" }, + { label: "This month", value: "$0.94" }, + { label: "Rate limit", value: "-1 requests / 10s" }, + ], + chart: { kind: "bars", unit: "USD", points: [0.12, 0.45, 0.94] }, + }, + { + title: "Spend history", + rows: [ + { + label: "Last 30 days", + value: "Unavailable right now", + secondaryValue: "Management API key not configured", + }, + ], + }, + ], +}; + const testCases = [ { name: "OpenRouter (User reported)", - data: { - loginMethod: "Balance: $35.05", - openRouterUsage: { - balance: 35.05187273999999, - totalCredits: 160, - totalUsage: 124.94812726, - usedPercent: 78.0925795375, - }, - }, + data: openRouterDetailsPayload, + expectedUsedPercent: 0, }, { name: "OpenAI / Codex (Standard)", @@ -403,3 +440,119 @@ if (ollamaSummary.usage.loginMethod !== "Ollama Cloud Pro") { throw new Error(`Expected Ollama Cloud Pro login method, got ${ollamaSummary.usage.loginMethod}`); } console.log("✓ Ollama Cloud HTML parser extracts Session and Weekly usage"); + +// OpenRouter details: normalizeSummary must pass usage.details through untouched, +// and normalizeDetailSections must sanitize it into a flat, render-safe shape. +const normalizedOpenRouter = client.normalizeSummary(openRouterDetailsPayload, false); +if ( + !Array.isArray(normalizedOpenRouter.usage.details) || + normalizedOpenRouter.usage.details.length !== 3 +) { + throw new Error( + `Expected normalizeSummary to pass through 3 detail sections, got ${JSON.stringify(normalizedOpenRouter.usage.details)}`, + ); +} +console.log("✓ usage.details survives normalizeSummary untouched"); + +const openRouterSections = normalizeDetailSections(normalizedOpenRouter.usage.details); +if (openRouterSections.length !== 3) { + throw new Error(`Expected 3 sanitized detail sections, got ${openRouterSections.length}`); +} +const rowCounts = openRouterSections.map((s) => s.rows.length).join(","); +if (rowCounts !== "3,8,1") { + throw new Error(`Expected section row counts "3,8,1", got "${rowCounts}"`); +} +if ( + openRouterSections[0].rows[0].label !== "Remaining" || + openRouterSections[0].rows[0].value !== "$0.82" +) { + throw new Error( + `Expected first Credits row to be Remaining/$0.82, got ${JSON.stringify(openRouterSections[0].rows[0])}`, + ); +} +console.log("✓ normalizeDetailSections produces the correct sections and rows"); + +if (!openRouterSections[1].hasChart || "chart" in openRouterSections[1]) { + throw new Error( + "Expected the API key section to record hasChart:true without leaking the raw chart", + ); +} +console.log("✓ chart is detected via hasChart but not leaked into the sanitized shape"); + +if ( + openRouterSections[2].rows[0].secondaryValue !== + "Management API key not configured" +) { + throw new Error( + `Expected Spend history secondaryValue to be preserved, got "${openRouterSections[2].rows[0].secondaryValue}"`, + ); +} +console.log("✓ secondaryValue is preserved on sanitized rows"); + +// Defensive shapes: anything that isn't a usable details array normalizes to []. +[undefined, null, "nope", 42, {}, { rows: [] }].forEach((input) => { + const result = normalizeDetailSections(input); + if (result.length !== 0) { + throw new Error( + `Expected normalizeDetailSections(${JSON.stringify(input)}) to be [], got ${JSON.stringify(result)}`, + ); + } +}); +console.log("✓ normalizeDetailSections defends against non-array and malformed inputs"); + +// A section with only a chart (no rows) is dropped, not rendered as a bare title. +if (normalizeDetailSections([{ title: "Chart only", chart: { kind: "bars" } }]).length !== 0) { + throw new Error("Expected a chart-only section with no rows to be dropped"); +} +console.log("✓ chart-only sections with no rows are dropped"); + +// Value coercion: 0 must render as "0" (not "" via a naive `row.value || ""` guard), +// NaN/objects must coerce to "", and rows need at least a label or a value to survive. +const coercionRows = normalizeDetailSections([ + { + title: "Coercion", + rows: [ + { label: "num", value: 0 }, + { label: "nan", value: NaN }, + { label: "obj", value: {} }, + { label: "", value: "" }, + { label: "b", value: true }, + ], + }, +])[0].rows; +if (coercionRows.length !== 4) { + throw new Error(`Expected 4 surviving rows after coercion, got ${coercionRows.length}`); +} +if (coercionRows[0].value !== "0") { + throw new Error(`Expected numeric 0 to coerce to "0", got "${coercionRows[0].value}"`); +} +if (coercionRows[1].value !== "") { + throw new Error(`Expected NaN to coerce to "", got "${coercionRows[1].value}"`); +} +if (coercionRows[2].value !== "") { + throw new Error(`Expected an object value to coerce to "", got "${coercionRows[2].value}"`); +} +console.log("✓ row value coercion handles 0, NaN, objects, and empty rows correctly"); + +// Claude regression guard: providers with no `details` key must be unaffected. +if (normalizeDetailSections(normalizedDashboard.usage.details).length !== 0) { + throw new Error("Expected a provider with no details key to produce zero sections"); +} +console.log("✓ providers without usage.details are unaffected (Claude/Codex regression guard)"); + +// deriveCreditsPercent: OpenRouter's degenerate {usedPercent:0, windowSeconds:0} +// "Usage Window" tier is meaningless (it reads "100% left" no matter the real +// balance); derive a real percent from the Credits section's Used/Total added. +const creditsPercent = deriveCreditsPercent(openRouterSections); +if (creditsPercent === null || Math.abs(creditsPercent - 91.8) > 0.0001) { + throw new Error(`Expected Credits-derived percent of 91.8, got ${creditsPercent}`); +} +console.log("✓ deriveCreditsPercent computes used% from the Credits section's Used/Total added"); + +if (deriveCreditsPercent([]) !== null) { + throw new Error("Expected deriveCreditsPercent([]) to be null (no Credits section)"); +} +if (deriveCreditsPercent(normalizeDetailSections(normalizedDashboard.usage.details)) !== null) { + throw new Error("Expected deriveCreditsPercent to be null for a provider with no details"); +} +console.log("✓ deriveCreditsPercent is null when there's no parseable Credits section"); diff --git a/usageApi.js b/usageApi.js index 7e59cc1..cd7b7e9 100644 --- a/usageApi.js +++ b/usageApi.js @@ -146,6 +146,96 @@ export const formatResetDescription = (seconds, windowSeconds, now = new Date()) return `Resets at ${resetStr} (in ${hours}h)`; }; +/** + * Sanitize the provider-supplied `details` array from the codexbar CLI into a + * flat, render-safe shape. Charts are intentionally dropped (not rendered yet). + * @param {unknown} details + * @returns {Array<{title: string, rows: Array<{label: string, value: string, secondaryValue: string}>, hasChart: boolean}>} + */ +export function normalizeDetailSections(details) { + if (!Array.isArray(details)) return []; + + const toText = (value) => { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''; + if (typeof value === 'boolean') return String(value); + return ''; // null, undefined, objects, arrays -> not renderable + }; + + const sections = []; + details.forEach((section) => { + if (!section || typeof section !== 'object') return; + + const rows = []; + if (Array.isArray(section.rows)) { + section.rows.forEach((row) => { + if (!row || typeof row !== 'object') return; + const label = toText(row.label); + const value = toText(row.value); + if (!label && !value) return; + rows.push({ label, value, secondaryValue: toText(row.secondaryValue) }); + }); + } + + if (rows.length === 0) return; // chart-only / empty -> drop section + + sections.push({ + title: toText(section.title), + rows, + hasChart: Boolean(section.chart && typeof section.chart === 'object'), + }); + }); + + return sections; +} + +/** + * Derive a used-percent from a sanitized "Credits" detail section (see + * normalizeDetailSections), for providers that report a balance instead of a + * time-bounded usage window (e.g. OpenRouter's `{usedPercent: 0, windowSeconds: 0}` + * placeholder tier). Returns null if no Credits section or no parseable + * Used/Total or Remaining/Total pair is found. + * @param {Array<{title: string, rows: Array<{label: string, value: string}>}>} sections + * @returns {number|null} + */ +export function deriveCreditsPercent(sections) { + if (!Array.isArray(sections)) return null; + + const parseAmount = (value) => { + if (typeof value !== 'string') return NaN; + const match = value.replace(/,/g, '').match(/-?\d+(\.\d+)?/); + return match ? parseFloat(match[0]) : NaN; + }; + + const creditsSection = sections.find( + (section) => section && typeof section.title === 'string' && + section.title.trim().toLowerCase() === 'credits' + ); + if (!creditsSection || !Array.isArray(creditsSection.rows)) return null; + + const findAmount = (label) => { + const row = creditsSection.rows.find( + (r) => r && typeof r.label === 'string' && r.label.trim().toLowerCase() === label + ); + return row ? parseAmount(row.value) : NaN; + }; + + const total = findAmount('total added'); + if (!Number.isFinite(total) || total <= 0) return null; + + const used = findAmount('used'); + if (Number.isFinite(used)) { + return Math.min(100, Math.max(0, (used / total) * 100)); + } + + const remaining = findAmount('remaining'); + if (Number.isFinite(remaining)) { + return Math.min(100, Math.max(0, ((total - remaining) / total) * 100)); + } + + return null; +} + export const calculateUsagePace = (usageWindow) => { const usedPercent = Number(usageWindow?.usedPercent); const windowSeconds = Number(usageWindow?.windowSeconds);