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);