diff --git a/opencodex-bar/README.md b/opencodex-bar/README.md new file mode 100644 index 00000000..96540bcf --- /dev/null +++ b/opencodex-bar/README.md @@ -0,0 +1,58 @@ +# OpenCodexBar + +[OpenCodex](https://github.com/lidge-jun/opencodex) account, quota, usage, and account-routing control for Noctalia. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `wy3z/opencodex-bar` | +| Entries | Bar widget: `usage`; panel: `panel`; service: `service` | + +Only `service` contacts OpenCodex. Widget and panel use shared state and never see the credential. + +## Requirements + +- Noctalia v5, plugin API 24 +- OpenCodex 2.31.0, with the Management API enabled +- OpenCodex admin token in the Noctalia process's `OPENCODEX_ADMIN_AUTH_TOKEN` or the file at `admin_token_file` (default `~/.opencodex/admin-api-token`). Env wins. +- `xdg-open` on `PATH` (from `xdg-utils`) for the dashboard button + +## Usage + +Install from the Noctalia plugin store and add the `usage` widget to a bar. Click the widget, or: + +```sh +noctalia msg panel-toggle wy3z/opencodex-bar:panel +``` + +- Accounts: subscription plans, health, reauth, quota windows, active Codex account selection, and confirmed reset-credit use +- Usage: today, 30-day request grid, provider/model totals, estimated cost + +Right-click the widget or use Refresh to force a quota refresh. The link button runs `xdg-open` on `base_url`. + +## Settings + +| Setting | Scope | Type | Default | Description | +| --- | --- | --- | --- | --- | +| `base_url` | Plugin | `string` | `http://127.0.0.1:10100` | API and dashboard URL. HTTP on loopback only; HTTPS otherwise. | +| `admin_token_file` | Plugin | `file` | `~/.opencodex/admin-api-token` | Token file path. Overridden by `OPENCODEX_ADMIN_AUTH_TOKEN` in Noctalia's environment. | +| `poll_seconds` | Plugin | `int` | `30` | Cached poll interval, 10–300 s. | +| `force_refresh_minutes` | Plugin | `int` | `10` | Quota refresh interval, 5–60 min. | +| `hidden_providers` | Plugin | `string` | empty | Comma-separated ids to hide (`openai`/`codex` are aliases). Does not change OpenCodex routing. | +| `theme_colors` | Plugin | `bool` | `false` | Use Noctalia colours instead of the OpenCodex palette. | +| `show_percentage` | Widget | `bool` | `true` | Mean quota used next to the icon. | +| `icon_source` | Widget | `select` | `bars` | `bars`, `active`, or `fixed`. | +| `glyph` | Widget | `glyph` | `brand-openai` | Used when `icon_source` is `fixed`. | + +Disabled OpenCodex providers are hidden the same way. Hidden providers are stripped from every figure. Cached-input and reasoning-output totals are omitted when anything is hidden (OpenCodex does not attribute them). + +## Notes + +- Network: authenticated Management API requests to `base_url` (`X-OpenCodex-API-Key`). Polling uses `GET`; confirmed account actions use `PUT /api/codex-auth/active` and `POST /api/codex-auth/reset-credits/consume`. Non-loopback HTTP is refused. +- Credential: env, then file. If neither provides a valid token, OpenCodex rejects Management API requests. The credential stays in the service; it is not shown, written, or published to plugin state. +- Files: reads the token file. Writes nothing locally. Account selection and reset-credit use mutate OpenCodex state only after an in-panel confirmation. +- Process: `xdg-open` with the dashboard URL. Nothing else is spawned. +- Daily costs are estimates, not invoices. The grid is request volume, not spend. +- Bar % is the mean of each visible account's busiest quota window. +- Accounts are labelled alias / log label / "Main Account" / OpenCodex id — never email. Subscription plans use the values reported by OpenCodex. diff --git a/opencodex-bar/common.luau b/opencodex-bar/common.luau new file mode 100644 index 00000000..805c26bf --- /dev/null +++ b/opencodex-bar/common.luau @@ -0,0 +1,102 @@ +--!nonstrict +-- Helpers the bar widget and the panel both need. Kept in one place so the two +-- surfaces cannot drift apart on which mark belongs to a provider or on how an +-- account's usage is derived. + +local common = {} + +function common.themeColors() + return noctalia.getConfig("theme_colors") == true +end + +-- Providers the user has chosen not to see, as a comma-separated list of ids +-- ("anthropic, xai"). A plugin setting cannot enumerate providers it only +-- learns about at runtime, so this is free text; matching is case-insensitive +-- and tolerates the label OpenCodex shows ("OpenAI Codex" -> "openai codex") +-- only insofar as the id is what OpenCodex reports. +-- +-- Unlike a provider disabled in OpenCodex itself, this hides the provider from +-- this plugin alone: OpenCodex keeps routing through it. +local hiddenCache, hiddenCacheSource = {}, nil + +function common.hiddenProviders() + local raw = noctalia.getConfig("hidden_providers") + if type(raw) ~= "string" then raw = "" end + if raw == hiddenCacheSource then return hiddenCache end + + local set = {} + for token in raw:gmatch("[^,%s]+") do + set[token:lower()] = true + end + -- Codex-login usage is filed under either id depending on the endpoint, so + -- naming one has to hide the other. + if set.openai then set.codex = true end + if set.codex then set.openai = true end + + hiddenCache, hiddenCacheSource = set, raw + return set +end + +function common.isHidden(id) + return common.hiddenProviders()[tostring(id or ""):lower()] == true +end + +-- The one test both surfaces use: a provider is shown when OpenCodex has it +-- enabled and the user has not hidden it here. +function common.providerVisible(provider) + return provider.enabled == true and not common.isHidden(provider.id) +end + +-- The worst window in a quota, or nil when it reports none. +function common.maxQuota(quota) + local maximum = nil + for _, window in ipairs((quota and quota.windows) or {}) do + if type(window.usedPercent) == "number" and (maximum == nil or window.usedPercent > maximum) then + maximum = window.usedPercent + end + end + return maximum +end + +function common.anyAccountQuota(provider) + for _, account in ipairs(provider.accounts or {}) do + if account.quota ~= nil then return true end + end + return false +end + +-- An account's own worst window, or its provider's when OpenCodex only reports +-- quota at the provider level (xAI does this). +function common.accountUsed(provider, account, providerHasAccountQuota) + if account.quota ~= nil then return common.maxQuota(account.quota) end + if providerHasAccountQuota then return nil end + return common.maxQuota(provider.quota) +end + +-- Tabler glyphs already bundled with Noctalia. Unknown providers deliberately +-- use the generic robot rather than importing artwork from OpenCodex. +local PROVIDER_GLYPHS = { + codex = "brand-openai", + openai = "brand-openai", + grok = "brand-x", + xai = "brand-x", + gemini = "brand-google", + google = "brand-google", + vertex = "brand-google", + copilot = "brand-github", + github = "brand-github", +} + +function common.providerGlyph(id) + local key = tostring(id or ""):lower() + local named = PROVIDER_GLYPHS[key] + if named ~= nil then return named end + + -- Deployments sometimes suffix ids, for example "openai_work". + for provider, glyph in pairs(PROVIDER_GLYPHS) do + if key:sub(1, #provider) == provider then return glyph end + end + return nil +end + +return common diff --git a/opencodex-bar/panel.luau b/opencodex-bar/panel.luau new file mode 100644 index 00000000..faa027d9 --- /dev/null +++ b/opencodex-bar/panel.luau @@ -0,0 +1,955 @@ +--!nonstrict +-- Native Noctalia panel for OpenCodex. It consumes the service snapshot and +-- never talks to OpenCodex itself. Layout follows CodexBar: every limit is one +-- row with a progress bar and a reset countdown. + +local common = require("./common.luau") + +-- Breathing room between sections. A separator applies its spacing above and +-- below the rule, so this is half the gap you actually see. +local SECTION_SPACING = 9 + +-- Mirrors the OpenCodex dashboard: its quota bar is green below the warn +-- threshold (`threshold: 80` in the GUI), amber at or above it, and a window is +-- "exhausted" at >= 99.5 (gui: `Kr(e){return e>=99.5}`). The hexes are its own +-- --green / --amber / --red custom properties, each with a light and dark value. +local WARN_USED = 80 +local EXHAUSTED_USED = 99.5 + +local TONES = { + ok = { light = "#0a7d5c", dark = "#4ecb9d" }, + warn = { light = "#9a4a08", dark = "#fbbf24" }, + over = { light = "#b91c1c", dark = "#f87171" }, +} + +-- The `theme_colors` setting swaps OpenCodex's palette for Noctalia's own +-- roles, so the plugin can sit inside a colour scheme instead of beside it. +local THEME_TONES = { ok = "primary", warn = "tertiary", over = "error" } + +local function tone(name) + if common.themeColors() then + return THEME_TONES[name] or THEME_TONES.ok + end + local pair = TONES[name] or TONES.ok + return noctalia.isDarkMode() and pair.dark or pair.light +end + +-- Contribution-grid shading: one ramp of the "ok" tone. A role token takes an +-- alpha suffix ("primary/0.25"); a literal hex needs an 8-digit form instead. +local GRID_ALPHA_HEX = { "40", "73", "b3", "ff" } +local GRID_ALPHA_ROLE = { "/0.25", "/0.45", "/0.7", "" } + +local function gridFill(step) + if common.themeColors() then + return THEME_TONES.ok .. GRID_ALPHA_ROLE[step] + end + return tone("ok") .. GRID_ALPHA_HEX[step] +end + +local currentSnapshot = noctalia.state.get("snapshot") or { + status = "loading", + providers = {}, +} +local currentAction = noctalia.state.get("action") or { status = "idle" } +local activeTab = "usage" +local panelOpen = false +local renderPanel +local hoveredUsageDate = nil +local selectedUsageDate = nil +local pendingAccountAction = nil +local hoveredAccountAction = nil + +-- Translation catalogs are cached by some Noctalia builds while a development +-- plugin is hot-reloaded. Keep new action controls readable until the next full +-- plugin reload instead of exposing a raw key such as "action.use_reset". +local function trOr(key, fallback, substitutions) + local translated = noctalia.tr(key, substitutions) + return translated == key and fallback or translated +end + +local function refresh() + noctalia.state.set("command", { type = "refresh", nonce = noctalia.nowMs() }) +end + +-- OpenCodex serves its dashboard from the proxy root, so the configured base +-- URL is the address to open (`ocx gui` opens exactly this). +-- +-- Only a bare scheme://host[:port] is a valid dashboard address. A base_url +-- with a path or anything odd simply hides the button. +local function dashboardUrl() + local url = noctalia.getConfig("base_url") + if type(url) ~= "string" then return nil end + url = noctalia.string.trim(url):gsub("/+$", "") + if url:match("^https?://[%w%.%-]+$") or url:match("^https?://[%w%.%-]+:%d+$") + or url:match("^https?://%[[^%]]+%]$") or url:match("^https?://%[[^%]]+%]:%d+$") then + return url + end + return nil +end + +local function openDashboard() + local url = dashboardUrl() + if url == nil then return end + -- plugin API 24 executes argument arrays directly, so the URL never passes + -- through a shell. + noctalia.runAsync({ "xdg-open", url }) +end + +local function enabledProviders() + local result = {} + for _, provider in ipairs(currentSnapshot.providers or {}) do + if common.providerVisible(provider) then table.insert(result, provider) end + end + return result +end + +-- A provider disabled in OpenCodex, or hidden by the `hidden_providers` +-- setting, is gone from the accounts tab and from the bar widget, so its usage +-- must not reappear here either. For the OpenCodex side the test is +-- "explicitly present and disabled" rather than "not in the enabled list": +-- /api/usage reports ids the provider list need not carry at all, and those +-- should still be shown. +local function hiddenProviderIds() + local hidden = {} + for _, provider in ipairs(currentSnapshot.providers or {}) do + if not provider.enabled then hidden[tostring(provider.id):lower()] = true end + end + -- Codex-login usage is filed under either id depending on the endpoint; the + -- snapshot only ever carries "openai". + if hidden.openai then hidden.codex = true end + if hidden.codex then hidden.openai = true end + for id in pairs(common.hiddenProviders()) do hidden[id] = true end + return hidden +end + +local function providerMark(id, size) + local named = common.providerGlyph(id) + return ui.glyph({ + name = named or "robot", + size = named ~= nil and size or size - 1, + color = named ~= nil and "on_surface" or "on_surface/0.7", + }) +end + +local function compactNumber(value) + if type(value) ~= "number" then return "0" end + local absolute = math.abs(value) + if absolute < 1000 then return string.format("%.0f", value) end + if absolute < 1000000 then return string.format("%.1fK", value / 1000) end + if absolute < 1000000000 then return string.format("%.1fM", value / 1000000) end + return string.format("%.1fB", value / 1000000000) +end + +local function cost(value) + if type(value) ~= "number" then return "—" end + if value == 0 then return "$0.00" end + if value < 0.01 then return "<$0.01" end + return string.format("$%.2f", value) +end + +local function usedPercent(value) + if type(value) ~= "number" then return "—" end + return noctalia.tr("quota.used", { percent = string.format("%.0f", value) }) +end + +local function countdown(resetAtMs) + if type(resetAtMs) ~= "number" then return nil end + local minutes = math.floor((resetAtMs - noctalia.nowMs()) / 60000) + if minutes <= 0 then return noctalia.tr("reset.pending") end + if minutes < 60 then return noctalia.tr("reset.minutes", { minutes = tostring(minutes) }) end + local hours = math.floor(minutes / 60) + if hours < 24 then + return noctalia.tr("reset.hours", { hours = tostring(hours), minutes = tostring(minutes % 60) }) + end + return noctalia.tr("reset.days", { days = tostring(math.floor(hours / 24)), hours = tostring(hours % 24) }) +end + +local function usedColor(value) + if value >= EXHAUSTED_USED then return tone("over") end + if value >= WARN_USED then return tone("warn") end + return tone("ok") +end + +local function accountPlan(account) + if type(account.plan) ~= "string" or account.plan == "" then return nil end + -- Keep OpenCodex's actual plan identifier, changing only its presentation. + -- In particular, do not conflate distinct values such as `pro`/`prolite` or + -- `team`/`business` with friendlier but potentially inaccurate names. + local words = {} + for word in account.plan:gsub("[_%-]+", " "):gmatch("%S+") do + table.insert(words, word:sub(1, 1):upper() .. word:sub(2):lower()) + end + return #words > 0 and table.concat(words, " ") or nil +end + +local function accountStatus(account) + if account.paused == true then return noctalia.tr("account.paused"), "error" end + if account.needsReauth == true then return noctalia.tr("account.reauth"), "error" end + + local health = type(account.health) == "table" and account.health.status or account.health + if health == "cooldown" or health == "warning" or health == "degraded" + or health == "unhealthy" or health == "unavailable" or health == "error" then + return account.healthLabel or noctalia.tr("account.warning"), "tertiary" + end + + if account.active == true then return noctalia.tr("account.active"), "primary" end + return nil, nil +end + +local function accountAction(commandType, account) + pendingAccountAction = nil + noctalia.state.set("command", { + type = commandType, + accountId = account.id, + nonce = noctalia.nowMs(), + }) + renderPanel() +end + +local function actionMessage() + if currentAction.status == "idle" or currentAction.status == "working" then return nil, nil end + local key + if currentAction.status == "success" then + if currentAction.type == "select_account" then + key = "action_result.selected" + elseif type(currentAction.remaining) == "number" then + local count = tostring(math.max(0, math.floor(currentAction.remaining))) + return trOr("action_result.reset_remaining", "Usage limits reset. " .. count .. " reset credits remain.", { + count = count, + }), "primary" + else + key = "action_result.reset" + end + elseif currentAction.code == "nothing_to_reset" then + key = "action_result.nothing_to_reset" + elseif currentAction.code == "no_credit" then + key = "action_result.no_credit" + else + key = currentAction.type == "select_account" and "action_result.select_failed" or "action_result.reset_failed" + end + local fallbacks = { + ["action_result.selected"] = "Account selected for the next turn.", + ["action_result.reset"] = "Usage limits reset successfully.", + ["action_result.nothing_to_reset"] = "This account currently has no usage limits to reset.", + ["action_result.no_credit"] = "No reset credit is available for this account.", + ["action_result.select_failed"] = "Could not change the active account.", + ["action_result.reset_failed"] = "Could not use the reset credit.", + } + return trOr(key, fallbacks[key] or key), currentAction.status == "success" and "primary" or "error" +end + +local function isCodexProvider(provider) + local providerId = tostring(provider.id or ""):lower() + return providerId == "openai" or providerId == "codex" +end + +local function beginAccountAction(commandType, account) + pendingAccountAction = { type = commandType, accountId = account.id } + renderPanel() +end + +local function accountActionEnabled(account) + return account.paused ~= true and account.needsReauth ~= true + and currentAction.status ~= "working" +end + +-- Account actions are compact text links rather than padded buttons. They use +-- the same typography and underline treatment so neither action dominates the +-- reset-credit row. +local function accountActionHover(key, hovered) + if hovered == "true" then + hoveredAccountAction = key + elseif hoveredAccountAction == key then + hoveredAccountAction = nil + end + renderPanel() +end + +local function switchAccountControl(account, key) + local working = currentAction.status == "working" + and currentAction.accountId == account.id + and currentAction.type == "select_account" + local enabled = accountActionEnabled(account) + local hovered = enabled and hoveredAccountAction == key + local color = hovered and "tertiary" or "primary" + local props = { key = key, gap = 0, opacity = enabled and 1 or 0.55 } + if enabled then + props.onClick = function() beginAccountAction("select_account", account) end + props.onHover = function(state) accountActionHover(key, state) end + end + return ui.column(props, { + ui.label({ + text = working and trOr("action.selecting", "Switching…") + or trOr("action.select", "Switch Account"), + color = color, + fontSize = 11, + fontWeight = "bold", + }), + ui.box({ height = hovered and 2 or 1, minWidth = 47, fill = color }), + }) +end + +-- Noctalia labels do not expose text-decoration or click handlers. A compact +-- clickable column with a one-pixel rule gives the reset action link styling +-- while keeping it directly in the reset-credit sentence. +local function resetAccountLink(account, key) + local working = currentAction.status == "working" + and currentAction.accountId == account.id + and currentAction.type == "reset_account" + local enabled = accountActionEnabled(account) + local hovered = enabled and hoveredAccountAction == key + local color = hovered and "tertiary" or "primary" + local text = working and trOr("action.resetting", "Resetting…") + or trOr("action.use_reset", "Use Reset") + local props = { key = key, gap = 0, opacity = enabled and 1 or 0.55 } + if enabled then + props.onClick = function() beginAccountAction("reset_account", account) end + props.onHover = function(state) accountActionHover(key, state) end + end + return ui.column(props, { + ui.label({ text = text, color = color, fontSize = 11, fontWeight = "bold" }), + ui.box({ height = hovered and 2 or 1, minWidth = 48, fill = color }), + }) +end + +local function accountConfirmation(card, provider, account, key) + if not isCodexProvider(provider) then return end + local confirmation = pendingAccountAction + if type(confirmation) ~= "table" or confirmation.accountId ~= account.id then return end + + local isReset = confirmation.type == "reset_account" + table.insert(card, ui.label({ + key = key .. ":confirm:text", + text = trOr( + isReset and "confirm.reset" or "confirm.select", + (isReset and "Use one reset credit for " or "Use ") + .. (account.label or account.id) .. (isReset and "?" or " for the next turn?"), + { account = account.label or account.id } + ), + color = isReset and "tertiary" or "on_surface/0.75", + fontSize = 11, + maxWidth = 350, + maxLines = 3, + })) + table.insert(card, ui.row({ key = key .. ":confirm:buttons", gap = 5, align = "center" }, { + ui.spacer({ flexGrow = 1 }), + ui.button({ + text = trOr("action.cancel", "Cancel"), + variant = "ghost", + controlSize = "sm", + onClick = function() + pendingAccountAction = nil + renderPanel() + end, + }), + ui.button({ + text = trOr(isReset and "action.confirm_reset" or "action.confirm_select", "Confirm"), + variant = isReset and "secondary" or "primary", + controlSize = "sm", + onClick = function() accountAction(confirmation.type, account) end, + }), + })) +end + +-- One limit row: label + percentage, bar, countdown. +-- Two elements per limit, not three: the reset countdown rides on the label +-- row rather than taking a line of its own underneath the bar. +local function quotaRows(rows, quota, keyPrefix) + for index, window in ipairs((quota and quota.windows) or {}) do + local used = window.usedPercent or 0 + local key = keyPrefix .. ":" .. tostring(index) + local head = { + ui.label({ text = window.label or noctalia.tr("quota.title"), fontSize = 12 }), + } + local reset = countdown(window.resetAtMs) + if reset ~= nil then + table.insert(head, ui.label({ text = reset, color = "on_surface/0.7", fontSize = 12, flexGrow = 1 })) + else + table.insert(head, ui.spacer({ flexGrow = 1 })) + end + table.insert(head, ui.label({ text = usedPercent(window.usedPercent), fontSize = 12, color = usedColor(used) })) + table.insert(rows, ui.row({ key = key .. ":head", gap = 6, align = "center" }, head)) + table.insert(rows, ui.progress({ + key = key .. ":bar", + progress = used / 100, + fill = usedColor(used), + height = 6, + radius = 3, + })) + end +end + +-- `named` is false for a provider's only account: the provider header already +-- identifies it, so repeating an alias underneath is noise. Its status badge +-- moves up into that header instead. +local function accountCard(rows, provider, account, providerHasAccountQuota, named) + local key = provider.id .. ":" .. account.id + local card = {} + + if named then + local status, statusColor = accountStatus(account) + local plan = accountPlan(account) + local title = { + ui.label({ + text = account.label or account.id, + fontSize = 13, + fontWeight = "bold", + maxWidth = 220, + }), + } + if plan ~= nil then + table.insert(title, ui.label({ + text = plan, + color = "on_surface/0.6", + fontSize = 10, + flexGrow = 1, + })) + else + table.insert(title, ui.spacer({ flexGrow = 1 })) + end + if status ~= nil then + table.insert(title, ui.label({ text = status, color = statusColor, fontSize = 10, fontWeight = "bold" })) + end + table.insert(card, ui.row({ key = key .. ":title", gap = 8, align = "center" }, title)) + end + + -- Provider-level quota stands in only where the account has none of its own, + -- so a provider's numbers are never printed twice. + if account.quota ~= nil then + quotaRows(card, account.quota, key) + elseif not providerHasAccountQuota and provider.quota ~= nil then + quotaRows(card, provider.quota, key) + else + table.insert(card, ui.label({ + key = key .. ":noquota", + text = noctalia.tr("quota.unavailable"), + color = "on_surface/0.6", + fontSize = 11, + })) + end + + -- A reset credit clears the account's current limits outright, so it is worth + -- surfacing next to a bar that looks close to full. + local credits = account.quota ~= nil and account.quota.resetCredits or nil + if credits ~= nil and credits > 0 then + local creditRow = { + ui.glyph({ name = "refresh-dot", size = 12, color = "primary" }), + ui.label({ + text = noctalia.trp("quota.reset_credits", credits, { count = tostring(math.floor(credits)) }) .. (isCodexProvider(provider) and " ·" or ""), + color = "primary", + fontSize = 11, + }), + } + if isCodexProvider(provider) then + table.insert(creditRow, resetAccountLink(account, key .. ":reset")) + if account.active ~= true then + table.insert(creditRow, ui.spacer({ flexGrow = 1 })) + table.insert(creditRow, switchAccountControl(account, key .. ":select")) + end + end + table.insert(card, ui.row({ key = key .. ":credits", gap = 5, align = "center" }, creditRow)) + elseif isCodexProvider(provider) and account.active ~= true then + -- Selection remains available when OpenCodex reports no reset-credit data. + table.insert(card, ui.row({ key = key .. ":select-row", align = "center" }, { + ui.spacer({ flexGrow = 1 }), + switchAccountControl(account, key .. ":select"), + })) + end + + accountConfirmation(card, provider, account, key) + + if account.quotaUnavailable then + table.insert(card, ui.label({ + key = key .. ":stale", + text = noctalia.tr("quota.stale"), + color = "on_surface/0.6", + fontSize = 11, + })) + end + + table.insert(rows, ui.column({ key = key .. ":card", gap = 3, paddingH = 8, paddingV = 2 }, card)) +end + +local function accountsTab(body) + local message, color = actionMessage() + if message ~= nil then + table.insert(body, ui.label({ + key = "action:result", + text = message, + color = color, + fontSize = 11, + maxWidth = 370, + maxLines = 2, + })) + end + + local providers = enabledProviders() + if #providers == 0 then + -- The status line names the failure; this says what to do about it. + local status = currentSnapshot.status + local title, hint = "empty.no_providers", "empty.no_providers_hint" + if status == "auth_error" then + title, hint = "status.auth_error", "empty.auth_hint" + elseif status == "offline" then + title, hint = "status.offline", "empty.offline_hint" + elseif status == "loading" then + title, hint = "status.loading", nil + end + table.insert(body, ui.label({ key = "empty", text = noctalia.tr(title), fontWeight = "bold" })) + if hint ~= nil then + table.insert(body, ui.label({ + key = "empty:hint", + text = noctalia.tr(hint), + color = "on_surface/0.7", + fontSize = 12, + maxWidth = 360, + maxLines = 3, + })) + end + return + end + + for index, provider in ipairs(providers) do + local hasAccountQuota = common.anyAccountQuota(provider) + local header = { + providerMark(provider.id, 16), + ui.label({ + text = provider.label or provider.id, + fontSize = 14, + fontWeight = "bold", + flexGrow = 1, + }), + } + local accounts = provider.accounts or {} + local named = #accounts > 1 + + if not named and accounts[1] ~= nil then + local account = accounts[1] + local plan = accountPlan(account) + local status, statusColor = accountStatus(account) + if plan ~= nil then + table.insert(header, ui.label({ text = plan, color = "on_surface/0.6", fontSize = 10 })) + end + if status ~= nil then + table.insert(header, ui.label({ text = status, color = statusColor, fontSize = 10, fontWeight = "bold" })) + end + end + table.insert(body, ui.row({ key = "hdr:" .. provider.id, gap = 8, align = "center" }, header)) + + if #accounts == 0 then + table.insert(body, ui.label({ + key = "noacct:" .. provider.id, + text = noctalia.tr("account.none"), + color = "on_surface/0.6", + fontSize = 11, + })) + end + for _, account in ipairs(accounts) do + accountCard(body, provider, account, hasAccountQuota, named) + end + if provider.pool ~= nil and provider.pool.strategy ~= nil then + table.insert(body, ui.label({ + key = "pool:" .. provider.id, + text = noctalia.tr("pool.strategy", { strategy = provider.pool.strategy }), + color = "on_surface/0.6", + fontSize = 10, + })) + end + if index < #providers then + table.insert(body, ui.separator({ key = "sep:" .. provider.id, spacing = SECTION_SPACING })) + end + end +end + +-- Contribution grid, shaded by request volume against the busiest day in the +-- window. Laid out as a calendar (weekday columns, one row per week) rather +-- than GitHub's transpose: 30 days is only ~5 columns the other way round, +-- which cannot fill the panel width without absurd cell sizes. +-- Cells flex rather than carrying pixel widths. The scroll view's content box +-- is its own width less viewportPaddingH on both sides (Style::spaceXs) and, +-- once a scrollbar appears, a further scrollbarWidth + scrollbarGap gutter. +-- Chasing that with constants meant overlapping the scrollbar; letting the row +-- distribute whatever width it is actually given cannot drift. +local GRID_GAP = 4 +local GRID_CELL_H = 26 +local WEEKDAY_KEYS = { "mon", "tue", "wed", "thu", "fri", "sat", "sun" } + +local function cellColor(requests, busiest) + if requests <= 0 then return "on_surface/0.07" end + local ratio = busiest > 0 and requests / busiest or 0 + local step = 1 + if ratio > 0.25 then step = 2 end + if ratio > 0.5 then step = 3 end + if ratio > 0.75 then step = 4 end + return gridFill(step) +end + +local function contributionGrid(body, days) + if #days == 0 then return end + + local busiest = 0 + for _, day in ipairs(days) do + if day.requests > busiest then busiest = day.requests end + end + + local headers = {} + for _, name in ipairs(WEEKDAY_KEYS) do + table.insert(headers, ui.label({ + text = noctalia.tr("weekday." .. name), + flexGrow = 1, + fontSize = 11, + textAlign = "center", + color = "on_surface/0.65", + })) + end + table.insert(body, ui.row({ key = "grid:head", gap = GRID_GAP, align = "center" }, headers)) + + -- Blank cells before the first day so every column is a fixed weekday. + local slots = {} + for _ = 1, (days[1].weekday or 1) - 1 do table.insert(slots, false) end + for _, day in ipairs(days) do table.insert(slots, day) end + + local rows = {} + for week = 1, math.ceil(#slots / 7) do + local cells = {} + for weekday = 1, 7 do + local slot = slots[(week - 1) * 7 + weekday] + local fill = "on_surface/0.02" + if slot ~= nil and slot ~= false then + fill = cellColor(slot.requests, busiest) + end + local props = { + key = "cell:" .. tostring(week) .. ":" .. tostring(weekday), + flexGrow = 1, + height = GRID_CELL_H, + radius = 3, + fill = fill, + } + if slot ~= nil and slot ~= false then + -- Boxes cannot own tooltips in Noctalia's panel API. Hover mirrors the + -- day into the detail line below; click does the same persistently and + -- also makes the cell keyboard-focusable/activatable. + props.border = selectedUsageDate == slot.date and "on_surface/0.9" or "on_surface/0" + props.borderWidth = 1 + props.onHover = function(hovered) + if hovered == "true" then + hoveredUsageDate = slot.date + elseif hoveredUsageDate == slot.date then + hoveredUsageDate = nil + end + renderPanel() + end + props.onClick = function() + if selectedUsageDate == slot.date then + selectedUsageDate = nil + else + selectedUsageDate = slot.date + end + renderPanel() + end + end + table.insert(cells, ui.box(props)) + end + table.insert(rows, ui.row({ key = "grid:" .. tostring(week), gap = GRID_GAP, align = "center" }, cells)) + end + table.insert(body, ui.column({ key = "grid", gap = GRID_GAP }, rows)) + + local detail = days[#days] + local wantedDate = hoveredUsageDate or selectedUsageDate + if wantedDate ~= nil then + for _, day in ipairs(days) do + if day.date == wantedDate then + detail = day + break + end + end + end + table.insert(body, ui.label({ + key = "grid:detail", + text = noctalia.trp("usage.grid_day", detail.requests, { + date = detail.date, + count = compactNumber(detail.requests), + cost = cost(detail.estimatedCostUsd), + }), + color = "on_surface/0.75", + fontSize = 11, + })) + + local legend = { + ui.label({ + text = noctalia.tr("usage.grid_caption", { days = tostring(#days), busiest = compactNumber(busiest) }), + color = "on_surface/0.6", + fontSize = 11, + flexGrow = 1, + }), + ui.label({ text = noctalia.tr("usage.less"), color = "on_surface/0.6", fontSize = 11 }), + ui.box({ width = 10, height = 10, radius = 2, fill = "on_surface/0.07" }), + } + for step = 1, 4 do + table.insert(legend, ui.box({ width = 10, height = 10, radius = 2, fill = gridFill(step) })) + end + table.insert(legend, ui.label({ text = noctalia.tr("usage.more"), color = "on_surface/0.6", fontSize = 11 })) + table.insert(body, ui.row({ key = "legend", gap = 4, align = "center" }, legend)) +end + +local function providerUsageRows(body, providers, models, totals, excluded) + if #providers == 0 then return end + table.insert(body, ui.label({ + key = "usage:by-provider", + text = noctalia.tr("usage.by_provider"), + fontWeight = "bold", + })) + + for index, item in ipairs(providers) do + local key = "usage:p:" .. item.id + if index > 1 then + table.insert(body, ui.spacer({ key = key .. ":gap", height = SECTION_SPACING })) + end + -- shareRatio is OpenCodex's own figure; fall back to a request share. With + -- a provider hidden it is a share of a total the panel no longer shows, so + -- the local computation takes over. + local share = not excluded and item.shareRatio or nil + if share == nil and totals.requests and totals.requests > 0 then + share = item.requests / totals.requests + end + + table.insert(body, ui.row({ key = key .. ":head", gap = 6, align = "center" }, { + providerMark(item.id, 14), + ui.label({ text = item.id, flexGrow = 1, fontSize = 12, fontWeight = "bold" }), + ui.label({ + text = noctalia.tr("usage.requests", { count = compactNumber(item.requests) }), + color = "on_surface/0.6", + fontSize = 11, + }), + ui.label({ text = cost(item.estimatedCostUsd), fontSize = 12, fontWeight = "bold" }), + })) + if share ~= nil then + table.insert(body, ui.progress({ + key = key .. ":share", + progress = share, + fill = tone("ok"), + height = 4, + radius = 2, + })) + end + + -- Which models made up that provider's usage, busiest first. + local busiest = 0 + for _, model in ipairs(models) do + if model.provider == item.id and model.requests > busiest then busiest = model.requests end + end + for _, model in ipairs(models) do + if model.provider == item.id then + table.insert(body, ui.row({ key = key .. ":m:" .. model.model, gap = 6, align = "center" }, { + ui.box({ + width = 3, + height = 12, + radius = 2, + fill = gridFill(busiest > 0 and math.max(1, math.ceil(model.requests / busiest * 4)) or 1), + }), + ui.label({ text = model.model, flexGrow = 1, fontSize = 11, color = "on_surface/0.85", maxWidth = 150 }), + ui.label({ + text = compactNumber(model.requests), + fontSize = 11, + color = "on_surface/0.6", + }), + ui.label({ + text = noctalia.tr("usage.tokens", { count = compactNumber(model.totalTokens) }), + fontSize = 11, + color = "on_surface/0.6", + }), + ui.label({ text = cost(model.estimatedCostUsd), fontSize = 11 }), + })) + end + end + end +end + +local function usageTab(body) + local usage = currentSnapshot.usage + if usage == nil then + table.insert(body, ui.label({ key = "usage:none", text = noctalia.tr("usage.unavailable"), color = "on_surface/0.7" })) + return + end + + local hidden = hiddenProviderIds() + + -- Drop hidden providers, and the models that belong to them, before anything + -- is drawn or summed. + local function isHidden(id) return hidden[tostring(id or ""):lower()] == true end + + local providers, models, excluded = {}, {}, false + for _, item in ipairs(usage.providers or {}) do + if isHidden(item.id) then excluded = true else table.insert(providers, item) end + end + for _, model in ipairs(usage.models or {}) do + if isHidden(model.provider) then excluded = true else table.insert(models, model) end + end + + local today = usage.today + local totals = usage.totals or {} + -- OpenCodex's totals cover every provider. Once one is hidden they would + -- report more than the rows above them, so re-sum what is left. Cached and + -- reasoning tokens are not broken down per provider and simply cannot be + -- re-summed, so those rows drop out rather than lie. + if excluded then + local summed = { requests = 0, totalTokens = 0, estimatedCostUsd = 0 } + for _, item in ipairs(providers) do + summed.requests = summed.requests + (item.requests or 0) + summed.totalTokens = summed.totalTokens + (item.totalTokens or 0) + summed.estimatedCostUsd = summed.estimatedCostUsd + (item.estimatedCostUsd or 0) + end + totals = summed + end + + table.insert(body, ui.label({ key = "usage:today-title", text = noctalia.tr("usage.today"), fontWeight = "bold" })) + table.insert(body, ui.row({ key = "usage:today", gap = 8 }, { + ui.label({ text = noctalia.tr("usage.requests", { count = compactNumber(today and today.requests) }), flexGrow = 1 }), + ui.label({ text = noctalia.tr("usage.tokens", { count = compactNumber(today and today.totalTokens) }) }), + })) + if today ~= nil and today.estimatedCostUsd ~= nil then + table.insert(body, ui.row({ key = "usage:today-cost", gap = 8 }, { + ui.label({ text = noctalia.tr("usage.today_cost"), flexGrow = 1 }), + ui.label({ text = cost(today.estimatedCostUsd) }), + })) + end + + table.insert(body, ui.separator({ key = "usage:sep1", spacing = SECTION_SPACING })) + contributionGrid(body, usage.days or {}) + + table.insert(body, ui.separator({ key = "usage:sep2", spacing = SECTION_SPACING })) + providerUsageRows(body, providers, models, totals, excluded) + + table.insert(body, ui.separator({ key = "usage:sep3", spacing = SECTION_SPACING })) + table.insert(body, ui.label({ + key = "usage:window-title", + text = noctalia.tr("usage.window", { range = usage.range or "30d" }), + fontWeight = "bold", + })) + local function metric(labelKey, text) + table.insert(body, ui.row({ key = "usage:" .. labelKey, gap = 8 }, { + ui.label({ text = noctalia.tr(labelKey), flexGrow = 1, color = "on_surface/0.8" }), + ui.label({ text = text }), + })) + end + metric("usage.requests_label", compactNumber(totals.requests)) + metric("usage.tokens_label", compactNumber(totals.totalTokens)) + if not excluded then + metric("usage.cached_label", compactNumber(totals.cachedInputTokens)) + metric("usage.reasoning_label", compactNumber(totals.reasoningOutputTokens)) + end + metric("usage.cost_label", cost(totals.estimatedCostUsd)) +end + + +local function statusLine() + if currentSnapshot.refreshing and currentSnapshot.lastSuccessfulAtMs == nil then + return noctalia.tr("status.loading"), "on_surface/0.65" + end + if currentSnapshot.error ~= nil and currentSnapshot.error.kind ~= nil then + return noctalia.tr("status." .. currentSnapshot.error.kind), + currentSnapshot.status == "degraded" and "tertiary" or "error" + end + if currentSnapshot.lastSuccessfulAtMs == nil then + return noctalia.tr("status.never_updated"), "on_surface/0.65" + end + local seconds = math.max(0, math.floor((noctalia.nowMs() - currentSnapshot.lastSuccessfulAtMs) / 1000)) + if seconds < 60 then + return noctalia.tr("status.updated_seconds", { seconds = tostring(seconds) }), "on_surface/0.6" + end + return noctalia.tr("status.updated_minutes", { minutes = tostring(math.floor(seconds / 60)) }), "on_surface/0.6" +end + +local function selectTab(name) + activeTab = name + renderPanel() +end + +function renderPanel() + if not panelOpen then return end + + local statusText, statusColor = statusLine() + local function tab(name, labelKey) + return ui.button({ + key = "tab:" .. name, + text = noctalia.tr(labelKey), + controlSize = "sm", + flexGrow = 1, + variant = activeTab == name and "primary" or "ghost", + onClick = function() selectTab(name) end, + }) + end + + local body = {} + if activeTab == "usage" then + usageTab(body) + else + accountsTab(body) + end + + local children = { + ui.row({ key = "header", gap = 6, align = "center" }, { + ui.label({ text = noctalia.tr("panel.title"), fontSize = 16, fontWeight = "bold" }), + ui.button({ + key = "dashboard", + glyph = "external-link", + glyphSize = 15, + variant = "ghost", + controlSize = "sm", + visible = dashboardUrl() ~= nil, + tooltip = noctalia.tr("action.dashboard"), + onClick = openDashboard, + }), + ui.spacer({ key = "header:gap", flexGrow = 1 }), + ui.label({ text = statusText, color = statusColor, fontSize = 11 }), + ui.button({ + key = "refresh", + glyph = "refresh", + glyphSize = 15, + variant = "ghost", + controlSize = "sm", + tooltip = noctalia.tr("action.refresh"), + onClick = refresh, + }), + }), + ui.row({ key = "tabs", gap = 4 }, { tab("accounts", "tab.accounts"), tab("usage", "tab.usage") }), + -- flexGrow on both the root column and the scroll is what bounds the tree + -- to the panel height; without it the content runs off the bottom edge + -- instead of scrolling. + ui.scroll({ key = "body", flexGrow = 1, gap = 5 }, body), + } + + panel.render(ui.column({ flexGrow = 1, gap = 8, padding = 14 }, children)) +end + +noctalia.state.watch("snapshot", function(value) + if type(value) == "table" then + currentSnapshot = value + renderPanel() + end +end) + +noctalia.state.watch("action", function(value) + if type(value) == "table" then + currentAction = value + if value.status ~= "working" then pendingAccountAction = nil end + renderPanel() + end +end) + +function onOpen() + panelOpen = true + -- Countdowns and the "updated Ns ago" line are the only live text here; a + -- panel gets no update tick at all unless it asks for one. + pcall(function() panel.setWantsSecondTicks(true) end) + renderPanel() +end + +function onClose() + panelOpen = false + panel.setWantsSecondTicks(false) +end + +function update() + renderPanel() +end diff --git a/opencodex-bar/plugin.toml b/opencodex-bar/plugin.toml new file mode 100644 index 00000000..db3dbfa2 --- /dev/null +++ b/opencodex-bar/plugin.toml @@ -0,0 +1,109 @@ +id = "wy3z/opencodex-bar" +name = "OpenCodexBar" +version = "1.0.0" +plugin_api = 24 +author = "wy3z" +license = "MIT" +icon = "brand-openai" +description = "OpenCodex account, quota, usage, and routing control." +tags = ["bar", "panel", "service", "ai", "indicator", "utility"] +dependencies = ["xdg-open"] + +[[setting]] +key = "base_url" +type = "string" +label_key = "settings.base_url.label" +description_key = "settings.base_url.description" +default = "http://127.0.0.1:10100" + +[[setting]] +key = "admin_token_file" +type = "file" +label_key = "settings.admin_token_file.label" +description_key = "settings.admin_token_file.description" +default = "~/.opencodex/admin-api-token" + +[[setting]] +key = "poll_seconds" +type = "int" +label_key = "settings.poll_seconds.label" +description_key = "settings.poll_seconds.description" +default = 30 +min = 10 +max = 300 + +[[setting]] +key = "force_refresh_minutes" +type = "int" +label_key = "settings.force_refresh_minutes.label" +description_key = "settings.force_refresh_minutes.description" +default = 10 +min = 5 +max = 60 + +[[setting]] +key = "hidden_providers" +type = "string" +label_key = "settings.hidden_providers.label" +description_key = "settings.hidden_providers.description" +default = "" + +[[setting]] +key = "theme_colors" +type = "bool" +label_key = "settings.theme_colors.label" +description_key = "settings.theme_colors.description" +default = false + +[[service]] +id = "service" +entry = "service.luau" + +[[widget]] +id = "usage" +entry = "widget.luau" + + + + [[widget.setting]] + key = "show_percentage" + type = "bool" + label_key = "settings.show_percentage.label" + description_key = "settings.show_percentage.description" + default = true + + + [[widget.setting]] + key = "icon_source" + type = "select" + label_key = "settings.icon_source.label" + description_key = "settings.icon_source.description" + default = "bars" + + [[widget.setting.options]] + value = "bars" + label_key = "settings.icon_source.bars" + + [[widget.setting.options]] + value = "active" + label_key = "settings.icon_source.active" + + [[widget.setting.options]] + value = "fixed" + label_key = "settings.icon_source.fixed" + + [[widget.setting]] + key = "glyph" + type = "glyph" + label_key = "settings.glyph.label" + description_key = "settings.glyph.description" + default = "brand-openai" + visible_when = { key = "icon_source", values = ["fixed"] } + +[[panel]] +id = "panel" +entry = "panel.luau" +width = 440 +height = 520 +placement = "attached" +open_near_click = true diff --git a/opencodex-bar/service.luau b/opencodex-bar/service.luau new file mode 100644 index 00000000..2028a1f7 --- /dev/null +++ b/opencodex-bar/service.luau @@ -0,0 +1,1135 @@ +--!nonstrict +-- OpenCodexBar service. This is the only entry that talks to OpenCodex. +-- The other entries exchange normalized snapshot, command, and action state. + +local common = require("./common.luau") + +local DEFAULT_BASE_URL = "http://127.0.0.1:10100" +local DEFAULT_POLL_SECONDS = 30 +local DEFAULT_FORCE_MINUTES = 10 +-- OpenCodex clamps the usage window at 30 days; the panel's contribution grid +-- wants every day it will give us. +local USAGE_RANGE = "30d" + +-- noctalia.http refuses a request past 8 concurrent per plugin runtime, so a +-- deployment with several OAuth providers would silently lose the overflow. +-- Queue instead, and stay a little under the ceiling. +local MAX_IN_FLIGHT = 6 +-- A refresh whose callbacks never all arrive would pin `inFlight` forever. +local REFRESH_TIMEOUT_MS = 60000 + +local snapshot = nil +local inFlight = false +local generation = 0 +local refreshStartedMs = 0 +local refreshPending = false +local forcePending = false +local lastRefreshMs = 0 +local lastForcedRefreshMs = 0 + +local queue = {} +local activeRequests = 0 +local pumping = false + +local tokenCache = { path = nil, value = nil, loaded = false } +local actionInFlight = false +local actionGeneration = 0 +local actionStartedMs = 0 + +local function nowMs() + return noctalia.nowMs() +end + +-- Finite numbers only: the JSON bridge happily hands back inf/NaN. +local function number(value) + if type(value) ~= "number" or value ~= value then return nil end + if value == math.huge or value == -math.huge then return nil end + return value +end + +local function clampedInt(value, fallback, minValue, maxValue) + value = number(value) + if value == nil then return fallback end + value = math.floor(value) + if value < minValue then return minValue end + if value > maxValue then return maxValue end + return value +end + +local function copy(value) + if type(value) ~= "table" then return value end + local result = {} + for key, child in pairs(value) do + result[key] = copy(child) + end + return result +end + +local function trim(value) + if type(value) ~= "string" then return "" end + return noctalia.string.trim(value) +end + +local function nonEmpty(value) + value = trim(value) + return value ~= "" and value or nil +end + +local function firstTable(value, keys) + if type(value) ~= "table" then return nil end + for _, key in ipairs(keys) do + if type(value[key]) == "table" then return value[key] end + end + return value +end + +-- A collection endpoint may answer with an array, an envelope around one, or a +-- single bare object; normalize all three to a plain array. +local function collection(value, keys) + local result = firstTable(value, keys) + if type(result) ~= "table" then return {} end + if result.id ~= nil or result.provider ~= nil or result.alias ~= nil then + return { result } + end + return result +end + +local function configString(key, fallback) + local value = noctalia.getConfig(key) + return type(value) == "string" and value ~= "" and value or fallback +end + +local function configNumber(key, fallback, minValue, maxValue) + return clampedInt(noctalia.getConfig(key), fallback, minValue, maxValue) +end + +local function baseUrl() + return (trim(configString("base_url", DEFAULT_BASE_URL)):gsub("/+$", "")) +end + +local function isLoopbackHost(host) + host = host:lower():gsub("%.$", "") + if host == "localhost" or host:match("%.localhost$") then return true end + if host == "::1" or host == "0:0:0:0:0:0:0:1" then return true end + + local first, second, third, fourth = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") + if first == nil or tonumber(first) ~= 127 then return false end + return tonumber(second) <= 255 and tonumber(third) <= 255 and tonumber(fourth) <= 255 +end + +-- Never put the admin credential on plaintext transport beyond the local +-- machine. Parsing the authority ourselves also rejects user-info and malformed +-- ports, where it is too easy for the apparent host not to be the real one. +local function requestBaseUrl() + local url = baseUrl() + local scheme, authority = url:match("^([%a][%w+%.%-]*)://([^/%?#]+)") + if scheme == nil or authority == nil or authority:find("@", 1, true) ~= nil then return nil end + + local host + if authority:sub(1, 1) == "[" then + host = authority:match("^%[([^%]]+)%]$") or authority:match("^%[([^%]]+)%]:%d+$") + else + host = authority:match("^([^:]+)$") or authority:match("^([^:]+):%d+$") + end + if host == nil then return nil end + + scheme = scheme:lower() + if scheme == "https" or (scheme == "http" and isLoopbackHost(host)) then return url end + return nil +end + +local function validToken(value) + value = trim(value) + if value == "" or value:find("%c") ~= nil then return nil end + return value +end + +local function adminToken() + local fromEnvironment = validToken(noctalia.getenv("OPENCODEX_ADMIN_AUTH_TOKEN")) + if fromEnvironment ~= nil then return fromEnvironment end + + local configured = trim(noctalia.getConfig("admin_token_file")) + if configured == "" then return nil end + + -- Cached: this is read once per request otherwise, several times a poll. + if tokenCache.loaded and tokenCache.path == configured then return tokenCache.value end + local contents = noctalia.readFile(configured) + tokenCache = { + path = configured, + value = type(contents) == "string" and validToken(contents) or nil, + loaded = true, + } + return tokenCache.value +end + +local function errorFor(kind, status, code) + return { kind = kind, status = status, code = code } +end + +local function startRequest(path, callback, options) + options = options or {} + local root = requestBaseUrl() + if root == nil then + callback(nil, errorFor("api_error")) + return + end + + local headers = { "Accept: application/json" } + if options.body ~= nil then + table.insert(headers, "Content-Type: application/json") + end + local token = adminToken() + if token ~= nil then + table.insert(headers, "X-OpenCodex-API-Key: " .. token) + end + + local accepted = noctalia.http({ + url = root .. path, + method = options.method or "GET", + headers = headers, + body = options.body, + }, function(response) + if type(response) ~= "table" then + callback(nil, errorFor("offline")) + return + end + + local status = number(response.status) or 0 + -- transportOk == false with no status is a connection failure, not an API error. + if response.ok == false and status == 0 then + callback(nil, errorFor("offline")) + return + end + if status == 401 or status == 403 then + callback(nil, errorFor("auth_error", status)) + return + end + -- 404 means this endpoint does not apply to the deployment (a setup with no + -- Codex pool has no /api/codex-auth), not that a refresh failed. Reporting + -- it as an error would park such a setup in "degraded" for good. + if status == 404 then + callback(nil, errorFor("not_found", status)) + return + end + if status < 200 or status >= 300 then + local code = nil + if type(response.body) == "string" and response.body ~= "" then + local decoded = noctalia.json.decode(response.body) + if type(decoded) == "table" then code = nonEmpty(decoded.code) or nonEmpty(decoded.error) end + end + callback(nil, errorFor("api_error", status, code)) + return + end + + if type(response.body) ~= "string" or response.body == "" then + callback({}, nil) + return + end + local decoded = noctalia.json.decode(response.body) + if decoded == nil then + callback(nil, errorFor("invalid_json")) + return + end + callback(decoded, nil) + end) + + if accepted ~= true then + callback(nil, errorFor("offline")) + end +end + +local function pump() + if pumping then return end + pumping = true + while activeRequests < MAX_IN_FLIGHT and #queue > 0 do + local job = table.remove(queue, 1) + activeRequests = activeRequests + 1 + job() + end + pumping = false +end + +local function request(path, callback, options) + table.insert(queue, function() + startRequest(path, function(data, err) + activeRequests = activeRequests - 1 + callback(data, err) + pump() + end, options) + end) + pump() +end + +-- OpenCodex reports epoch times in either seconds or milliseconds. +local function timestamp(value) + value = number(value) + if value == nil or value <= 0 then return nil end + if value < 100000000000 then return value * 1000 end + return value +end + +local function normalizedLabel(value) + if type(value) ~= "string" then return "" end + local label = trim(value):lower() + return (label:gsub("[^%w]+", "_"):gsub("^_+", ""):gsub("_+$", "")) +end + +local function canonicalWindow(label) + local key = normalizedLabel(label) + if key == "five_hour" or key == "five_hours" or key == "5_hour" or key == "5_hours" then return "five_hour" end + if key == "weekly" or key == "week" then return "weekly" end + if key == "monthly" or key == "month" then return "monthly" end + return nil +end + +local function quotaPercent(value) + value = number(value) + if value == nil then return nil end + if value < 0 then return 0 end + if value > 100 then return 100 end + return value +end + +local function windowPercent(value) + if type(value) ~= "table" then return nil end + return quotaPercent(value.usedPercent or value.percent or value.percentage or value.utilizationPercent) +end + +local function windowReset(value) + if type(value) ~= "table" then return nil end + return timestamp(value.resetAtMs or value.resetAt or value.reset) +end + +local function normalizeQuota(value) + if type(value) ~= "table" then return nil end + local source = type(value.quota) == "table" and value.quota or value + local windows = {} + local seen = {} + + local function add(id, label, percent, resetAt) + percent = quotaPercent(percent) + if percent == nil or seen[id] then return end + seen[id] = true + table.insert(windows, { + id = id, + label = label, + usedPercent = percent, + resetAtMs = timestamp(resetAt), + }) + end + + add("five_hour", noctalia.tr("window.five_hour"), source.fiveHourPercent, source.fiveHourResetAt) + add("weekly", noctalia.tr("window.weekly"), source.weeklyPercent, source.weeklyResetAt) + add("monthly", noctalia.tr("window.monthly"), source.monthlyPercent, source.monthlyResetAt) + + local custom = source.customWindows + if type(custom) == "table" then + for index, item in ipairs(custom) do + local raw = type(item) == "table" and item or { percent = item } + local label = trim(type(item) == "table" and (item.label or item.name or item.window or item.id) or nil) + if label == "" then label = tostring(index) end + add(canonicalWindow(label) or ("custom:" .. normalizedLabel(label)), label, windowPercent(raw), windowReset(raw)) + end + -- Object-shaped customWindows has no server-order guarantee, but is still + -- supported for compatibility with older OpenCodex responses. Integer keys + -- were already taken by the ipairs pass above and are skipped here. + for label, item in pairs(custom) do + if type(label) == "string" and type(item) == "table" then + local id = canonicalWindow(label) or ("custom:" .. normalizedLabel(label)) + add(id, trim(item.label or item.name or label), windowPercent(item), windowReset(item)) + end + end + end + + -- OpenCodex's dashboard calls these "reset credits": each one instantly + -- clears the account's current hourly and weekly limits. + local resetCredits = number(source.resetCredits) + if resetCredits ~= nil and resetCredits < 0 then resetCredits = nil end + + if #windows == 0 and resetCredits == nil then return nil end + return { + windows = windows, + resetCredits = resetCredits, + updatedAtMs = timestamp(source.updatedAtMs or source.updatedAt), + } +end + +-- Deliberately never an email address: the panel lists accounts in the open. +-- Give OpenCodex's native login a stable friendly name; aliases and log labels +-- identify added pool accounts without exposing their email addresses. +local function accountLabel(account, isMain) + if isMain then + local label = noctalia.tr("account.main") + -- Some Noctalia builds retain the previous English catalog during plugin + -- hot reloads. Keep the requested capitalization correct in that case. + if label == "account.main" or label == "Main account" then return "Main Account" end + return label + end + local label = nonEmpty(account.alias) or nonEmpty(account.logLabel) + if label ~= nil then return label end + return nonEmpty(account.id) or nonEmpty(account.accountId) or noctalia.tr("account.fallback") +end + +-- `health` is an object ({ status = "healthy" }) on current OpenCodex builds, +-- and a bare string on older ones. +local function normalizeHealth(account) + local health = account.health + if type(health) == "table" then + health = health.status or health.state + end + return nonEmpty(health), nonEmpty(account.healthLabel), nonEmpty(account.healthSummary) +end + +local function activeIdFrom(value) + if type(value) ~= "table" then return nil end + local id = value.activeCodexAccountId or value.activeAccountId or value.activeId + return type(id) == "string" and id or nil +end + +-- `true`/`false` are meaningful, but so is "OpenCodex never said"; keep nil. +local function tribool(value) + if value == true then return true end + if value == false then return false end + return nil +end + +local function normalizeAccount(raw, activeId, isMain) + local id = nonEmpty(raw.id) or nonEmpty(raw.accountId) + if id == nil then return nil end + local health, healthLabel, healthSummary = normalizeHealth(raw) + return { + id = id, + label = accountLabel(raw, isMain), + plan = nonEmpty(raw.plan), + isMain = isMain == true, + active = raw.active == true or (activeId ~= nil and activeId == id), + paused = tribool(raw.paused), + needsReauth = tribool(raw.needsReauth), + health = health, + healthLabel = healthLabel, + healthSummary = healthSummary, + quotaUnavailable = raw.quotaUnavailable == true, + quota = normalizeQuota(raw.quota), + } +end + +local function normalizeAccounts(value, activeId) + local accounts = {} + for _, raw in ipairs(collection(value, { "accounts", "data", "items" })) do + if type(raw) == "table" then + local account = normalizeAccount(raw, activeId, raw.isMain == true) + if account ~= nil then table.insert(accounts, account) end + end + end + return accounts +end + +local function normalizeProvider(raw) + if type(raw) ~= "table" then return nil end + local id = nonEmpty(raw.id) or nonEmpty(raw.provider) or nonEmpty(raw.name) + if id == nil then return nil end + local disabled = raw.disabled == true or raw.enabled == false or raw.authMode == "disabled" or raw.mode == "disabled" + return { + id = id, + label = nonEmpty(raw.label) or nonEmpty(raw.displayName) or nonEmpty(raw.name) or id, + enabled = not disabled, + oauth = raw.authMode == "oauth", + quota = nil, + accounts = {}, + pool = nil, + } +end + +local function providerList(value) + local result = {} + for _, raw in ipairs(collection(value, { "providers", "data", "items" })) do + local provider = normalizeProvider(raw) + if provider ~= nil then table.insert(result, provider) end + end + return result +end + +local function providerQuotaList(value) + local result = {} + -- Current builds answer with { generatedAt, reports = [...] }. + for _, raw in ipairs(collection(value, { "reports", "quotas", "providerQuotas", "data", "items" })) do + if type(raw) == "table" then + local id = nonEmpty(raw.provider) or nonEmpty(raw.id) or nonEmpty(raw.providerId) + local quota = normalizeQuota(raw.quota or raw) + if id ~= nil and quota ~= nil then + -- The report carries a presentable name ("OpenAI (Codex login)") that + -- /api/providers does not. + table.insert(result, { id = id, label = nonEmpty(raw.label), quota = quota }) + end + end + end + return result +end + +-- OpenCodex's report labels are mostly " " ("Anthropic +-- Claude", "xAI Grok"), but the Codex one reads "OpenAI (Codex login)" — it +-- names the auth method rather than the product. Restate it in the same shape. +-- Matching the suffix rather than the provider id keeps a plain OpenAI +-- API-key provider from being mislabelled "Codex". +local function displayLabel(id, label) + if type(label) ~= "string" then return label end + return (label:gsub("%s*%(Codex login%)%s*$", " Codex")) +end + +local function providerById(providers, id) + for _, provider in ipairs(providers) do + if provider.id == id then return provider end + end + return nil +end + +local function ensureProvider(providers, id, label) + local provider = providerById(providers, id) + if provider ~= nil then return provider end + provider = { + id = id, + label = label or id, + enabled = true, + quota = nil, + accounts = {}, + pool = nil, + } + table.insert(providers, provider) + return provider +end + +-- 1 = Monday .. 7 = Sunday, for laying days out in weekday rows. +local function weekdayOf(dateText) + local y, m, d = dateText:match("^(%d+)-(%d+)-(%d+)") + if y == nil then return nil end + local stamp = os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 12 }) + if stamp == nil then return nil end + return (tonumber(os.date("%w", stamp)) + 6) % 7 + 1 +end + +local function normalizeUsage(value) + if type(value) ~= "table" then return nil end + local source = type(value.usage) == "table" and value.usage or value + local totals = source.totals or source.summary or source.aggregate or source + if type(totals) ~= "table" then totals = {} end + local function metric(keys) + for _, key in ipairs(keys) do + local n = number(totals[key]) + if n ~= nil then return n end + end + return 0 + end + + local usage = { + range = USAGE_RANGE, + totals = { + requests = metric({ "requests", "requestCount", "totalRequests" }), + inputTokens = metric({ "inputTokens", "promptTokens" }), + outputTokens = metric({ "outputTokens", "completionTokens" }), + cachedInputTokens = metric({ "cachedInputTokens", "cacheReadTokens" }), + reasoningOutputTokens = metric({ "reasoningOutputTokens", "reasoningTokens" }), + totalTokens = metric({ "totalTokens", "tokens" }), + estimatedCostUsd = metric({ "estimatedCostUsd", "costUsd", "estimatedCost", "cost" }), + }, + } + + -- Daily buckets expose per-model token counts but no dollar figure. Keep the + -- model rows: they are the only way to remove a hidden provider from a day. + -- A period-wide rate allocates each model's own known cost across its days; + -- missing rates stay missing rather than turning a partial day into $0. + local modelCostRates = {} + local function modelKey(provider, model) + return tostring(provider or ""):lower() .. "\31" .. tostring(model or ""):lower() + end + + local models = {} + for _, item in ipairs(type(source.models) == "table" and source.models or {}) do + if type(item) == "table" then + local model = nonEmpty(item.model) or nonEmpty(item.resolvedModel) or nonEmpty(item.id) + local provider = nonEmpty(item.provider) or "" + local tokens = number(item.totalTokens or item.tokens) + local estimatedCost = number(item.estimatedCostUsd or item.costUsd or item.estimatedCost or item.cost) + if model ~= nil and tokens ~= nil and tokens > 0 and estimatedCost ~= nil then + modelCostRates[modelKey(provider, model)] = estimatedCost / tokens + end + if model ~= nil then + table.insert(models, { + model = model, + provider = provider, + requests = number(item.requests or item.requestCount) or 0, + totalTokens = tokens or 0, + estimatedCostUsd = estimatedCost or 0, + }) + end + end + end + table.sort(models, function(a, b) return a.requests > b.requests end) + usage.models = models + + local function normalizeDayModels(day) + local result = {} + for _, item in ipairs(type(day.models) == "table" and day.models or {}) do + if type(item) == "table" then + local model = nonEmpty(item.model) or nonEmpty(item.resolvedModel) or nonEmpty(item.id) + local provider = nonEmpty(item.provider) or "" + if model ~= nil then + local requests = number(item.requests or item.requestCount) or 0 + local tokens = number(item.totalTokens or item.tokens) or 0 + local rate = modelCostRates[modelKey(provider, model)] + table.insert(result, { + model = model, + provider = provider, + requests = requests, + totalTokens = tokens, + estimatedCostUsd = tokens > 0 and rate ~= nil and tokens * rate or nil, + }) + end + end + end + return result + end + + local function dailyEstimatedCost(day, dayModels) + local direct = number(day.estimatedCostUsd or day.costUsd or day.estimatedCost or day.cost) + if direct ~= nil then return direct end + if #dayModels == 0 then return nil end + + local total, hasUsage = 0, false + for _, item in ipairs(dayModels) do + if item.requests > 0 or item.totalTokens > 0 then + hasUsage = true + if item.estimatedCostUsd == nil then return nil end + total = total + item.estimatedCostUsd + end + end + return hasUsage and total or nil + end + + -- Daily buckets, trimmed to what the panel's contribution grid draws. The + -- weekday is resolved here so the panel never has to parse a date. `all` + -- preserves the API's deduplicated request total when a later settings change + -- removes the filter without a successful usage refresh. + local days = {} + for _, day in ipairs(type(source.days) == "table" and source.days or {}) do + if type(day) == "table" then + local date = day.date or day.day or day.localDate + if type(date) == "string" then + date = date:sub(1, 10) + local dayModels = normalizeDayModels(day) + local entry = { + date = date, + weekday = weekdayOf(date), + requests = number(day.requests or day.requestCount) or 0, + totalTokens = number(day.totalTokens or day.tokens) or 0, + estimatedCostUsd = dailyEstimatedCost(day, dayModels), + models = dayModels, + } + entry.all = { + requests = entry.requests, + totalTokens = entry.totalTokens, + estimatedCostUsd = entry.estimatedCostUsd, + } + table.insert(days, entry) + end + end + end + usage.days = days + + local providers = {} + for _, item in ipairs(type(source.providers) == "table" and source.providers or {}) do + if type(item) == "table" then + local id = nonEmpty(item.provider) or nonEmpty(item.id) or nonEmpty(item.name) + if id ~= nil then + table.insert(providers, { + id = id, + requests = number(item.requests or item.requestCount) or 0, + totalTokens = number(item.totalTokens or item.tokens) or 0, + estimatedCostUsd = number(item.estimatedCostUsd or item.costUsd or item.cost) or 0, + shareRatio = number(item.shareRatio), + }) + end + end + end + table.sort(providers, function(a, b) return a.requests > b.requests end) + usage.providers = providers + + return usage +end + +local function filterUsageDays(usage, providerStates) + if type(usage) ~= "table" then return usage end + + local hidden = {} + local function hide(id) + if id ~= nil then hidden[tostring(id):lower()] = true end + end + for id in pairs(common.hiddenProviders()) do hide(id) end + for _, provider in ipairs(providerStates or {}) do + if provider.enabled ~= true then hide(provider.id) end + end + -- Codex-login usage is filed under either id depending on the endpoint. + if hidden.openai then hidden.codex = true end + if hidden.codex then hidden.openai = true end + + local function isHidden(id) + return hidden[tostring(id or ""):lower()] == true + end + + -- Match the panel's definition of an active filter: an excluded id has to be + -- present in the usage response, not merely typed into settings. + local excluded = false + for _, item in ipairs(usage.providers or {}) do + if isHidden(item.id) then excluded = true break end + end + if not excluded then + for _, item in ipairs(usage.models or {}) do + if isHidden(item.provider) then excluded = true break end + end + end + + usage.today = nil + local today = os.date("%Y-%m-%d") + for _, day in ipairs(usage.days or {}) do + local all = type(day.all) == "table" and day.all or { + requests = day.requests, + totalTokens = day.totalTokens, + estimatedCostUsd = day.estimatedCostUsd, + } + day.all = all + + local dayModels = type(day.models) == "table" and day.models or {} + if excluded and #dayModels > 0 then + local requests, totalTokens, estimatedCost = 0, 0, 0 + local completeCost = true + for _, item in ipairs(dayModels) do + if not isHidden(item.provider) then + requests = requests + (number(item.requests) or 0) + totalTokens = totalTokens + (number(item.totalTokens) or 0) + if (number(item.requests) or 0) > 0 or (number(item.totalTokens) or 0) > 0 then + local cost = number(item.estimatedCostUsd) + if cost == nil then + completeCost = false + else + estimatedCost = estimatedCost + cost + end + end + end + end + day.requests = requests + day.totalTokens = totalTokens + day.estimatedCostUsd = completeCost and estimatedCost or nil + else + day.requests = number(all.requests) or 0 + day.totalTokens = number(all.totalTokens) or 0 + day.estimatedCostUsd = number(all.estimatedCostUsd) + end + + if day.date == today then + usage.today = { + requests = day.requests, + totalTokens = day.totalTokens, + estimatedCostUsd = day.estimatedCostUsd, + } + end + end + return usage +end + +-- Active first, then healthy, then anything needing attention, then paused; +-- ties keep OpenCodex's own ordering. +local function sortedAccounts(accounts) + local decorated = {} + for index, account in ipairs(accounts or {}) do + table.insert(decorated, { account = account, index = index }) + end + local function rank(account) + if account.paused == true then return 4 end + if account.needsReauth == true or account.quotaUnavailable == true then return 3 end + if account.active == true then return 1 end + local health = type(account.health) == "string" and account.health:lower() or "" + if health == "degraded" or health == "unhealthy" or health == "unavailable" or health == "error" then return 3 end + return 2 + end + table.sort(decorated, function(a, b) + local ar, br = rank(a.account), rank(b.account) + if ar == br then return a.index < b.index end + return ar < br + end) + local result = {} + for _, item in ipairs(decorated) do table.insert(result, item.account) end + return result +end + +local function poolFrom(value) + if type(value) ~= "table" then return nil end + return { + strategy = type(value.accountPoolStrategy) == "string" and value.accountPoolStrategy or nil, + stickyLimit = number(value.accountPoolStickyLimit), + autoSwitchThreshold = number(value.autoSwitchThreshold), + failoverThreshold = number(value.upstreamFailoverThreshold), + } +end + +local function initialSnapshot() + return { + schemaVersion = 1, + status = "loading", + generatedAtMs = nowMs(), + lastSuccessfulAtMs = nil, + refreshing = true, + error = nil, + providers = {}, + usage = nil, + } +end + +local function publish(value) + snapshot = value + noctalia.state.set("snapshot", value) +end + +local function mergeSnapshot(results, previous) + local providers + if results.providers ~= nil and results.providers.data ~= nil then + providers = providerList(results.providers.data) + -- Provider discovery carries no accounts or quotas; keep the last good ones + -- so a partial refresh never blanks the panel. + for _, provider in ipairs(providers) do + local old = providerById(previous.providers or {}, provider.id) + if old ~= nil then + provider.quota = copy(old.quota) + provider.accounts = copy(old.accounts or {}) + provider.pool = copy(old.pool) + end + end + else + providers = copy(previous.providers or {}) + end + + for _, provider in ipairs(providers) do + provider.accounts = provider.accounts or {} + end + + if results.providerQuotas ~= nil and results.providerQuotas.data ~= nil then + for _, item in ipairs(providerQuotaList(results.providerQuotas.data)) do + local provider = ensureProvider(providers, item.id) + provider.quota = item.quota + -- /api/providers only knows the bare id ("openai"); prefer a real name. + if item.label ~= nil and provider.label == provider.id then + provider.label = item.label + end + end + end + + local activeData = results.active and results.active.data or nil + local activeId = activeIdFrom(activeData) + local previousOpenAi = providerById(previous.providers or {}, "openai") + if activeId == nil and previousOpenAi ~= nil then + for _, account in ipairs(previousOpenAi.accounts or {}) do + if account.active == true then + activeId = account.id + break + end + end + end + + if results.codexAccounts ~= nil and results.codexAccounts.data ~= nil then + local codexAccounts = sortedAccounts(normalizeAccounts(results.codexAccounts.data, activeId)) + -- Only vouch for an OpenAI provider that /api/providers did not list if the + -- Codex pool actually holds accounts; otherwise a deployment that never + -- configured Codex grows an empty "OpenAI" group. + if #codexAccounts > 0 or providerById(providers, "openai") ~= nil then + local openai = ensureProvider(providers, "openai", "OpenAI") + if openai.label == "openai" then openai.label = "OpenAI" end + openai.accounts = codexAccounts + if activeData ~= nil then + openai.pool = poolFrom(activeData) + end + end + end + + for providerId, result in pairs(results.oauth) do + if result.data ~= nil then + local provider = ensureProvider(providers, providerId) + provider.accounts = sortedAccounts(normalizeAccounts(result.data, activeIdFrom(result.data))) + end + end + + for _, provider in ipairs(providers) do + provider.label = displayLabel(provider.id, provider.label) + end + + local usage = copy(previous.usage) + if results.usage ~= nil and results.usage.data ~= nil then + usage = normalizeUsage(results.usage.data) + end + usage = filterUsageDays(usage, providers) + + local anySuccess, hasFailure, hasAuthError, hasOffline = false, false, false, false + local firstErrorKind = nil + local function inspect(result) + if result == nil then return end + if result.data ~= nil then + anySuccess = true + elseif result.error ~= nil and result.error.kind ~= "not_found" then + hasFailure = true + if result.error.kind == "auth_error" then hasAuthError = true end + if result.error.kind == "offline" then hasOffline = true end + if firstErrorKind == nil then firstErrorKind = result.error.kind end + end + end + inspect(results.providers) + inspect(results.codexAccounts) + inspect(results.active) + inspect(results.providerQuotas) + inspect(results.usage) + for _, result in pairs(results.oauth) do inspect(result) end + + local status, errorKind = "ok", nil + if hasAuthError then + status, errorKind = "auth_error", "auth_error" + elseif not anySuccess and hasOffline then + status, errorKind = "offline", "offline" + elseif hasFailure then + status = "degraded" + errorKind = anySuccess and "partial_failure" or (firstErrorKind or "api_error") + if not anySuccess then status = "offline" end + end + + return { + schemaVersion = 1, + status = status, + generatedAtMs = nowMs(), + lastSuccessfulAtMs = anySuccess and nowMs() or previous.lastSuccessfulAtMs, + refreshing = false, + error = errorKind ~= nil and { kind = errorKind } or nil, + providers = providers, + usage = usage, + } +end + +local refresh + +local function finishRefresh(results, previous) + publish(mergeSnapshot(results, previous)) + inFlight = false + if refreshPending then + local runForce = forcePending + refreshPending = false + forcePending = false + refresh(runForce) + end +end + +function refresh(force) + if inFlight then + refreshPending = true + if force then forcePending = true end + return + end + + inFlight = true + -- Re-read the file once per refresh so OpenCodex token rotation recovers + -- without requiring a settings change. The first request repopulates it and + -- every other request in this refresh reuses that value. + tokenCache = { path = nil, value = nil, loaded = false } + generation = generation + 1 + local myGeneration = generation + refreshStartedMs = nowMs() + lastRefreshMs = refreshStartedMs + if force then lastForcedRefreshMs = refreshStartedMs end + + local previous = snapshot or initialSnapshot() + if not previous.refreshing then + -- Shallow copy: mergeSnapshot only reads `previous`, and every field it + -- keeps is deep-copied there. + local loading = {} + for key, value in pairs(previous) do loading[key] = value end + loading.refreshing = true + loading.generatedAtMs = refreshStartedMs + publish(loading) + end + + local results = { oauth = {} } + local pending = 0 + -- Discovery has to finish before `pending` can mean anything: it is what + -- schedules the per-provider OAuth requests. + local discoveryComplete = false + + local function maybeFinish() + -- A refresh abandoned by the watchdog must not publish when its late + -- callbacks finally land on top of a newer one. + if generation ~= myGeneration then return end + if pending ~= 0 or not discoveryComplete then return end + finishRefresh(results, previous) + end + + local function addRequest(name, path, onData) + pending = pending + 1 + request(path, function(data, err) + results[name] = { data = data, error = err } + if data ~= nil and onData ~= nil then onData(data) end + pending = pending - 1 + maybeFinish() + end) + end + + addRequest("providers", "/api/providers", function(data) + for _, provider in ipairs(providerList(data)) do + -- A provider hidden in the plugin settings is never drawn, so there is no + -- reason to spend a round trip fetching its accounts and quota. + if provider.enabled and provider.oauth and not common.isHidden(provider.id) + and provider.id ~= "openai" and provider.id ~= "codex" then + local query = "?provider=" .. noctalia.string.urlEncode(provider.id) + -- Only Anthropic exposes per-account quota through this endpoint. + if provider.id:lower() == "anthropic" then + query = query .. ""a=1" .. (force and "&refresh=1" or "") + end + pending = pending + 1 + request("/api/oauth/accounts" .. query, function(accounts, err) + results.oauth[provider.id] = { data = accounts, error = err } + pending = pending - 1 + maybeFinish() + end) + end + end + end) + -- Set unconditionally: a failed discovery must still release maybeFinish, + -- otherwise one unreachable /api/providers wedges the service permanently. + discoveryComplete = true + + addRequest("codexAccounts", "/api/codex-auth/accounts" .. (force and "?refresh=1" or "")) + addRequest("active", "/api/codex-auth/active") + addRequest("providerQuotas", "/api/provider-quotas" .. (force and "?refresh=1" or "")) + addRequest("usage", "/api/usage?range=" .. USAGE_RANGE .. "&surface=all") + maybeFinish() +end + +local function findCodexAccount(accountId) + local provider = providerById((snapshot and snapshot.providers) or {}, "openai") + for _, account in ipairs((provider and provider.accounts) or {}) do + if account.id == accountId then return account end + end + return nil +end + +local function publishAction(value) + noctalia.state.set("action", value) +end + +local function runAccountAction(command) + if actionInFlight then return end + local accountId = nonEmpty(command.accountId) + local account = accountId ~= nil and findCodexAccount(accountId) or nil + local actionType = command.type + if account == nil or account.paused == true or account.needsReauth == true then + publishAction({ status = "error", type = actionType, accountId = accountId, code = "invalid_account" }) + return + end + if actionType == "reset_account" then + local credits = account.quota and number(account.quota.resetCredits) or nil + if credits == nil or credits <= 0 then + publishAction({ status = "error", type = actionType, accountId = accountId, code = "no_credit" }) + return + end + end + + actionInFlight = true + actionGeneration = actionGeneration + 1 + local myActionGeneration = actionGeneration + actionStartedMs = nowMs() + publishAction({ status = "working", type = actionType, accountId = accountId }) + tokenCache = { path = nil, value = nil, loaded = false } + + local path, method, body + if actionType == "select_account" then + path, method = "/api/codex-auth/active", "PUT" + body = noctalia.json.encode({ accountId = accountId }) + else + path, method = "/api/codex-auth/reset-credits/consume", "POST" + body = noctalia.json.encode({ accountId = accountId }) + end + + request(path, function(data, err) + if actionGeneration ~= myActionGeneration then return end + actionInFlight = false + if err ~= nil then + publishAction({ + status = "error", + type = actionType, + accountId = accountId, + code = err.code or err.kind, + httpStatus = err.status, + }) + return + end + + local code = type(data) == "table" and nonEmpty(data.code) or nil + if actionType == "reset_account" and code ~= "reset" and code ~= "already_redeemed" then + publishAction({ status = "error", type = actionType, accountId = accountId, code = code or "api_error" }) + refresh(true) + return + end + + publishAction({ + status = "success", + type = actionType, + accountId = accountId, + code = code, + remaining = type(data) == "table" and number(data.remaining) or nil, + }) + refresh(true) + end, { method = method, body = body }) +end + +noctalia.state.watch("command", function(command) + if type(command) ~= "table" then return end + if command.type == "refresh" then + refresh(true) + elseif command.type == "select_account" or command.type == "reset_account" then + runAccountAction(command) + end +end) + +function onConfigChanged() + tokenCache = { path = nil, value = nil, loaded = false } + refresh(true) +end + +function update() + local now = nowMs() + + -- Apply the same lost-callback protection to mutations. A late response from + -- an abandoned action is ignored rather than overwriting a newer result. + if actionInFlight and now - actionStartedMs >= REFRESH_TIMEOUT_MS then + actionGeneration = actionGeneration + 1 + actionInFlight = false + publishAction({ status = "error", code = "timeout" }) + end + + -- A refresh whose callbacks were lost would otherwise block every later one. + -- Bumping the generation orphans it; the in-flight requests still settle + -- normally so the concurrency counters stay honest. + if inFlight and now - refreshStartedMs >= REFRESH_TIMEOUT_MS then + generation = generation + 1 + inFlight = false + refreshPending = false + forcePending = false + end + + local forceMinutes = configNumber("force_refresh_minutes", DEFAULT_FORCE_MINUTES, 5, 60) + local pollSeconds = configNumber("poll_seconds", DEFAULT_POLL_SECONDS, 10, 300) + -- Forced first: it also satisfies the normal poll, and checking it second + -- lets a short poll interval starve it by a tick every time. + if now - lastForcedRefreshMs >= forceMinutes * 60000 then + refresh(true) + elseif now - lastRefreshMs >= pollSeconds * 1000 then + refresh(false) + end +end + +publish(initialSnapshot()) +publishAction({ status = "idle" }) +lastForcedRefreshMs = nowMs() +noctalia.setUpdateInterval(1000) +refresh(false) diff --git a/opencodex-bar/thumbnail.webp b/opencodex-bar/thumbnail.webp new file mode 100644 index 00000000..532c16d2 Binary files /dev/null and b/opencodex-bar/thumbnail.webp differ diff --git a/opencodex-bar/translations/en.json b/opencodex-bar/translations/en.json new file mode 100644 index 00000000..ec501dfa --- /dev/null +++ b/opencodex-bar/translations/en.json @@ -0,0 +1,163 @@ +{ + "settings": { + "base_url": { + "label": "OpenCodex base URL", + "description": "Local OpenCodex Management API URL." + }, + "admin_token_file": { + "label": "Admin token file", + "description": "Path to the OpenCodex admin token file. Defaults to OpenCodex's own ~/.opencodex/admin-api-token. Ignored when OPENCODEX_ADMIN_AUTH_TOKEN is set." + }, + "poll_seconds": { + "label": "Polling interval", + "description": "How often to read cached OpenCodex data, in seconds." + }, + "force_refresh_minutes": { + "label": "Forced refresh interval", + "description": "How often to ask OpenCodex to refresh quota data, in minutes." + }, + "show_percentage": { + "label": "Show percentage used", + "description": "Show the percentage used next to the glyph. Turn off for a glyph-only widget." + }, + "glyph": { + "label": "Glyph", + "description": "Glyph used when the bar icon is set to manual selection." + }, + "icon_source": { + "label": "Bar icon", + "description": "What the bar shows.", + "active": "Glyph of the provider in use", + "fixed": "Manual glyph selection", + "bars": "Usage gauge" + }, + "hidden_providers": { + "label": "Hidden providers", + "description": "Comma-separated provider ids to leave out of the bar and the panel entirely, e.g. \"anthropic, xai\". OpenCodex keeps using them." + }, + "theme_colors": { + "label": "Use theme colours", + "description": "Colour quotas with Noctalia's palette instead of the OpenCodex dashboard's green/amber/red." + } + }, + "panel": { + "title": "OpenCodex" + }, + "action": { + "refresh": "Refresh quotas", + "dashboard": "Open the OpenCodex dashboard", + "select": "Switch Account", + "selecting": "Switching…", + "use_reset": "Use Reset", + "resetting": "Resetting…", + "cancel": "Cancel", + "confirm_select": "Confirm", + "confirm_reset": "Confirm" + }, + "confirm": { + "select": "Use {account} for the next turn?", + "reset": "Use one reset credit for {account}? This immediately resets its current usage limits." + }, + "action_result": { + "selected": "Account selected for the next turn.", + "select_failed": "Could not change the active account.", + "reset": "Usage limits reset successfully.", + "reset_remaining": "Usage limits reset. {count} reset credits remain.", + "reset_failed": "Could not use the reset credit.", + "nothing_to_reset": "This account currently has no usage limits to reset.", + "no_credit": "No reset credit is available for this account." + }, + "status": { + "loading": "Contacting OpenCodex…", + "connected": "Connected", + "offline": "OpenCodex unavailable", + "auth_error": "OpenCodex authentication failed", + "api_error": "OpenCodex API error", + "invalid_json": "Unexpected response from OpenCodex", + "partial_failure": "Some data could not be refreshed", + "stale_data": "Showing partially refreshed data", + "never_updated": "Not updated yet", + "updated_seconds": "Updated {seconds}s ago", + "updated_minutes": "Updated {minutes}m ago" + }, + "empty": { + "auth_hint": "Set OPENCODEX_ADMIN_AUTH_TOKEN or select an admin token file in plugin settings.", + "offline_hint": "Could not reach the local OpenCodex service.", + "no_providers": "No enabled providers", + "no_providers_hint": "OpenCodex is reachable but reports no enabled provider." + }, + "window": { + "five_hour": "5 hour", + "weekly": "Weekly", + "monthly": "Monthly" + }, + "quota": { + "title": "Quota", + "used": "{percent}% used", + "unavailable": "Quota unavailable", + "stale": "Quota refresh unavailable; showing last good data", + "reset_credits": { + "one": "1 reset credit available", + "other": "{count} reset credits available" + } + }, + "reset": { + "pending": "reset pending", + "minutes": "resets in {minutes}m", + "hours": "resets in {hours}h {minutes}m", + "days": "resets in {days}d {hours}h" + }, + "account": { + "active": "CURRENT", + "paused": "PAUSED", + "reauth": "REAUTH", + "warning": "WARNING", + "fallback": "Account", + "none": "No accounts", + "main": "Main Account" + }, + "pool": { + "strategy": "Pool: {strategy}" + }, + "usage": { + "today": "Today", + "unavailable": "Usage unavailable", + "requests": "{count} requests", + "tokens": "{count} tokens", + "today_cost": "Today estimated cost", + "requests_label": "Requests", + "tokens_label": "Tokens", + "cached_label": "Cached input", + "reasoning_label": "Reasoning output", + "cost_label": "Estimated cost", + "by_provider": "By provider", + "window": "Last {range}", + "grid_caption": "{days} days · busiest {busiest} requests", + "grid_day": { + "one": "{date} · 1 request · {cost} estimated", + "other": "{date} · {count} requests · {cost} estimated" + }, + "less": "Less", + "more": "More" + }, + "tab": { + "accounts": "Accounts", + "usage": "Usage" + }, + "bar": { + "used_tooltip": { + "one": "{used}% used on 1 account", + "other": "{used}% used across {count} accounts" + }, + "no_quota": "No quota reported" + }, + "weekday": { + "mon": "M", + "tue": "T", + "wed": "W", + "thu": "T", + "fri": "F", + "sat": "S", + "sun": "S" + } +} diff --git a/opencodex-bar/widget.luau b/opencodex-bar/widget.luau new file mode 100644 index 00000000..09aaac39 --- /dev/null +++ b/opencodex-bar/widget.luau @@ -0,0 +1,173 @@ +--!nonstrict +-- Compact bar view: one indicator and how much of the quota is spent across +-- every account. +-- All data comes from the service snapshot. + +local common = require("./common.luau") + +local PANEL_ID = "wy3z/opencodex-bar:panel" + +-- The bar keeps the theme's own ink until things are actually urgent, then goes +-- red past 90% used. OpenCodex's --red, light and dark. +local CRITICAL_USED = 90 +local RED = { light = "#b91c1c", dark = "#f87171" } + +-- `theme_colors` (plugin-level, so the bar and the panel move together) swaps +-- OpenCodex's red for Noctalia's own error role. +local function pick(pair) + if common.themeColors() then return "error" end + return noctalia.isDarkMode() and pair.dark or pair.light +end + +local function configured(key, fallback) + local value = noctalia.getConfig(key) + if value == nil then return fallback end + return value +end + +-- Mean consumption over every account that reports a quota: "you have spent +-- this much of your capacity". +local function cumulativeUsed(snapshot) + local total, counted = 0, 0 + for _, provider in ipairs(snapshot.providers or {}) do + if common.providerVisible(provider) then + local hasAccountQuota = common.anyAccountQuota(provider) + for _, account in ipairs(provider.accounts or {}) do + local used = common.accountUsed(provider, account, hasAccountQuota) + if used ~= nil then + total = total + used + counted = counted + 1 + end + end + end + end + if counted == 0 then return nil end + return total / counted, counted +end + +-- Which provider the mark speaks for: whichever account OpenCodex is currently +-- routing through. With nothing marked active, the tightest provider is a +-- better guess than an arbitrary first entry. +local function iconProvider(snapshot) + local fallback, tightestId, tightestUsed = nil, nil, nil + for _, provider in ipairs(snapshot.providers or {}) do + if common.providerVisible(provider) then + if fallback == nil then fallback = provider.id end + local hasAccountQuota = common.anyAccountQuota(provider) + for _, account in ipairs(provider.accounts or {}) do + if account.active == true then return provider.id end + local used = common.accountUsed(provider, account, hasAccountQuota) + if used ~= nil and (tightestUsed == nil or used > tightestUsed) then + tightestUsed = used + tightestId = provider.id + end + end + end + end + return tightestId or fallback +end + +-- Provider marks are kept slightly smaller than the base glyph metric so their +-- dense brand shapes match the visual weight of the usage gauge. +local MARK_SIZE = 13 +local GAUGE_H = 12 +local GAUGE_W = 8 + +local function usedColorFor(used) + return used >= CRITICAL_USED and pick(RED) or "on_surface" +end + +-- A vertical gauge for the aggregate figure: it fills as quota is spent, so a +-- full gauge means no headroom left. ui.progress cannot do this: ProgressBar +-- supports a Vertical orientation internally, but the reconciler never exposes +-- it, so the gauge is a track column holding a level box pinned to the bottom. +local function usageGauge(used) + if used == nil then return nil end + local level = math.max(0, math.min(100, used)) / 100 + -- Keep a sliver visible at zero so the gauge never reads as "no data". + local filled = level <= 0 and 1 or math.max(2, math.floor(GAUGE_H * level + 0.5)) + return ui.column({ + key = "gauge", + width = GAUGE_W, + height = GAUGE_H, + radius = 3, + fill = "on_surface/0.18", + justify = "end", + align = "stretch", + }, { + ui.box({ key = "level", height = filled, radius = 2, fill = usedColorFor(used) }), + }) +end + +local function usedColor(used, status) + if status == "offline" or status == "auth_error" then return pick(RED) end + if used == nil then return "on_surface" end + return usedColorFor(used) +end + +local function tooltip(snapshot, used, counted) + local lines = { noctalia.tr("panel.title") } + if used ~= nil then + table.insert(lines, noctalia.trp("bar.used_tooltip", counted, { + used = string.format("%.0f", used), + count = tostring(counted), + })) + else + table.insert(lines, noctalia.tr("bar.no_quota")) + end + if snapshot.error ~= nil and snapshot.error.kind ~= nil then + table.insert(lines, noctalia.tr("status." .. snapshot.error.kind)) + end + return table.concat(lines, "\n") +end + +local function render(snapshot) + snapshot = snapshot or {} + local status = snapshot.status or "loading" + local used, counted = cumulativeUsed(snapshot) + local color = usedColor(used, status) + + local mode = configured("icon_source", "bars") + local critical = used ~= nil and used >= CRITICAL_USED + + local icon = nil + if mode == "bars" then + icon = usageGauge(used) + else + local glyph = mode == "fixed" + and configured("glyph", "brand-openai") + or common.providerGlyph(iconProvider(snapshot)) or "robot" + if critical and mode ~= "fixed" then glyph = "alert-triangle-filled" end + icon = ui.glyph({ key = "glyph", name = glyph, size = MARK_SIZE, color = color }) + end + + local children = { icon } + if configured("show_percentage", true) then + table.insert(children, ui.label({ + key = "used", + text = used ~= nil and string.format("%.0f%%", used) or "—", + color = color, + })) + end + + local container = barWidget.isVertical() and ui.column or ui.row + barWidget.render(container({ gap = 4, align = "center" }, children)) + barWidget.setTooltip(tooltip(snapshot, used, counted or 0)) +end + +noctalia.state.watch("snapshot", function(value) + render(value) +end) + +function onClick() + noctalia.togglePanel(PANEL_ID) +end + +function onRightClick() + noctalia.state.set("command", { type = "refresh", nonce = noctalia.nowMs() }) +end + +-- Nothing here is time-driven: renders are pushed by the snapshot watcher, so +-- the host's update tick only needs to exist, not to be frequent. +noctalia.setUpdateInterval(600000) +render(noctalia.state.get("snapshot"))