From 5357811370ca6fee0e9c6c16075bb490154b2be4 Mon Sep 17 00:00:00 2001 From: wy3z Date: Sat, 22 Aug 2026 13:49:55 +0100 Subject: [PATCH 1/3] Add OpenCodexBar plugin --- opencodex-bar/README.md | 58 ++ opencodex-bar/common.luau | 102 +++ opencodex-bar/panel.luau | 715 +++++++++++++++++++ opencodex-bar/plugin.toml | 109 +++ opencodex-bar/service.luau | 1024 ++++++++++++++++++++++++++++ opencodex-bar/thumbnail.webp | Bin 0 -> 51674 bytes opencodex-bar/translations/en.json | 143 ++++ opencodex-bar/widget.luau | 173 +++++ 8 files changed, 2324 insertions(+) create mode 100644 opencodex-bar/README.md create mode 100644 opencodex-bar/common.luau create mode 100644 opencodex-bar/panel.luau create mode 100644 opencodex-bar/plugin.toml create mode 100644 opencodex-bar/service.luau create mode 100644 opencodex-bar/thumbnail.webp create mode 100644 opencodex-bar/translations/en.json create mode 100644 opencodex-bar/widget.luau diff --git a/opencodex-bar/README.md b/opencodex-bar/README.md new file mode 100644 index 00000000..e22ec331 --- /dev/null +++ b/opencodex-bar/README.md @@ -0,0 +1,58 @@ +# OpenCodexBar + +Read-only [OpenCodex](https://github.com/lidge-jun/opencodex) account, quota, and usage monitor for Noctalia. + +## Plugin + +| Field | Value | +| --- | --- | +| ID | `wy3z/opencodex-bar` | +| Entries | Bar widget: `usage`; panel: `panel`; service: `service` | + +Only `service` contacts OpenCodex. Widget and panel read a shared snapshot 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: health, reauth, quota windows, resets, Codex reset credits +- 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 `GET` to `base_url` only (`X-OpenCodex-API-Key`). No mutating calls. 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. +- 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. 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..65e38488 --- /dev/null +++ b/opencodex-bar/panel.luau @@ -0,0 +1,715 @@ +--!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 activeTab = "usage" +local panelOpen = false +local renderPanel +local hoveredUsageDate = nil +local selectedUsageDate = nil + +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 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 + +-- 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 title = { + ui.label({ text = account.label or account.id, flexGrow = 1, fontWeight = "bold", maxWidth = 250 }), + } + if status ~= nil then + table.insert(title, ui.label({ text = status, color = statusColor, fontSize = 11, 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 + table.insert(card, ui.row({ key = key .. ":credits", gap = 5, align = "center" }, { + ui.glyph({ name = "refresh-dot", size = 12, color = "primary" }), + ui.label({ + text = noctalia.trp("quota.reset_credits", credits, { count = tostring(math.floor(credits)) }), + color = "primary", + fontSize = 11, + }), + })) + end + + 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 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, fontWeight = "bold", flexGrow = 1 }), + } + local accounts = provider.accounts or {} + local named = #accounts > 1 + + if not named and accounts[1] ~= nil then + local status, statusColor = accountStatus(accounts[1]) + if status ~= nil then + table.insert(header, ui.label({ text = status, color = statusColor, fontSize = 11, 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 = 11, + })) + 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) + +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..57b9bf9b --- /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 = "Read-only OpenCodex account, quota and usage monitor." +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..d408b6d8 --- /dev/null +++ b/opencodex-bar/service.luau @@ -0,0 +1,1024 @@ +--!nonstrict +-- OpenCodexBar service. This is the only entry that talks to OpenCodex. +-- The other entries receive only the normalized `snapshot` 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 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) + return { kind = kind } +end + +local function startRequest(path, callback) + local root = requestBaseUrl() + if root == nil then + callback(nil, errorFor("api_error")) + return + end + + local headers = { "Accept: application/json" } + local token = adminToken() + if token ~= nil then + table.insert(headers, "X-OpenCodex-API-Key: " .. token) + end + + local accepted = noctalia.http({ + url = root .. path, + method = "GET", + headers = headers, + }, 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")) + 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")) + return + end + if status < 200 or status >= 300 then + callback(nil, errorFor("api_error")) + 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) + table.insert(queue, function() + startRequest(path, function(data, err) + activeRequests = activeRequests - 1 + callback(data, err) + pump() + end) + 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, +-- and an alias, log label or "Main account" identifies them just as well. +local function accountLabel(account, isMain) + local label = nonEmpty(account.alias) or nonEmpty(account.logLabel) + if label ~= nil then return label end + if isMain then return noctalia.tr("account.main") 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), + 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 + +noctalia.state.watch("command", function(command) + if type(command) == "table" and command.type == "refresh" then + refresh(true) + end +end) + +function onConfigChanged() + tokenCache = { path = nil, value = nil, loaded = false } + refresh(true) +end + +function update() + local now = nowMs() + + -- 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()) +lastForcedRefreshMs = nowMs() +noctalia.setUpdateInterval(1000) +refresh(false) diff --git a/opencodex-bar/thumbnail.webp b/opencodex-bar/thumbnail.webp new file mode 100644 index 0000000000000000000000000000000000000000..532c16d2ffebe9dbf57461fd499698891c18a407 GIT binary patch literal 51674 zcmaHvQ<$bPv#!6k&1rMmw(V(8+qP}nwry+Lwr$()X?=VCwGP%g*n3y5s@$nO*PBXm zlvhbgTzp|108kSXR!~*oREGrs07U=17Yu+43Xl>JQ5XaHHw1u;{U?Gm0suC)&W=hF z!bIvCnnX~00FZy}zs$hM$^O6T{~G_PURM5FyGZwcRs8>xFvccMM*k#t|D4S6-{Svj zLjT9;X8+BU|6#-bX5Rm>o3p+1KaIkF*zvcL$Uipz$CPIOiw*x58`(SlM<4x9!)s&h z@}IT-WB=J5yos%f^1mMXpW^|X07?J}fbf6z|8M-y>HKjfDyn1VE=c+L`S0lfPx2T4hVH07#=7; z3}}F002=^+oSN!ydHwM+wfTM7eG5F%n_-R=@D`@TRB3?};z2chIl%1^V0RtLkgDx$d*~Gw<5(+;8vQ;Jf>K6bSXMw_8_W zcLv<{+x@ov27dYbojwDA+ZP}kdcd|1y#tA>+hu`7KaH=ld)yDdci^LLD1XX3;mhq( z@3p`saNHL|ZT@5QQ6M_^2#E8(`sjBpQ0ccRaN?KqG5V_W7VtOjR^YgI5jgtU^x5|T z@hS4=_gHtMx8GasXALa+w%dOF?4|QE0lGcJ?O`p|B>**nARka)bwH%|y6#<%@A?LlM;cEA@``K^(Fa1~17svZ-yI(!w0dU%{%g-E0 z^&R!`3XFTvOU_&HD*wOcxF?{dd_h|Xrd^LR`y)pc!)KR}rpvybLr`}(;SETE_ zBj7yH75E5*yIoyFx;Fdjb<$hjefG;CflisU>FR zvi528abIxPo)gI^scrEWZ2)|6g6?k%7IGobhcJ#oirSScDBQ>_?z#GzXK}`S{IMpo zrZ>YB^L?&B2=^C-xBDwawbjm*Ff0cCdOZEnWR-URlTD_WmGWfb5UG}+ zWntK;nO!I=41pV> z$^>dQ=rqgqJ?iz;gt?l9R`BhyXcE2{+2SD^l+4npTguF9h9zpgDXIu9TIRpeYimt6 z|5kDny5<1l4si@utj~2yEqBPIKit!wPc3{3mppU)RK(D`C>$*+ADg;HB5Vz}22#A3 zS7*wZI0B96Y3wXzmD$w2eQ}Zqeo123u6w4mo53k{W$wK-9Nc!iEbw`6C(DK>AaFZW zE5YL~M6QwxB7(7Y?4Ek*-fE`(U7KYYCYIR*1!fSKmxtmqV z5XK9Q`|PQl8D~y=7j4v8o%m@VEhRaGKAOay1c6(sCX`EXzDKU3&=8N(Ns9nv%XN!z zqcL;Y*N*o1v-1{#$;ipPB%frjA$RNaUbe=Bp&Ivh8Scr)%FNVb4NkpCSV+=F$%YTn zG?<5Fi3cBsoKUu-OR!j&OO+G~YpqAzNjdu`{)pH9N*kV=t^7fL5S<$rW<~;R`aPh)|O+ zfjrqe1~mkV;TVuRX`SnfPR8`D3A{yg8|(&vb*Y{DMKK>YwEwjb>28~7^b*72y#p1U z!k7G$#u=gUSyRNzr_$V0gEN1Ocrb$1^4D)R4@6s$kM0+=mkM_v6vpGi)8wcdR{c}m zCokrFtjXXFoMhT9#821RgMc(8lhoPiupn6RB6q(ap69E3**F zlDW?ag+$r>V;kAC1oHM)VcbIC0#~-To&NcauMoaP4buasWZpZ=zQYXy$1TmrHK$ja zBM>oCN>mFxG2|*`P zT>mg;HfNKyY)2gxgn(MRr^4VQii6{}^OUj#2X%AZbkEV}BkDsax1PPUO!ws_#3b`O-!`a0}QeSl(3E^r(FBM7iR;uJ5{tQ}8U~fr>0q+PY zfQ@D}xdok-?fGuz#qN6D1TU2K7`M>Q^TC~8q(dQ4DkDF!?lo|t6=SBK{w`NL$cQ5! z2a5i{R-p{&<*UT7=YdQO6VJH1Xq^F{dUj4pIw(jvYxDR1NdvN2gzrd|kq0d1kK)Hn zm?Zuo5B0%LLHd;t*|`gSaUS>=lRIWHz=$xK;c9?;o|CVM>%9oNqIx)P3$_&`LAE$! zZADaE+GgvaL}}M2w8`<=K!(%G9Rl|3XI*l;-1cGXMJn{X$1nCgc_UzGbYB;z$?@4+ z3D^^RVS5x=zN>fH7k{A6V9H{CO07&1jSX9|^0$OGPwps-lb?BU+I+Iw)WXB_-f&%i zO;(=MYSy-I@d8|%?yF`^Hc3-K|IlPWQw+{_^D3Tubv`lAnY>+H?a%g5;i+M^!h1+T zeocnOEaUA(5}hltzjv_hJkQvJ97MLtkE9}}jx(0vR60%;bD3)N@YDhB=ynDjqYFNj zjn+PX4Qz0gvBAcUiI`YXC#m)>GOh#H8Ld=_6n*@uZt(@+!@hSsSSEY6Tzy$l;d)D@ zOVdp;LxQc@9m9J!X z9lo^|X#p;4aWv4DP8Z9AJGZa}f}9<=7?z_!`qQ@ldvb>a>pkbVW09^s^^&S$RaUZ} zjf)}f#Ir7?%TDA!KRXsvp8uTBxG~4S4+9Jf7{fgyo#(CbtKN5!ub}8(@39=6#$%nf)7SC znmg%1@Adt?MI|KJ4={hAGN%Sy8LE;!56HB`XX#vVmJ}OCA>rg_0i(W%hz2bJ>{&LN zXB3y}%-JMD5Oh28yFJi!ZGk~}^{@H!wLdZ;4i~5Q97zpwZ ziA#k_@I*FBYutRjkt}RIw%H5 zPQ|YJl84E4;uv~C0o8JgtA+q$)#dnVs1Z)*87XWzAq&&M!zuYg$a`d1=a4|bMDVwI z2UZvFj>2h$ZW_~7-G z^amb|lD|#QJo2@*ej!AITmYIJa$eW2AGURVd>HSy4&!)PZ)~C@2dgIP=k`yZGNF`b z> zX2r!m@8*JrX#uI7&FFF#pL>T!uwy~k>yfJeREWNZ z?2pk;7%!6#eVW)svpaN7)PfO2@#$dRa?*B;5%J?|th(~4LWPWpPkkF~PT@{0KFoxU zm4zQ@e2jb>Jkt0l4-X~XGKy%80S-fxlS%z!o1se(Wh>bz5oZkyIpT0lpiHVRJwGP* z&E@Z|0VK8P5)2y~SFT-|$QLUTXXtEjR98sZEj6ezszu`c`u281Wts)}X)n(4MlxpD zl?*S22Zy||k^!syCXj!LvY!#u7iJ4nrbFXWL$^=fA5(TCq0@R9N6<~6^~!zfJS;_Y z@$w@ft-*(Xi)h>G461Al6r)dLT~;{n5_9mL#Sk1|jEfYJzi5Wro;U$ZP-5+%N?YiJ zs&1#kKMWHqS!xS_#L!4%O+h+!)5Crf}CM6BB@aBu;oo!V!d0dKbGX$%;G8FA43aBnCH zCfBnnkgxbnPJ00rUSAUT%(7_2>Nyy9HT*;`?taPcO7A{9!;4U^o5vpOI5Bj^_KR$U zt<{lKp+NBK4`kj}nrliat%o*m-n2HQ3kN-K*gRU&yNhx+d5u)74?uR4&lxmvBg800 z?G~gStym&4Q_5-CxqA7?_ITiDPCmlvijA5l#LjTk%K9Yqjrgh1(A%j{JQH@BJ{<8y zDyqmY_(pQUGCjCN$^qg(mms?`_;=42U9#aI2I0zQ{bDUrJTBZH*&oV;k=O&!v(N6W zr@uQB9N@)khus}d4+YECenmd_@kTs9POD8Kd&Ms-tFKy5SfPC|^6Gdwx-_Ol|YO{gE)*S-ms*>GonqU5qGVYT6=F9dhFTd8Zv! zkR;P$71`L;F}TGMyp4g-;4C8&Y1qlNU~gB8=Ww2|jN)O{JhtB|oW&YFrur~Ke?j3# z=N9&4E-pvN9=Z`9ypW>ak({UZZZeTJfET zrBY#isvKikv3hOxlP*uC#|(JidS1-mOVEhe8~B$!x!de&ouY{cT$0_Wl1f91+v&|{ zKi$=^>><3oVHA>KC}0C{#I-bW+w4R&w{Z|Ot0Bf*ka0E?Y_UD>erN29t>G{Md9N*F zq55=R(j>=NYI>43TlPxcIg{lbt;kz*pF9xiV}z%3Tn4lChzc%Q4EgmjM^@HBt7M~jg9p|W2aCzeNVB|is? zxGy~2>lse)5}wF^(BgSnZ6BmX|!m25ZHwMB8Rp^3{hb}ZmMKXjLkrAmMu8(*z5qvwB^kN+9Wr3c$B{;K*|k042! znR7VMA$a{0($Ms<*%LXGwOMhg5AgjlDJY`pmhDx^Wh;l<7VuVbo)C=E3BwuBE7{#l zHMUe4v3m4v}OVqUo8F_+TFyiicJE7G{D#L%kwMw`rbA?V?#3-i=D?!sykW z(|=g1F z>uw4pz+KorN2r1EXVl22p7di$;2_&`*0Nr>ahY2O7N8N2q3q#Qm`|3ta! zHE*)~^H=v%OiiKICzuB}fsomN@i7K{S5$QFG!=@pYT2AP>alm;-W+E`I(Q&F>Tz|p z2yF}fsX#ip(ZJ{4j&XJif>Bq>Fwjf9z0*3)+_v^7n>^I>dEsFKpE4O^U8>51Lmk3x z#86LwFwsd6=kF)#bwTb1F+UnV#+#OHn9M&07Qc-?t!PlRww4DGl55kUnN%U#`+LM}!;0Ax^I`3hNHtBh z%;y*`$;R(e(tMISo!^PTbG!g@1qJKZT z5(8Om083Sdv-H)HTkl}CH(hFDthkV5K~8#1Dt&?9DVH}0VyUPglv(DF>a+wj9XbpA zu<7S@Dkar7FP!>@hIo&~fOHM(x1IeaoHxs94jhJa7%z$u(i0X zyEX@~q?x+ja`s-4ekw}&V0U#Jt84OfSIG^^Bi{(+2MgHXdX9#r)U&`Dm$FGlZ!t~d zbH^q%wxCu zNJ{XhggW3^@t7u;igD^?4Cz1+qF=vz12gn8>U~PLf%eg4CX5& zI&n2JGy#0+L#h13a&4ge$~B6j5wamXBn!=mg0(FmpBjVB527W8B>hNxg@wEft;R}C zMAC1&LzkW49PRhW%FN?bf);lUi=0AFb^4MtKSJnY;j7Et=(J2gXP4(jMtxySF_s$* zdn9)X7Bx0e&kUB+HNH%udnO1H+(s7hs zi1-bNHC$tGs8B&XL)BCr3fcm`fH6{H8;qaHFO()zh*QIr#jG>MnE7%AuqzTi=M+W$ zo4XW`Cbq9k6@1!LD_@kH)B3w2T4t_nl_shJ*55W-EF&wbd*C3BnoXA`L*H)^-e~3@JHlUXt6Fx3|#Akq+-f_VgwdNPgFh+9MTHu)AU6nMH(6CM)>E= z6kwK_d)TuZbmEr#Fi%Zc)*#O*xIbE-(+k|xm#z)5y=9&Kq}K3>ihy?E7^#-I2F3mA zQ2Q;?O*1Fe!X_I30kY}KZ1J=YP@2C2ktK20MHBa3z2nASFYq*+_cuPq6kueWnrc6^ zm4Q!^d$;}5kgf^Sj0fVKzilZM6S{haKg}L?Mm;1PSYMwcIj!-8+PGF=Z3@W##MsZ3 z7A}2+IrVjT67poY(P_;+9u__o-~DFPVVvC6C$ zZA~gSWsJqAvG%BoVVTCU=b@qya02(c_iX$!GV^SVa26Y8Cd0kT{E-ah!7uQ5u`qn~ zH5E~GzBxd_TLjr)cZ0|x=je*SP%|zV#1=VSE`zH1a+2t%;{mMuldtyRl99!$ zuzGV%_uI3feU!$O34MuFI7SKARzlvNgG3MPi<9L?rW=2jN`0!Dpi%LN4MMJC4iBOE zR_}Ujzg*r2DJtM^XqC4PJK`gJFk};LC$X?I-(AZkmX%A?`d*T(O0j8-pY!%EY9|IF zDCdw3S**}A;-%^ppXpAj_DvNlJp1)6IFw?F<}lwew}3mS81Ag0nSy-T2|WB&>h9HP zmPcXf-okf>eKQZerqOtUR1WGh_M}rs+fi^4;>gKg=tue*MqewV$uYZtN=|^nddCKZ zLP3j9YIyTQLS@BDDy9`qXmeNAB}7U;saP}QPE?T5{s#qeREVp+GHqz2;gi2$KTo>D z%32>e<|lG|X^>-F(9N6W%iv?#XD#yBT~|~`KkFS#`+i@Fx~f>k1@MXe*S|l@_OHZB zTkX|iQDWrU@}V}9OPFeirn{E3pce(47hj1UkJt85T1@j<0V@3B_{&J?6lKQ6I%+Uw zg9I9_!@E+x)sLfn%0ZJc}ci9v<9DkMk3U666C2%*(cs=U|2`Gi!& zRyl?vtSvPWa*$W2Ckq~-s+LHw=T9Q!M_Mv^0Tc2mvCdF;CBlAdbV(=%MNytu-Zj0N z7ALQt^vZ8(#Y-rV?R=-VK!r#hbA+x;&aWZPyyb_Z#lkh5F zFOFU4vo_RP@hjA{ zAwJ9+{qV?g^=qf0Jad;R%HCW+m3;%`?XeSML>F}-u`W<&QPoakKRit1yvpD6dnt`Z zYYM+1sM-Kcl zmEYiv_e^DDY;BsUo>#6kO|KF1@VXQqh_XQ)xk=Iu-B(c8y;B_xxlm@2qrroQD}SWi zLOEl=EMWoy^F!}EyWRI!m{`7g_81PCOH3VYb3JNM!yV@TCU&5fpkb#$6T7*3_@m5b zyQ!(>{f!E%vJL0c^@kd2T>O(t@e+)we&jS#ZxHgk7{(koAuM^q<(nP@*O7#)%XQ5S zn!u%Hd2F?~CChSPYtP2f{dt+6>IJAAm37xf+(4LS!gU4@Fi}`Q>gM%XoO)>N>Tgi1 zPVZD#UzvP2&1fCOtW-_ukoUv-i`Ak~eNRv=ZEwYt)tZy4I^%ne!kIntTMRerot;-p zzMMbxf_h;x5Rp&)Jlp*tbUNm0)NFGzx3uwV}Fe!dOmErBJm6p$c`O# zXy+q&Fz7)e>#p~_o4_xlbT)n51w9K)Q8Teys^*t#Km|3|hH^@c6( z(z2pT_B+MC3-b1IH;Y#frt$}60~Xj-FUX45YOrL$^Z}lcVjE4doZu)6wS5*#oW-1# z1EJDr-$OCc%uYj_zeIoQsV%M|@gAZCPhfS-v@fCraRFCqo!1pn*lh8w5JLh^GG6uZvhOdQ;IH0?eX1QgZ!a0gW}*sE=%ecB&l zh6&_wdEy~WBwf3kb9=n|piT`TLw}T=0K3?|y4R#;bG!OG|4IM&uaq4OzOcpz0d~oa z)s~tjw>`!?=*UP9EaEOa-I@N}lv9*mW)!G05AN%b@QfzMN?Wq@OgPb26#hn2X~QaRj-D^p$<(XkT-RmijZ9G+A3*q z;9UmNFG{gr4J^)|g^OC9q$8i8-VIN`+uE(n2mf*t(Xh8&SAbGD*(SHHiXw6+ur!VW z{DtJ_I3Fxl|8ND(w@c)^=Y2&pTXufJsEC5V=6876DJvWEm6A#OYu>vJ)*)zFCI_Q~ z(Hv9WIv&03(-=KJ6baz_gwKmU1!W`3y4=C{Hu#a>Z4g&xo(G{6?Dv|4DwjY|VhRLi z1erguhWJpheEE7pWc`l)+Z|wy9Z9ShnExCi_v8t)nRMPMx_<3RtwrZLzGGe=%mKUX zEL%|OM-q?OFYUYd^0(;lu0g%Lugu_bNPySj2p zC^OqYuL(siI1fW_*o0;aRX<*D%BA8qMGV#Qk4@}K%HWb#Tap&NqDy)!C&t&&k1xX` zAg%>JssRoxO;d5)hi$}Imp*~$B#shrVdnes1#iQP5Wj!$0@YrOm$y2kH$NPtSav%s zuV4(fq`PNH9lal}JYyqU6oz)4P=cLs=dWN41QADq3r>4t4KDZRS99U=djkdS_Wd$l zmRr^~5Vs1{Vyk%6AKnpK_as9di>EJ{|Fy6z$b9lj98Ir#9bz^_my)1p$h@pLu z^5bthR$G;NDC_U018q*la*4y=#N~>q%Q3*bfrZ;=1Xe_JUt3jD|61ZSFWfUo$S%I% z3vV#L|haULpZgAYr}b9cnt8Y+Lq7~T+)iz;pNHks`Kp8@cuRi1**ifX0)JRC-o zoE~z>BqG}Uix9sihSjD+yLOO?(W*e&vsGo%Nz;ve5h{I0&MdH-72}>Pm4Rx1mXK_% z#0)DI!|!F<2RpDqAaI0$Q_5ZBjh;_H%Re45=UlqUsm%j9WFbd)8a0Xtn7C}iFS5+j z)KDh?UoZg6=<~(b9?EAQYfSyS_>xGi>G)$iW9f<)^Md&br8>|40tLl~vWj84p-b~e zZwkHiH5}WrTYckbT!3%TeB2aU5mzZW9@N~BKv8pt5?pq>LE#UkvrtQD*%rkuwV?VO z0Y%Zm5T>j}*`#+ zQc6Ipc_@X>zVn+#2JsbpMiBwj;50*$2_yuEWx^Dd8+dB#1QQU;f9O!erGq{*0B7$y za-Ef*4p;ucqk0xhZt>xImDSg@BVs=JfC=ZM*bt7fJ9l?wcUY4kU);Vv1%Y5(D7d># zuunt(D%BEs2kS05RG*hBp00DL;1feHQ^U)JK52^Yo*5~Tol+NttgWqNSTK|iCs|=j zhHMtP4zt>pY?9t^9|w^Dh!R=iRZN0Nm9!=oCBB|>2l)$d(Ne|(o({Jn=e`h|p;o;I zGsfB>ULN8Og_E2zB@c2=EQ$wWf*}+M(&|x3He$sbi;S)-8<7G8C)991Sww*48 zffe+(#yu$TTNT3gNK3qhTrT4K80r?qJkukO-}J7zZbh#u@>EOucthjD6mW+ccr)&6 zBaO&aCP#%-EsU_CS3OO{pl>Y!fzZuXifny*i%-_kI56V zXYi$dgc_#Lf;b;?tmVZ75t$O}I28?z0in3h?G7}eNFYB1TB;Hx!lqd@cq6EN){-b} zb7nV~PYpjacu@pSXAnA;~4}K33+dD zYQ6EKN?`*K!%i@|Wx3t@0L=we2QflOJK@vXqUIXi87e3O!&wKjzrfl<8L!kOt<`6d ze?m14uY3L@@(_Alnh3J;q6+<%rHF zu1%=2#Je@*F}7qj4GR+=P&4t74Bo`&99n$8EW(tyQg(bDQcH7JDk+)GgI6V=Xf#+R z;2Hld-(TXn1C)KQ6`Ttn#iQt~9KHN0f)U^RqdSV~#eT+YyA+5;Un28_!cB?D_mv?e{(Etx`-FJhLQP8YW<LW59vE_sCj=_sr2A$NnH){L4BwaQ8wRUt*HrfyMMkTi?#|u1uGq zBGsjjNEdPcv5&JJ`!bwmv%6B7qmm1Y0wlkQMUC4A8AN-#Ly4bD+&EI*mx?T*UGeNDEbOAy zBX+Wl<;!+0uqS$M6&ZP3jIDgKeu_X-@-z?-nlk4=no@B@Tx<=ugiub};1^^(xA6A(lIzc(UUpI zd4G_tcx?p6vi?D1wdUQ{<2b>GE_HH;yH+QvSV8(LGO}xzE=9FC4+sh)n;`*Cy4>xJGQy;fqeaWvJT-r^1!oYADGEZbOgzAy0|^Esphq7dMwiiGP~RZcH)!RWhVBk2j!9j7lj!&=Wc ztT?stmPL%k4cF_mgxKLjf^FE^5wb|Y*LAAMc+F_;Em{NJXqb6AdnI|X3IL%QbcsBG z62yt4#mu)^!RtdvNwW$wj9AR35g+*-K~y?7&$NkMjH0Fk75n7l<03%toa5iIyV7(d zOkrBa+jK_Ki}=YqZ=)*#RhDf)YiFGx53e8uqdbK*W{{F&BtotZ+nrcB_5lgcFQdeD z%I}VstKSOwR47t(nd@lg+tWW=>(MJVdJit1#SFh&!99Sd2HZ->N7cEY#z~lx@iP}j zZEO{7?13$`rZT8YbDPeY@t`xQzVa?aJesvrT`=LU=%j;2-daO1k6UW9$!Pu;(-1ktF=^kmU7iO z`Z-CbdS=cl%F+3em_EQwYt4L(R(cO`@FD@0|C?V z?yWMv;zTro1GpA@V`!1Ia_G>@0@phL&{zJmiy3gEJUoPk)5oRIlpGRv@fO z+QlblLik_wc0H4uxqMq0U4ekE1Tpl5xuctpK4#uzR61%gQL&$h$ivG4x|WU6A&(Zm z>*Tw%#nxW*H;)&iuC$0f9c4;y{s7CMK+QQQS zY2JzLN|xAt83In~LWdvGd{AUmMpklmrUXVkQF+3Sy2oN2^slxg-%?#7-`jm-7%n zPbIOHY&fBHO!^Y=`4~XDkJ1~-+QN;Oy35gsv?jMYD>HZ^eM%iif-&AzAsP4lHusFY zYpM*3gRIoTdGw;&WI68e2y(}}a$$P1Daiy+DkRW=t%gVG2R|&c{Bu6<_eG%1+)#;S3G~Buo8j{f#XA~YbSB?EnkrrOEuO-R>`_u3cmIJu-B{eNM9K5bT{43wGt!*Rn6%qPtWdR-Xm0 zdM?U20`!v&kdjMzWs@4*a(a~j!=`au$hLbFd`DO5qAJHQE`moC9jRJqce~U${OsrN zbRcbdssLrRqhby~Gk9kv)J~B*mZB$fT|q4!Y^9_wSRk+tg(k=LX5?OTM zfni0;$*8b^SJm{hc(lO^vT{pKw|Pq|HDh7zqlGei```X@?AoA^jSn5d@#zTzIa_{? z;Y!)g9|Bs#2>;p@sgF7TDX{?*blN5Jc-lzgp>v^ps^<2O+Htdkdwl`1T(|n;aE<|X zGdxv&5g{l+R9kfd-IRe{T{Cj1Cdm0Gz{6|>tlW@2N?Ch-mrUEIA|ZFnBy}8?cxH{W==^vHgRUG-v%g#NXp59*!?d&>M6LBNK@^-b)WrxW+Fb}<&OtWvE z*MV{qqGkpW^c{ldxwu)iP*vTQJ732HyhNrUBAbv+h5(&&?|Km~}9 z2U1sdN!$~-MxFROuQ9w|EX(ff0HJUgBncns_-bI2&J2k+0UvAfwLQ0llNgAI@wI9- zO#RdjiPr0RDYnwRdL&U{t3jiDQTS6_c_D^8m4fF%6RR8Cyw=g|S~6GghQc8aS)zUr z+b+`L#C`|!m?ex$G$68+&>Wt)_u(x*K4*ien702!EoRTs5Do zUk(1AWDdpUIe4Hmp-Dob=Jys$rZxLRWbw-)H%X!y?XyJ>5KF)<0(sv?VV&iGv*FpXQ2KJt3GC|Z7a8#pD z3VldKb-T%-T+|gmP7COvD?_hvdt!XylYr)>)7tZwz?CS2lJMkDZpxh5`5N@TK8?ZAmwW3G=xjC3-hFw7DNE90souxkk@p z6rQRD2RM|GVmz!8lb&K!GgH)*C~Fc}X)r=^3F4&I5YJdQ*kdPol6G&m2MDk(xc2Fn zpt>?9cv#u6_lqbRfo!baxe=qWzWp5U^>MuRsoQ??|Cq{Iz@>R11!`C>_~qZ=SBHe` zEENA(VyHDq}6`b?HQ~c86CM=4o|Hk=x}YvNHW4q4Djfb#(|xzBl1(^ zab{P<5)w0RfOgeC<&oW#^o@s9!W0vwbSNvPzc^d=&hH{hBsnIkX_t>Bw`whZUy?_c zUcO>8@YC7pUX*FyS=P@R#x%s>H)8v^Akrb5uu;DGWm~%z(bz>~YmBOG*P(ZQ`c&xp zbjUI$iy3B^a~G&rlFRm+3LNm!Cj9VInL)q{!#;1b)0%@tKwGpt;CWbqsnypQO1F%6 zfYDodzFog~+>6l3KTmo%>$^F`U1{J~W?1HAO}2=Js9^ zwqEqXV(7LZTU0Ar!Ki=K1$1Ls)EU;24Tr?%W}H%d`XKiJ6`hRP=kPzZ%j&9N=!kW&< z{vu55C%f}KAc(T)K%2!i1-M3vuw9p*An#XlC({<%CqbIFVcL^%+Tf=DJ(mRykJO4* z5k6*>;!QR?=hAF3VvE{GIx6btRY=4eJ->jR{e5?aFsyi7M=BuEgckx8Sugx$w6(dn{QA{UMbn${cV*wv)=&vK1K(mK=?pL{ z%dx3A1djD`xG=T^EsD7V&9q#vY&^^fRTMu(*Qq!P`Fg;>?@vz&GcqP5&mt|wz;bon z!u%<{NJspvVPqRX6hIvu-=miC2EM9X9NT=D+RfgBqNU#QqM_C9-|~0mghK`7yrH-z zz|};d5%U~{ippT|{y1r;sPWc01`DM`77VzhvN$pLCPg`?(qzQOKAWv^m6%*NfxX;nuIDKGrV zbV7i|zvF%l+N;Y>{hl04U7(a#C>-k%cb-Hnd}>z$)NrU-pWpt#v&MNo#L+UQs09rS zz~^U660Z9baZMaJ|8CI8&&fDX>lmJ^muq)GU%1J7@AiAB%somd8K&>R#@%S@7onfZ z?Jh#&M|BGH-Z_qz73^RTY;?drd`LXFhmnvRoXh4D^iiJ?yNBkeB+^q-v5Av^aKsO% zUR<2a2P`>OM5|aL0rEWzr&J6k>0rV%95Ke#dDM8GKz(b^0*1ylnt<{pI(?|5dfU$) zuq8M)_T;vEzTKyoi`pY=Mxz8mAr4)tVvI@nDoCF0Msn*fFAWQD9GS)jP;$BtuJWF< zF*5pnG0|7A1NNUbxr$kYwfM#rR`$YaGY%@jml~p8ntwHxZ?Tu1=9!02H$0^Uz9svq znSb3g*|O7sk0n%>r|sculVrPhyv%$ z9x3|GgAz*c3A`ylVr zncv=?Q2KsN?d8Hke1P}S%GhK)#bi_!&~SDuv|{9?HX&MvA=SW&WV@fbmK@It2>z?baw9oNX35efCm(ygL8BsVur?K$ap7R+@T88q;( zb{0^E_q?mOCym7NzlTmK;v_v6b5_M4-bQ}Q+{vE(z~7IXmKi$xyhwAc zFXQN=AP`sfD_pC#8*0kJC%pLexb8-X@J?KHHVGc$HdHq4(NfU`#!1n0AOVsQorZfE z<-|FCMa_6~#)=CX6UYt+#=2f;!3sDw2kFdEW+DO0w-wQ4mc0xRp8Cb%ygM>TUl58O z))s$c`{YISM~syTG<_?M$7@76RR_C1qCCkmkI!WQ^3b+YS3s+66di4IXj>$Y$aApW z_xn`=d#o`t-?a2wdelLqtWv<`*wJZ`&S_0qzjpM(vi zNaIhzh8;fr76k@oI8G}6WL>g!iY={tu=;KN20W_CxGU`kXG0CgYlf<3-C7h;qH*4# z)$ZX6QW)?qN%;iC^Q@L_zgp4PN9F$mM?kp0dSE&)+;F;NQ?!zGrV^JF)+|@=uizAM7TUkJe(DsF5%O%SzWQfq05WE3`Ds~iH-74gt8 zu97oMRZ>_ZTqL7?>_G%p0fIb{Gl4Qq3@K&O11_Y9Q@ds&_QakU4Ae3x&YGImF)(#r&#fkA0eICjm_fTn(m@R zMGteFni3Gads+unO^*HgoT1}i#$g^JUavKY!XPKcBrNuoQU5v+`YfHQV3VTZ!VyTB zb8i6AyFKlRh6=m9RS<&%RRjd-^H@bu4^Yoop^M#y8+Fid{U>+Kv;kEk#~Wz8d|J+R zd(!YtzkV`4y?r8>UqZ#G?8`So@RaO8cE1T6@7G{Lhdx0=1XAPk=z)@pmOe?7Y?b`V z8>>UgbviQTe=9`ZeMe;H6fFcWhEtV*1iGZ9g!CQ@Hc5=E*_8o@ZzV>`?5clXmKpG) zVl%~Ha2hDqfu-D&rZx7JKN(}H_dkCV#^KH~*=fHrSe35dE05@+m9+I`>xyu zOC$ffs4LGpk&&OI-bC;T7A03fL^=(gb${_aQ9UX`xcm}_1O3XUOh7c1`X1XlGhM*9 z>(L+FG8HksV!dyWfb}6iohF2)4P{>wY@*Tmowe$*eNwA+^Jyo<(3HePzs@29k|Pki zn7yb@?5{-X9<$TEaac)Ylq}DTlWn)ewzsa0%%`V!P({j0O*?~ry_BGWUG@1&FH~k& zab^LsR(A&dSit-8+(M8??wa%^@?Whxd)ywQD5f#}!PSj(G$Y;R!tY#0czhG~lDT5P z-*A$?j&aI?;A0pbqF#yJ~Zie5BnWIZ@+r1 zuQRxP$AZh#Per;o?OBVZj6OtR#3Z7cJbq9AmB@3aGSlcba|`Ig(pafxl_Ujn4>Frr z7G~qqLC#g{D?sK%C=G!?cV&WA|9d&Qc|lR#X3{|81h$bq0DEy5#v@eK-0*FHVCd}3 z1eGphp{9`c1Arloz-ZBy7jAGzdc1R>(?khu%(~mfW~}RUM(T}BXm`&M()|VoxU1Fg}RLynD11@Qlvva z_7Z#T$_zg}LW0yN4-X3`ysbf;>~qf({`3pxgS6x^@tM$i^peYOvT}uyhh&^~0gird z=2KSnv1O)w!>ip#{6%!yNj!~dz@yLqDD{NE000qR>e{Z+YoO_J=X>CUnNk>w9?bAR zufcWgejlyMNX+}unJ^{ne8L5cw}Dd6ZY9XiQXKW&zT)P)VsEW$Jd-_N z?T&$9W1Ykys$$uB37>FUK}AY4MQZteM*W*+J`M!bmXt)`(t>_YHNNOlX*&4(g;^)_ z5cB+}ADRunpf>#x^X+k_;(vDwQC8W42?tF5IOFiamz)(XwQ4C66!{lE3CWxzfESNLm-VAJBb2$u@^JQ0 zYoK8$#IQkLURQ@(B~o!hf=doaiep1`yVBu!rCYU}@(INt?o5zQZs?6gXldSPSY0To z^)SwyA*6S53R==SebvisHH1g-MPC^Gk^!9?0xW9Erl8pRkcr8%fW8LmKS}9%mXc&_ zK{7wvQs@E>DU5UYHZvOURk1A#9&?!fYk2`VSo*}dGbMo$|H*6T|52;4Tt0d<;DKOl zl)i|sGLs+5jYxu~<2qaFz3BwCwVg68L6{^)eGhvyRDb|aMCmY9HjdEcsibtwwWVJ| zDk6ipPF>X2W+5oW^REAY~vM-!a7yo(Oj@_k9DULw~~Mo%G+Ap zJo0wlQGtQE>N>kTTwr9lBJQpnJEYKOZe1+oBvj7t(DCcDMElzmqa>BOG~7S(Mbtz- zM<)#c7(%9itWl5%1_<0^^Pv1{@dVC)e3NdElb_TxW&)&ce|-UM!70%zZ?vB0;Wr!8 zFZh)cY1gfsbui7mQ=uj!nLII?x%S4Ogn=MKC*at)535}R0Vl3du=(Q7u9waW9V5oI zbOTr@WG6+>z@PVIBI6rJB%}?_z>@jp)}r^=<9WAFAhW=e*XW8u45?BpIsTmyZMvLr zImleMCp*|5=xDqW?bleNFGWKCA1Kx?Rq~cVOgifSvlWPRu@fryLy=+@T}>}I6#1Wi z&srA--gXZ1!@&hK!2gtc!6v0o;d4gh?>i=VbdBwS!YQzRlT;pvjY)x4xj!GFb|Pj- z9rrn{N$d072a%?pjc%uaEE-+MHZ;gu-U4yVy(s8!dS@|FO6ghNRl6J>x_Bt3?b#`F zjPYdfe}NF?i*nGm(Ucrpr=Obp`H~7H(n*eG6_Fxfc6I{5cS*^3iFN6+w0d$;hI$j1 zUoApD$)(4QaKCx{#7;9Q0&j`}hXV5|#j(n7O9HTx} zIJh+Vd@KYXOb$TdFX}s@BA#ZDB0z`s4FiIkG4NtBPNFM$Ee@lQ&Gbadsl+o4S}Rs4 z_UXGKcb(zg$tdT5nR2^QoE>=}ma*NTlBUI&g6Wiq~8UWkwYngPxP)@;lb2h%E&S(4>I8wT42;d7WM_CP^X>;M1& z_5Y3fJ3cJ1G-!U!9x^G={#D=m*+p9FkSBFI^v4+p?4kU9IDG<2k-2wBH$$|Z3_y>Am1-VR?M>GN$I(p z?bL9tX9KszK2Z%s75wls5f~|r33)DA zEaM-=qQyqRD=jNx!_o;sOThy$!a1b+7Dl>=S4Yu%o_VUuc0bgmjk%Lj;$xA_6WSTx zx~PZNAMT_eZ`Kb2Tp*+iD7C`5=I6uE^S}$((Ikrr9}|lkO*X)l@v-$hA@36XX3hj6bl}{MHzG`_Y9%leh&_Bd9zWH?^>hW>qDISoi z9bV4G1eaR521=_gvRXfOe~d00gOFeQG_LRHA=et>Gdjl_8I#J+n_*z5?eKNdT|HFr zWVpPu zuV6YCcu?rO2T#BKkT8^mR{~9<=t~kVo+E;WIJwhotN|lHboZF^oB~#(mv`s_nk-OZ zx2YYx9I5$_N!%H$uaqVwTpDhlaubSIk#XibwzLDbhW47hpI$hZt@F_J2ZXd%v|qSU z!rzneg?(ZYC{3p_6x{S^p(BEdD?&=#ZD7xS3vwyQ8Nenb)Fc4tyNMxC67;}K8dfF-pKpOXLCs{-Ea?3d z<_UMWXM&ki12wctf4f__1F_4Km>ebKXF^sE_ttwWd#po9r1RAda3irhE-1Yaf%(>w zrFRqfD01t}%(2Z?$`QA&v(#F2E$#x+dEBL#k&#pEV=^SxAnKm159v~c!WK?<3LBlID2uUj{D)Ij~{AS|5k$> ztpa220Mp7##s5+zK^e77BZGv`-=_+C=J#a7#kUn9?rB9@8_5$80G!HLXV5pMHa_>R z8kau6nXWhXK7PoO|2Zfgq=>e~(Jt`f9_@Uw8QS1iHXg6kU$E>1V@Z7cpWb1J1MK&eM_NL_Kk{8nb5xeFF@8dwe^A^82yF&S_p&%x0%zYHSSj%3Xe)rCIM=44V-ix z@Y+5FTv!O)eGgk8n;eJ!YemP)xz<0o9Tue%+U34)BVPY#!&}Fzc6HC4v8C7uALLWDt5QR4g#(a_IPZg;_tE zDqMMTWaU+yu{39mH4fAXUxgsgW>D4)Id~`~5_G0C&C3e0M9h0CT_i z;w@#`>p*i`*@agPG_G_sF+57vLy` zqm>riT7rL;jL>tsLB{Xbu1SBpHG*3cAY8v);T#HnLm*f|R`Ayee{An|WDzXtNj51MGfR}BB0b^1ox@v zuXWR%wET6tOX?@_rMia@+?`JYQ5ItvT={m3$ z>gu*PEkIYe5o8n~*Qz_{q}4nqyD7%_!nzvgR$g>Nqa0#>^$jo`M3N7fFc7G-RhW^n z83Evz8-;hw0n}oB1pR=7`s6mE)@bhRhqUZmoukp@WG^a?ex;{?m!6|{VdO3`zBi56 zGwAH7eQG-_&yAlBf`k;(7>#kLpVaQyQO8jLKL@(SBxLWt%t4$!pE+S~AE$>X+<_te zC}vsiu@q;K`J1>jAqBv(*D-yl@XxMFQ8qr)ai_FMLst75Q)v3aA)qTs9t)70 z;DPu~OPA#XIVX;<4G%=^w=4(}S@)(VjiBGvA8{bV0dYi-H|0r_PA{@kxR7%`&|z=- zHo-pJ7lJIwHt*0HqmsZrW21m`yib2ex*Q(KT+{1*lf`R~wVXilCJSaCB$s$}x;(Vp zwG{=zt9F{+gxMPXn_u@nUCm5#f5YrIv~4o?EFuhxxVaMt!t`%I`Cs<)`1yLbljH*L z50sSlQ4F@!H^emunylX`xeiy+XlTPd6fFb8`kL6DH9bvwKv$?)@~DG>Aeg65@UAGw z%=2?n(jC;KoHb zsK0j4_>Dj_=N@5{&}2p=8YqLqOvTmLC6_n9_WKZ7rAxVEAD5bB$uYA7+_Ya<2JWvb znqt5VsXJm(0o_Hg`_WDaGSo8%s*?_H-%amF#^evW9dUMGwa-)|*OZaZ%^CYQ{HJ-Ym0*SD98m_4Py>7Lmm6*h{0XA;r`NkZKI#A1 zoynS+YID_2Bzqt!S$v(FOv*rYiWI2tCnvgtQ@s;sSEx9Bk2H)VCq|JDJ^>dtL8EO* zHiAI$T`twZ{Y0gkq{_Z{_}8b4*00eqpWiia`H?J{_{8UIu;y6Sr?Dyj@+qP9aw+QN z?Rr*`o__OSMx_o`$H~9j8W9gc&gCZ9HGT6s#cQ=6E>VvMT8Uj~IoL>NTenb@U}dj> zA+-`xmSnwsuR!bx3av1&X%Vx1=AHezIAQ2|gttGq7a=@+T6N^fWGQC4c0)3|N1g_v z8MTHZ#fhJ~Xj`tk?>V)bb-kg&XbuuV+&Z`5CXT)J4MVDe5zy_(cI0(kM2>f9am$+7 zs+@p$0C2;?_wF_CrjJaZtuBjwDU|l)19dKg9FsDxJ5+!Rsl1z4AcI&OCc(9#X{2@8 z=;LD=y)-8z=wkh}@6@qPuRlbGKfV+UuV6Jt;M8WE+LPR=Ge7Y1XpAz-!cIyPxGUOc zAHh}(=b_UO&1mkk9(+?|Y+ht!t;Q2pe_C9l>r4$MTB`yI`_Z;Ws>Q05Jjo;EOF}`s z36d`kmHLV3nMiI&>NCp`Dd5**r7!Id^zvAzyoG=U+H&<}AW8ICGVQ##hSk8cjeZ{R zVtNWDbh(X?cnRq5_FFn$)d>UsTI%lJzA8*=K$YUl73BBey3Mh(GVt(QE@*N(EQ93c zk!nv^3B{bn$WPi4-5OnzLt`*al4d^iH*KGnz}Y@a!xWDUN**3enA2$V8U2MWX> zl}5P+v`*OLpry{sY%$d|iwIRtdW?Z>f1HVq$>>&`s+fD+NTUf$gG(T@Am0=|GvJZ(oeh+}x_R#Hi$0V==~^hw0}?%R!img=!ddq{e>!Yn`BfqD(kTk|B@#5;9{KO#1*he) z(_P3(fFMWFy=(fV<7b@E0PaOGbQbthaL+%LN=69rf-6Z!&5%nlv&_O=9u)8`u|?r3 zLvcx_>=X>_e;8A;=*t9FFQ7+IK<1pF{ZQ=+9P-jNiEGZI(t$?B!J~^?LI17FBElU+G$z+lTHkJN{#|vz->s4~@k*W1dkt z7b<2K7hw8R(c(oJXI|Ywyjy)n@>qldwXRO8O}pi-P=?8{wlw%Sw*BSIEN2kS;Fa^Xp|7HMMSsCQW_&+3!Td@)}s^p9}mIx-+wweh>>;yM_UX zQv0?sJ4VO#K{Cs^G<*xv60UEV2+E*zW#UQRH>_W_iFoFiA|0|y4V2r+0{V0|`7-T` z6>3IJ>;$iV;{0^luAVyuLz&lipNZv?k1{@s)Aq7_W8H;a0F#fsI8%PN2iOt@L%N^7 zmA%hjA!*PRL9-f+mh|!W`@*3!D;_Q<=A5*5chr6&Xz4kK4`h?!8DCN_zdkg9?{Kxt zh$Pcg(qZejd~t5K zlgFeN%mWY~n{<+MfDLAmM$K4}8#yg8!)JxUsFz%Oi$`2s9APOzTk-V+?6$NZ<@xIA z+|kShjPdmiyIXp8{0VX7)*buAp!93^jk@75P?u$>NDZA$q+G zYiq^Ke8_7$lO4QeAM+0nQFBjAPNJRy&fqniGg0`)q1t3w2$9VVkdBGL``8zgj&tZd$*%?;jZ>QT^H)+{${5SecFZrS2^dR;Av? za)Oq4EQ3zzxuYu$+HkRtOa@k4OM4`)8Dk@Z={Uljov|FoLos99AR4-@)8v&AB^Q15 zkeE)YZjom=7J1>m0y9-)@4YYYQNBg)A!a+8cfVyyITuO^)96=QyEcR1DxuT{{mF7T zOId`fP#LuNkG93*?JyQn9N$YpeqyUfSqMkAPYH^eJw~*F?So9DLo)pWr0@NDUQKI^ zDkdUx$Lgpb^c@p4NPB@IjOeDNiBGmQu9Fvg$V;!22G9Lm;TWsL6TNZ`@?Ah_V;}rB%a|$40WO2q6ZWrz!!;iNYWK6 z#9b&3cJU~u%yjY}gg8`Kk}sejioyMgba`9=+y$V)&MgA-$lLCGeabvv0@;`TV{3Yo z3+Cm4VG_}6J*AQZ5Zhi`dpbg>@on4XLTGB_N8}t@yHS*v^3`$qag@w>`fZttRXL+xY9` z#HMadXRK_!Q5r1a+xgW~qu6Ea?x4u5I+pbMTR|6y zvfrPKaF?aqn6ge1UPb+lOXJ35rvm$N%K0w(8GAv7M?nP(59EGSiX&Xxkj=%GXEA?x zCi7nna_EbnXBv({Y)G9hzY-+>#MWt!a5})v-7q=+b%d_&hu|1G88aXUfdb^$hISiq zdA(pg0ZJ|`cV4x-WjAsOgFLtAzk}#Fsf_yLaU@9A41s6R!1y*}qf*6UGy;DvM?*47 zIU+`nyePd%q*6;xe;?l;6y^w4h6R>lb!1V}b0%K$3Rkis;mx5Jj2lz9oO(VCV251v za{XJuBQQuAS<~G(Wn?STW48NsU#A>Ope2gqh~`J#^!f#m(pA~JG}MpZ-TL!lQHBg? z*Z8VRqFL5X_ev{t8KH!IdIaCPWEHt2nwAy**WI{s2fH~*;UK9~^{wg087xCk*Uv|A z{9SqL$u+TSGfDJd+rY5SrvHV*V)2%gj@*gV03JUdzu(ylH7TWj5=TQRi)G>Cc}LSA zKI!s8M=vW=voz)E$;Yt7DE6bUMq?_f1-Kr5iu^5#Bk~cYz3$HaS&ED}0iiG2@Unzz z)wlT7j9aVBS^S+DYxytgV*h23cZ&<6pGJf1N0a6Mz#df?F!_f386<&C{Fu}Rj*l~a z+lkbc4Ei0YQqPqCJW1$Bj-i2QFu%={NIa+~9SC9c+b!Xara2aI+$Z)H5YZl;$d>9Q ztZn7{c_q%@AObwdrMhftqs z-j6FYR@>di(~be>axh`6PM3i)pa>K9xWG(Q%H?)<(tNBJ6K%n(Hx$Be^Mu_UcV1g| zM(GGHhX-zj^nw#a8}x%4cFe?7%>LESJz%v$hO&tlEP@9C@wR?nL%3T`({KGKQN3@C zRA1QXvd4m+_!LPYqzoZd`=ASB=U$wZ-F$)GGhmb{_O+e*kuM;6#5{P2zNdjocRV*O zF^K^Ujfnh)rQ%MjmbgsUZZ}8x96tes9u} z`+#lArfVUD%04j$?*Z=Tsa)>E_hbzH#U$YB674)DXeR~~C7E$oYG zakvd${S)gu78xJ}mdD}D2uvTJb;Lb8Q8lw&h>&JEk-#aw&YGckWB|Ke>6>KB6yRo$ zKT#3lHuM||p8B@G_GA*5WZOd13WWX}C~!s&T{>eDT(4VSv2+)46o&K?TRPQ2^Hmeh ztq211f_S@%u{PpMD6BXn5SHq<0;Zn&T4}TTYB;E$CzuJQpkN@OOyP4R(P`e4ygk42 zOr`#x6}TB|@T|BE4wC43s|DYgi#7Hi+8GJVN2LpO6pE*+Z&dL{#p&lcJ&EUZck$<0 z5*S(R=Q$LW3UaJWt%h0z?nUl9`5l|W4gq|YCd^*_uFTAxhUoIC7ydAufozpf=cy#N zAXn9g#vAq?Na0EoF0+NQlP@SI<&p+#@G_?}{IdB0q+vRKkr2)lqy7<0eNlhq5Um-A zgrU*u;jH7q!@iR5rI^0z?2|lQd;(bAG#%=Ty?0rI|yY0Sl?NAsJm3#qh%g^_a=g}@q0v*^GwWA zHM-M)GGu5nw5NGuyu4r1MaE_=$IY0*qn_P@zzA@+qs4Y>urZ+6nwal?lI=|O-}Ii% z&9A!$#(tsEe2Qhp=NGqRfb-@45mud$JYbErAZfYvJ171)m0EO2#kcr|fbW zJEIYDM`C0g+tqdAJh>>6h_A?}8TlI~`?QhmW5aN}MO}Jgi{Z?9{ZAf}Z~Pk+(LhH2 z;(q*hERx^Yd4GZ{?3?Qqd0% zA{|6{9*p!MllLni+L-w2hvj8-Cn(P+x-Xj9j_j);kw^saxx7Oe`FDs1e; zw|nf&nq6idkF0zqzC^se%VUiWUJGn*BmT++;=J6EZAtk%SObKV+!zPP<`hR_4tg`r zN@PY4oYUb)|9gHJy8>hiz5cMhgP$>Xkg}+g@k2!)a^_WMyvZF&H^EDh^5@Ra!p?Ay4#QMw4($8l%dgYlsRZ>Q z9qDSLb%_xwc~U%7G`&acQPfV>GILF~Bx~J7OFVsY-UdMb6 zOFC2*smJ@ArfY#LfAjOZkVBWdul{yD#8a8!w4=5*mjUtmk8$-Y8n3kLZN7fndUbIz zrn*TD6a~k(E?sjAPAIhx_q)cXNQ;na%qyRFUH$o9Ow1C&4KI)T=G0($gV~Oy7ej%W zbGVwOwSFDeqmwz%kWjy_IarJT2cPf$%=>Em;9AFdFn+XI_dh95^Pi8OhZ(JsWKc}= zEid0Sm`+#7yPrEx>%;59Bro*+L=SBo9l~Z`dCpB}& zM(~A%%;#>=_%|TQ-Y`qPuL!1ZC{WOvFlcME{bn!7F=46LRta42@^4UIG(ck@3C6EFE zu*v>Nr$8U+Wt5Tt%#>7L-F?Pi>PS*+E44|^oo3H=FZS^15o%0bQ+fzx&JsUtAfBvi z0R0^{pO^JFkOKu$p*$uvG?QA&9z3HFCpm(KEC_r_tA|XD06NZ|pGz^6XkJ8~!A;*(!Htf5=@dLM(dSpr z&y-^y+TOCh=xezhc$D&R>DHQUO^koJ@Ewhh#jV*f07niW^AU;cf{r8IVq=%ubH%0p z8RLHuw~WwrBshM{G$Z{Az`bKiifnALG~3h@S<#{#U!)#=_`?N^0EiA9XYWdIctCAx zpW^p^{1F(Y`d^Ae_^K_tU?zh(*{^dII~x{(=Oo}$ag;+`m?~%6V^^FLnJOOQvrQ-g zZj5g~_;b@FkQtzWS>A6kIa6F$Ti453+$0?*@3wVPdiahVA%Zrg+ZcsfFFUARie5SI z6MGpIaUz@`wop*kwdWBu8Ol>t<7aYlO7@(b+_ZX35#yCAvzXXIHaFLKM=jqgmckdD zSz|}c1>-r8g?NXzmiv0*TYS#ozIfNbAG=ntAED-XpI?8X|8aNpDx!dVypd8g{4nyk z^km!ac%jr`x}`4F+4W$VeqTeXWvEqOjXq<#>*1CfVh}q{s11>+G6ESc+y;jdP={l2C%w*#Px3Y?C zh5<(ocgyW~L#`3VlWM$c2pmbopjmFh$pYQCw_A2u$j|UdB2#_SG;Ic%m(TGY8zjun z#=*fal3r!gWW8vie?eMdC|YZis8)@pbwjp5eE9I5RG+wYOtauDb8;C2-2Et@kRJl2 z-!chUTRnE9$}j}gcaYV!%_nLMMgX6(tAVm$y>&;IxE@2P#s#&D0Vc1IXk8;I+|9y8 zS;GS0oHa3!gPO|jhMt-m3Jdr#$~H<@=*f}akdx0D(3Gf-4ha793L7Edv_b5xrD9;wSmTOgzbe3GNNfp|;<2|b6v zYl_0aLdOy>&LY7l`Tr}##C3gwQ2h$%d0tU{E*Ep z@!W@sMLR`gd^_DU*<DCbTre# z2X(?jb*ABou5MOIW@$89mJD_?*Pxx~U(P9z1tUUDUC8A_>u)@r^ZYFLm+aSSuNF5<1hPDd|2iZ6$hJcdoWj zc0x66gYg_sl}J=lL8gTfXxJnM(6-lXA=jx@{VNj?8{;RPX>% zJ0=SI6hvQNBXv~UJ??JUrNhdvOw!snwC2IRM?iUaQOB7+}Us62n`BjQ3S<21sY7`RHe2rv_0q z1oLsYI(s1TNaQ2n_Y*(5j_9iJsq~r2JCI*~q{+`$>)rooPhGssC0X6{jvj-Hi6tGWb7pTQ! zKLu0|YdvY5d-_t&OF3bFw+#5PCXH4b;Z%L>wR8wa?uUG)o}MjRj(EFt&`nmvFAj&Y zMPJ)F>kwg|pE%X^O;fh;Cqcl@$63K@LoEbM#6!^(`T--`6b@ZTC_awYP>RvRBWJ>yYHgQNt`7l+1Sp@Mv_r}j-ofpbkA|2Yvbqy(&-H@#n0 zfy(kwsj6Eey+Di71Oe&fu`PB(^7*bu6JJjY25nxSh`Lq1RVfVtbCLu=Jf$H14XnaS z8gVBsn*P=MYqb4K^($}2l9rA00s7K*=NG~reeDJLtOhzS&_}}RN_$w}c$&c|4CT2o z&CypoLfn>QU_`i%ck#pMv4ZpR&tf}5Ylk?ME?C#}elSAx^>n+ZF=vVlfO zli*P>jzwpuO+=)os}oe!EO8HT{&XG9@k03o-vA6A>;C?omBO;T<-83W-yKs&ZH2tE z*`96G>n|YLn6G#sT;@J;(~1><5^o82Z9wDtdFPf%5$o}Ca^CG^i*J%p;YV&R3r;+e zTCKJ@!!AX43b%8Y8tuM@M6e3mrq~585?6|b$8jnx?&WH9EW!=|=)Stox+{~g zjj|s)Nhh+r$`R4x()!`2kQ0)|uLCL{${%4)l@U=$zNp}Ue=zvf#dH2flTLA%{NQ@= z+*PM@+73(qkaW4*F4SC5m2WBN@SnlUzA{H5bGPQ%fu0G5g1oxIjG29BH)s+t&YC(^ zGxlCpBWFv#{RO`Vp^0#>p2QSD9r^)-07{B%mOCQi^DzQg%)!Q-^{ZwWfTU!maZ-^c z3qb%NBxtj*@RBu5=<}t{iRO8cxS@4{%wlZr&wkUAbO~Pd(dD&q=OtpHEjiz9=$U@E zw#C*;8MZ9oPhY8mWHXEm`9#9pgDS9&R)xCcVo0R6(3f zcnCuqqcZu!&jT=XKH8V`#J)Esr+)akIc7*5HZiH0Q0VC2~dQFcX zNnF41ZXkQ;j$LVsYaqNl>G~xV1Vz`mf0H$$k_r)QegSYR_;KXiosu5@1@?!M>>Ijr z-t1PZ4{mtm+EF~+S1eh+{VVXrR&uRcwQz9MU&FIDl-ScbtDMd#n!`86Ms`)(6}1C~ z)~oaQ#C7R)4(EXD^^^IXPk=#KmW*H|m)u0?m;;4=t;{#K_#Ys@#Nemi3*zaDF24#R ze}=Td*Z&y3ebHi;q}4ZF4p`fC)`4(%*zPw0#aHA#Y-#kZk0S~4f464dNyXNHUKOLh z!`j-F6FCcaqdH^407SZO^$&!^ z0GLMh-}YE+{8*YNF76Xe9@B||LrSOuk;IL&g_Qx58YolfA~i)Ew|A3=ZL|$SXCl@w zK+~m!^XOK1edJ9Wb#gA+JGXLF%xK9!Y?c_wi@-KSScVm|2-y1-#mC6bkCZsqRD`XkpZ=ZE2-nYQxDRHriMDv7J#}!YEN~s>4 z;TSsjvYKr#0g_!A#O|wFR*~{MfAf71dhsCS&T?tnC@HgTE7x5mBNsK4lzyt~dHWiP z9^74hn|adrTkDoc?OhqaJGPo3cG+sz8B>3&)O24A^4V_8Y?=dB%>Fd^FP~X9nBX67lx%@3eg`f2!=hj#(|_g3pu*E7iyBTFeBx zj?~Lb#CgW)j*p(tJUa`7gz(IYpwc@9oCzu#DDU2+LX|xp;0GS$U@6NzsTtPF)9Z%7 zG<|Scx~|!kJ)ixs6Q=91jPMe}XJu!5q_C1iY_>3FBf1IZ4r|GDK?Qpi85HM+A39wV zN=e+Te!FZArT3pec^TkXuWj4OZ4ahw&t8fqXro0^Ss#_Xu=o9~BPW+N!eXjjfMGt; z-G||Be8`kl>}{7#Klf$7^2841~dM`&jK${Etoj?^7k>hi4OZGvU=$f{C>muf*$(}a0VjRQ0jC1$MUBlE7BN^KTq%N2L4Sp@XHH97H z;%QQwR}o2qs-knqQ2~VduU29iU@qXIQAy|sEYO-fui0@Uv;gKiGSL2U0H2wb<@O^LRzajQXm<4h(C=A2OQa8s6&J?w);d{{UC`t zGNz*M&gEAMl(TRtea**n z#4^f&s3xwJR8#;WtMEMf>e0?;%H_Z1l@2TZ^3hO`46biILwGSwJMTV&y2aO|v0)B> z4|Xkcvn`W@WK0z^r-@`r;)w37ZeI_(q%{O4iHWU?W$Ji>8uBO8_Q~GeZmp?vHIRV& zUL70NzsYtaab*zDfKdiVm430Wsx&>14c=Qr#kr zxA4gqOO}x$7ptFC37WBGmw4H>PR83<+#VN!s~DfSC~k-{(7J0)g;Ar5yu-bNJK{17 z`d=sAB)11T(AQ-fy)~K(RuoekqDU@zm6S>H?EgWh7jtY=2OCbHl2hM|%Txh5 zuwFi`#)1jAJ|r>Lw$zwtCjYEc#seD&`6X5E3J3bDZEU1@Mkw`*IuuqZP9fIWbgpfo zBR2#9=h>VczJWO+#=z#JS3$86AM8FvawHhnhOb-@M_e$!{$ECUuAU8hDUZDi&YLOV zD8##kV@t=$jApA$jecBc02)a*c4mP<=|=};cl`Y^2xR|@3r6QaWqMv=4r!=^jV8}* zz69{yC=+1Kw2ChM(Mghb*k%GEu^~)fxKZ7N5D+yC5@?fRETAr--osMRO>05^WS#aE z&}7Vy>e)170_U`oIGpYMK;f7K1Y1m9JLS~?9Tu;ulvAT0m~@hCNM%J-h@p*@%Dd;a zW83;1>YlrC09)uD68wgqO30=u0>N~&EmAB*+9DjTBmLoF+A?1#HM~%k_(e`Tve7I+ z&!E5r6(wHM5?SNBVNeyec>=m?lc+k>f7Z1uX}bXr#YVSv_kU>(XzU-TQ$U~+A5PB! zFRiTIjzy?Y0GFun0%A57L4B+Mf$Ye#{Vi(zvi|+h2}7|f3pafY(sw4V2u;Eh{%Ejt zwm~9#i&Xt_jB)g-cf=6qFi`NNm8V%Sn&8IqMzb?bx}Tf;--+KRc!sjx-!ZVi?_^J3Ur&f6m{* z+5-i#5n_vRd^fwaNffuxYII-K%n40GU3;sNAi?MsJr#P_s$2^)qYN2N_iU+{Z+7C* zJfAErU!h{#C)lM)C3X(KZjNTfcgk+fV)}HNH&3>0HCR|g(c8(CDDr#KAM}-@FlAMc z=FRNj!&^{i%m6h8up9Fzir9jFdG`aisIs+)f55$rhvf9im-}xL#dz8n)1;UgYA;2z?M1^n#FZj>2K=7(8g45WB6QF}Ksut-)ixXndn_!lVp-I-;d2XF=^i*hW( z)4ocs^>>8D!2tqahddtuYeESup0ENM8s3yih#w`UPy_j%sd=mh#o9{@da5L=ax=ug z|86H1x(^90Hw5RCDp2Zhk$Ss-;ptLqTnQNo%pTIBYs5vv=qUPjvyd z5k`u!#xNp9gL{{y1LqPJZh}iY`$8 z1jJgv6%}=bzdSgLkEy0TP*y_diku+t@=D6yx45Q{tzYp=q}Tbt(yoZr11#xlUoa13 zS;_R({2AxSo#yO5Z2W)xf)1Iri0d)z#19Y{OIL{EKw;aoR0&zwHhBXC)KdV;IT?6U!oT_eU@o;PiZ_6$W;(w6EPP zJSY5vR}ahQ4ZsKOk{DtO|v9>ZkLXMg$(^FkF^(rhJ-5h z!I}kZMEE)eRk$0i^a|h=E7<-GrwlLrx5JKx5<{{&BYP3+Q{(Kn^5memE?}Z&^2CBd zUF}S;LG!<9^;4=!hO$BRQHgO13Kz$u<72(EvlA}vxGYEMXRo0LJ~DrbOry9z6VhJr zaaNzp9dAf?`mLAi)x;+(3{*t0K6ReX-2SlnN*`%ZT=O%+?igcdhy?^O?`aR6 zGXwxRlx!laczNX(84BOlfDvpv{U`fpy}vmYN@)DgkL@Gij8c2Ug#vB3GPj&C*+KKq9-y;D8DWm!PppV%7sxeDDIr}K zuQX)?el)d;J%xAXW(cFdexeeyd~fryywAwocXvTNR_T7%mb7SEK`avz>DCiDvOmG= z3u>c0l3Qs+_n7v{kE#f@xl>34!ct7Dl536mdyT__qhs$L)|hec2JQh;}x8EO?_u&C#+E?!>hHhco5sj zlyY&|$lshL-BWMvpYULRw{#)T|^Ok3o%d?2j(8VDLI8T}=o-V$77 z0xq)Xx#tc;U+DzjEw-biq45n^1{B5vuf~2g*SU0`v#;6k46bpZbr4gh(kQrMu%t7$w@dPTh zT)X}-TVZU>3Xg+=Ct5_#Y2o+Z6Ws<=x+EmqDDKc0A4nN}!Ms?rv1Dj(B6~%E%=?nAPLzT4f)!V3+}IgALZU@_ z@?>T+i877)qUi}QB%b_ZaH}Bd6A5x0`bgmM)`xB2X$=eeX&;FsBf`LF2CC1@OpgE_ z?-QqvU#1=vm3|-HnCB1i)3^R}4*ZWBdo2Hex?ZkwK>49iGcpJY4+L=?$R47@<6rgQ zBV}Y+dIFXr^xuBkw2A(ZKG2ho`L`+CR_5*Y(JaZXx~|*+5{`FZTTS#JodBAulp7i> zvD09LE!SY|!%Qn%KssBNgY?nRvti6l2sHstAwIR91l+9Oz^Et){Z|4chz;8L{Ns{y zP^Sw9_i4?EuI;XB`H72JfiWkWQ!Xy_**sD-CF4j|3gu-vf1Ea^{BNg3mH1UKdnBh* z)?}Y%*n;96<;59Te#?CNf~jtPUKF!w|J?62Mf6O3uf}u7Qq&mOQ($PC=kp-hHyPT_ zWOyz`brgVa8JQ*#>J8p+1}D0`#T@K^+An5$atPm+wdU)=#qn`4)x7l z$UpWRXd4TFpC~7xM_XX1GoL}g000000ih~)MWeM11%?Z8+e)B-3dUV>8b~6z5|iL- z3Vr>t%pxq3>>2{ldRU=ng-qHmJ?wK@w7L2#5+H~c+bxm51sAvRB-k+Mi-wk_IK^7_ zH<6;zWiMd+V1<)|W#KYVC84$eCNSH7LQ-6VMHX6+{#EjEqkNnZNPh`rrfSac5az-b z%S5|goubiC0oAK=yDQ*9s^AmSP!6rP!!P4`BJ1^kPwbJ|H~b`@Kffe(uM4z*wtJt( z!*N{wt9_u;_AOb(y?J#Pxv-fI??+V_DF&6VpmM=74IEHlY?{R2=CqWuK{M)JX7D7hBD}f+v3!><})5AF(}DIkrsPj&8)6 zyv-8;!7EtV?0$l7@TI>q`E+Q#rpf$QSJll2J%9IKV}1o0CkTqCt>OrQe zJ$+k&AdYCw(f^-ayEhC+p*wjB0ut!jD>{Zgy7<V5jg(&^sN6z15x7mLhXc9%$mKKSH+{L7CaBjHo;{O}ej?_H|3_jk zK~JxruA9%Jm6E^z+c?W+0yZ2U=BvW-lvROJGV6AW6<$}c;5MI%%|G3nAv?6ET7Y8a z{lzlOIjldJXVv9q+C%t2T>9lT1>XudGQCM0YioTm;9U*jXs71!s5!5 zQqkM3TdEonv@kKyB_nA#d$^YE2yA5RV1^Dx7D-LLVnN;zOLsOSpHK_D#Yade829_( zt(AJdL%)xvFUw%^WYRV~i=oWSN~8tw8%Cyd#jjD?c;Po2o)`rh6L$X>E7(!Y)@)zK z768eNUv5TEhsCX@(3~XxsfAM7NIGumT?|JDB$6Bg7B^7tw@ePe)SN@950rMIe!qcL zLW@TLo;JXFy`1l7)$Z{%!0TI4VXKRF2+AzfhvR9)S5}i+*#Swj3rkNs9qYkY8-iX7 zmY;#4NbF^jz8RMt3JJ5VI`1j~5BvuG;<2Fg3sczA8)1B<7pS{ zLYIv)$o$$1O|;4D?x>^5cBa#p*+?_+D-^Dnn2Ty1OO9t zSWua_iM+5Nv17!@vje#*(;kd-%d7XlUL2TyY2D`lyr!EL(mK{6o#v-Y(jW}LiChJ1 zEnh5E?pqt6KQ{sSGTT$$7abD#6M@Bdjuva9M*;rDaHH z8RtdQ_?p^JT@2e^h$lrEW=do}KkkcOzZHwX)~$_{CW5peY@}STWkL3LGD&|oLPEw1 z)yrK%E1I0dSu~@$O)KJa&-hP7eN70vP-(9>0cg41b97~9p`j=9{IgaOEUNYdJo>!Q z+Eazk=)fG82ZUSd1&XZwR43?n{LN}t#KkO&14kqP^XL{%C^xmCEYk|Lz0iqY5YShK zq4qogH=|#69ZoK!nCUFX=)ZGI134iMjHVj{p@3ao#QgFSz6%ylI9T=cus(9QMjzV| zND~aR;$14WF)ZPypZ>Zhyn$O1Ai$PN2pMF`-srbJODN~$^X=SSeO^Gxm6dmp9!@w- zDetd7c!lKd25pC|_?5RQdiGP@&Gew2UbNB#KN316vW-b%Vo<;@%WPD*J?1}{ExEHO z8=C}{9ia9{GG1Q0ar9s}F)?KoX48j!eRB=ysHGHoWU)%=#) z!H{^b%3LNHr;3Axp>(qR$TiZFRZlDks(042JQ(0QdBBb<5hAuCNeD_~SMzHsK-Ixj zg=U0TVjDh z-NT%@V2Exy=`6RQfBB1BzQtT-Ae>r2$%bndPw1E~ zT-nY>!5-$`+zX)1)B?J^{(;uIX+~Z4Cf}G2oVuB!m4DNfv4E2E?6-ayacpVi-gSn4 z=t%S$FmH=M2aRpgd(Y`muF|>ZIbel2!o6nJZ|+MKh^(*6F>tqmaLIEQVpLHVIy7p-C~P4H3lW zQu)A@~|)bW#uxCWqL22!imK+oXzP;ClyQ3*4;RX!rVD1 zTgKYlV;40hIc&tM8y}z4T!6ZOxTXH{srpo2BT>J;2*i-!hH%|S>G*mLnq(#H}K_y`yLpuA`LTSh`WYj zDG@*Ryp(5=zdrPb4^>^c53aI+q*#^kK(E$|2qQ1xpo2b<#!beYcZK5`yRiQ86z2`j z9sRihmZx#bcz)=`Pl#L3Z79ajx9Epo7}Z+bv|q} zO-?X5XO+LRQ11b&x6Q1NnZ1$HWP!;hCpLW{IM-t2F4$3rKn!>N7=3TG!1FuworM?% zQP6LU=1iLnsj)`N3;@NyXLZ>*p=ep9)GXjWHkzgrL_kV?6m#g&?LH|{$k@Omr`~c} zD|*eTL#}ttsawanQb8GT&?(NAUI^|M5>I_6B`kkp_HbBLj7B_`mm#e&IiSahci)Vz z+cgqfh&P=8X;K>hHMySsDDqt5^{j1Xz}`e4Jur3PM@jR=%$gFEFAx1-xz}K?38AHs z6$X?l>D^0`CDG0L z?+Ku)G{vFy?|5!U*?QLQIL6O{`1_FZVG*5d7-oYKZ&h9It%de&$@2xPLBswwNYP{52*c-D}%X@saICQz*mJOZ8-zwW#q3Q9~ zai)b^r0wt<;YZ~b<9nR?Qe~qD%PP3hEU>+rB7Klb0pp$a$>G@GnNc95zcNqzXD*f4 z9<_xjP)<@mo8#(C3a1~kxDQM!o>QWz*~3bYN0YT1oaBkM^*E`DUf)ivwUW2u-P?qdiwrEW8{nfBdgO>Rf2=S< z&io12QUi#sKQYX`PMrtzl(3@76cQN;_0Z+vJ?OEjY7EUMrden)utUN_^ZL)Yb1nsm z;~9OWNO+kT4QJ1SUlP*>uTsJu!(KdyVchXZq&YM$#5kUb8?ghQf8^&kdJ>_DO`7Z8 zz1EwA9y?eX2#1KDCD_=VC@}8))QbHue}Rpj%jEqVffRNq~B8IXCCYdz~zY z)oJB~0YD50GvrSXab*KyCkGj?0=;Xl&Gd2+Er# zUfs$%b0$UcN)!0Vs5a9eyq3;<6?goQE~SJ#$j7Iq;wH z$w%@MXiOca@1zCYI?oD9cZz@yR1%s`*GHO7!D(E#ba$RXS<&@#FjHtXCn8Y%#xd?L zPw#~lS7Qw-^6OS6`a}3V2a``#UzPs6wW2KeP2^&N*(AZ36BV8Z-CD0xcRW-T(OL<$ zM{%xzq#OnH4KRl3beqHn zU4CW4UyL-cwez zyV(wIx0quogz786cTUK}Q%_t#i!POeEBQ)mYRp7of)5d={7kwknB$r26q-GHRb5A^ zT!qO+BBA*)Ezx@3Wk2Smdc~_g_7OGN!PKkqAc$+-OJstu0PBu9u{wrSWRrLHcjrre z{Sp(4-eg**U6`kBGT0^nTye-nXbklmsQuujh zQA7VFo0i+Pq}GlSKbacs$U3zWzwC$_3G+T7EUG|>&5*uy5a-|3Osr*ibbN)8r$G2W z54AmSM3o`+?S&)i9c&oNsX%$P3AGlY_^SgEvsIVde`R&A`^8EO^MX(QMHNlhWSObT zWz3!c?jN}1+$~^jph=dO-lFFKj6F$thLpHhZ1!`Mi&pmv=GMrS?JuZ7r`$a{&2)#p z_9yc1Z8;83KXgMXK<)qGk`^_EgDx|0A^W-A&e=~y-53VIL zoGBl}?}5)E3)(8seyzp=X5nDGpr&H1q}eIjW}LZKVAL2msGXPkjm!p|M57h+uIJPO zdg>LlSXaUEdQY;Gm&g$nDEr#Fl-cG2K_GB%3W218LAQuZ21&?tJ#GuKFCZg9lk3UjZ4^|bQ79?O4c=6R zFs=N541oK842gNo%BJsRm(Du61ogu~DF7|EYP~B7L1O9VD@i5y?6jky&!xb__qZ;} z{cBPrhp^QMqv~9*x*R!gG>W*8uQXla6?#j~M;n2aFf#o*fz>gmE%*QJL*3w{2r}m7 zKL_p4DjT8~$_-`l!D0YOBPh)k97AwH>2QchR&|)lvU;b>Ah$7d9kkY+VA@T5-IvRS zHykV7Q^eG5ogOo?#@o`R25Rh5x+8B+s{Q9m`Gq8@G(h^SV>|F!BIc}jC*pyh)2$HA zk8rR>T}kx##T@P&`IW*+$~AaGRzP38ykf}lhYdZU68sZ4fb%sPva3Epd6>(m$qvdB zV7>XGUg?aCu^xd7%>B=3ec!FzIpYsrFW&hiwh9nU&A>?zvyzfs1xx=9U|3Q-98X>Y z%Y1EY|4Yn%(p=lW7}##X-bc)aej@m5M5xdy;#W;AfymkugavN(0HzqS+0qYvvGMwG z@YnKGGC8PY1UI8)qp$+wqrYZv>-c6fa999zXcu&7l>_#`Ql)aYQ|K5bT!65V3()AG znEs9s6k2y9KaXoV;tBf{Vb=6^3N&iuen;IV9bkpPrrPQIEnfuy2~OO9v&gytPcd## z*k9x#adTjlc->!7k)3}~iK4^pm1TMugHm*`@03$V zwHu#VvrUp&e2u>JWju$Ny9>+m0d#QO5DPhgZ8Ku@pUCvu8Wzxd?s3S$j1~<3OF2xIJfkbEW{R)zHhu+Kc!%((Rq)AHrPh4t1p9yLln? zE!s=Qt0e=xAi~M8k37>Yhkj9MHdHf4ScsvF(jal6w=%ELLHCbbbiP4z`cK3^V>~!z zf9BpDB&XI|R3tQP?g#a{Z1G@hkF^4HQUqbHPGO0m_cLVXt4AGpz?=auZDhpDwJc`4 zk`lD>u(GBY+b!=kr<@zKZy8P8nFFz|hLhNSex0NLHZR@+2vaC0y}|o!{qk%zoXNH#B|{HG52?1+f;f(v`p8i~}nnpBB0}AJZJ2+fDm} zv|k};4_82Xn$zq6l$bOCd_Ha`yefr=4iA9Q=Ge=(ryPs1VOvJeu-#&fZ5-nw6y=)5 z>FNFmxnxEov$_{b;&2Y#?$6$b_j3%qOtxbcfB_^5V#Ymh7i#?&&fasWrQG;x13^8&5>!FRDV!=pxf@uzxBrx(i4GgSG{~ ztYq%%iR{F)+}F`F^k=N(=|6A>q1D#|0RrkS%zCYTg+@?q>y6y{4`{gKdHap|^gx_@ zD1Pkv>}yVIi-tv~I=$HznN45jC_MgF>H-75(a{L9Q%UPNltg!_;&52Z9%l{#yYsaNVo*{xk^F$s za1Ucr?&(Gm}Wxk{KeWBJ8h(6}^?zkva}a=<)#ASVqg;>lDi zr|9tb5?R3T*^b7nfp9Ax`BeGAJILi%ervbP*YvWKQfEt(>`XSqjR8y+7(Jx=^(T*`u>}~L^uLP#pjk)-WffXR z0uQg(sCvXAnUR)ehQQQ~UjGM}ZQ=2MKXdF1c!34~Ng7AY9p7;s<%{y)2ke{$9ZBku z5e4Qq8$sWxe{y9eHMbpZzo1}Vd++)^02$E>STuB+U9=Lr+pR*`Hk{Y{u&g}o>xw9y z#JFGz%Ck%gN?qDEgmTooTF=GYxW${U;AVD4N;QV=$=S}!*#q|UUI{9Sw z`0(bqSS6w(ZG3Qacl!47+=;jJ;Mpjjqov8Yw#_$+mbRh3IE%8zQTI<}*}fl1bzgdc zBN51LI%k-3H9E{0;N$8v!H?bW)IuBVOIIqDhj>;7l9f^gY%#du|LC=yb);kVBsKUr zwo=&czyLlASbZs>T1~6-E9r|&K9%n!J)i)Y=8>xHQRj85paB3-da z2|du%JGrhb;q?SBdXC*o?+xdM3xydg?n9Lv1Pq&CbqSXjlXl_btjmx8x>^s*${K(j zAyG#ylT$3c-0mf?-;Wy{mYW)420(}y+Czo9!ZqEsTm82|9JIreDrL0nD9_NfF#r#d zuRZl&6x>fYF6YBXTbV?+wZLq6t)*oA-rs44-gY0jJ5UNbh^K+JOo*R{fT;5?7i9o# zQ!wS0S;M}3ITZ&|Og9H$G3o%!9>ZhWRk$xo7mSx0W84Kk5fJO{o6G*?($;ob^)SnVS-x7UY5BcHG+lYvBR0&mnw-}<6Yc81B_ zfc}?kUK_+5po3DcKtyez!rqX*e%19x@C2#R83+8B=k0Mkv7DUw(0Tuj`;858eGaxL zH#5co`B_x*6h6y+hBP1N&IopQ?yU1DiU~pM|KRs(0<`fFL&+e$4rz^Ybsx?_*8X(2FrXWQc!ON8hwiO{n3b~=GRxOv? zqC$Gm4FvW_3Sb;@-*xmX@pY{mZY}QFw{^wG(Ds*G>Avj76%QnMe^RWK+2HO`7dyG5 z+|wwFbBQCCCHcRd9YWzG6~jo8lK2s5&(zzfsNd8Q6Pv|kf8!( z9xhtBTeNU`CD(*MHD@yOBoMx-r z@$`(;DY%$&9z9S|53TD{aRpF&YWqS9^HHZ*d}R1Mlc3&)^qqcloFOBzqZ=VS`wYKr zNo`$@{AeQLNwQ)eK(H=szy@a!;-Gj!2s$376N(gJ!y*AC1zyXmHjrTZobvgnp@dU`DY?T)cl27Ie zv(|hS_l0j?8U>rm>p^e8HG-gL$=aZ+%GWTSwi5b6u|26ILkgy-kvfsWT~F9?a5uZ{u<)45Rd)N@(E-miuOD^V)T1{ z!b+^lYV&0R1Kb5*TkjYyY=<_u_t^z>e*X7ZeeCHjK79boGaL5&1Pkvtr%~TaNLpgk z)Z3}?93BQ*UT^y*9DNO>Gnx%oTHaK-f~G2sfsPkFu?A_!@|ehSVx~&AUm~ zJCj|f&(xngcb`xlRSZA3Y{XlQcLC3c957hXooZWibxPB}r*a=$-REpQvj$O@X|&^$ zF}hX`QZ9Pj>}3%bkJ^Zw@N_Rq+2x4HgPX!$l>>Y}#sS@}XP42S8U%SnFEaU6f~38& zZgxgTOB?O0cRdbcrssmINiwwVhPy{jm;j2Mx4T+i%&z%t>Y^Vb=2li~;|%I1o88#e zz)E34Ro;hmdH4eD`Go`dIb0v#0x6sMhOK&4>zAuAJa1C9%{{ufDTJ3lrP^N=p38e! zLvw9B*{4N^wUxXmdXa{#8Iu!PoWYpwr{i}MPS@mXi>bmQ6s3QmpNWzxozKe=oOg~q z$z8tSoT8AXfyies92Q;U&yzcy(E2&pXOJ{*mi`B_e=nXq;%DVZR)1wai#vkvxKmPKvSF-2P|48ah?=fI|UsnnH=~xN3m1@Ss5HpH(1aZ zJO3mZ36NUnP~`?Nn_JRPJj|-NTQ2iM!5UJoLi@EuOOwJEh$bBgqGq_Eg+8SEN?S3iUyOOFrS!=*SU5U_*=yoE z!6B>swGqT=Z>;)(``u?b*YL*}4anCL??L%exM>0ij$o@%-u_(B1hLp;iEf=uh9}`I zBDD>a&fbF{V#}(rTzgxswQfMV?_w{yU|3I?*dxcsKlMhfhChnmW|xS zI5%-c8|pcYFX3LTA#hIPU`!xZ)S%E-P>Kj+z86iO3~v3@FXK0#?Wmu(uj8Yjzm0vF z+eSCX)BxV}%E<6q39nZvA1!%&Kd;pT{Xt^fyQ1{amszQGea9phG0f!o;=slEur&a$ z;QncHj{sSuFHOlVMgM3+0~t6G-W*~z5vV=28jje{%gestPNbd&zEDaA4-wU>%Gb;Q zkro{lmIB-%SnDfjOTKxcjOTODljBD+404hK_-qSNJ5nApGaw45`kYsKmm1Q1|HAp4bb>_! zVb`kz@}8KuUb!WqCHQmEppbFOkmr8_Qg_395P-H^AH3RhjlLOWq3Cz`o73~7BV*C} zJPVIfh3|*Xx8z|JU{0Zqy$DFIJ}dv9AZ&w&h_I5ly+hP9F78Nr%q&{C(sb!uL*r4q z{sYjBY}6OB0cYI6{ZyhQ2)n8a7ji7H;%>p*0?f@N-XEu8>1n{Wy9LHT)Lu`Tnm!26 z1J`IMf^w+n!e;F~b{Gl|gDJ94%H$!w9cl@%^evF(6p1{9ARW?jQp3Y@ZP-c@&(aS6 zQ|WiJYTFP|0gsjd$}-$_*lrzk2KHjt!@87ivTwMiOzLb}M3@MT!Pa5W$1O(d|ke}JZ zyYhhk#`-4(z;=Ohe1CimRX}pb!7c@lx1)q{l>^oL1rI|y)f(;pbnq_R3-Oe-pu&93 zk|`B&2-;@oN(I{v!e>@uF02g4cAT-1fg%JmS3P$_m68aO&ImD01MY&6^pUbx6Hgdor{lObz2`aGk_?uqK{s` z=rZXr3e(w}8W6MMU+TRL)?4ZA3JCN@ zrxPHoPXv1hzLr=QGak;}>ON%cRK|Bryk^)yT$mOFXE^i+q1C!Q; zr-dWk7S04!Ykkn{FYK3%OLP$5+HX;dFLLdUWD!YJ9(#6=2qaISDeSinwb4eg7gbT?P!6eX$qqR>#abxOlItXQ_io(U#!RK4?ool(kn_y$ zB#XS?`FC`4;Dq+bG(tydL4wFEp|ett)*_kI7(J`cyvr*86#YpRc+&}y1F0fjqG5V! z$&?$HRrZ6S&0B>S156o6OiFA}P9+8KE^R9O1+WVITrn52Lxzx`O&(}(K6NO{h(_NL zE-Ub04XZ^HAQIXEsE&vt+m|;kqlKqdnHY80Mj<^}S#a^(IpOX6bWqD+lj9WdCl}{W z00SawoM&cPW?3M2@p|B=pl=6HAW6?#ovA&}h3-0qz8~Hu9)ujmW#~F8wQeapKulSD zon9=6ZZG>p8pCDYs)VP29l`?8%@>jg!3+%+jICMhYM|9$5m%sSQHl;mf0SuH*3vOL z-!Ldmglt@$+NkEXRU%g)frYUCC};igMr5mVG@x>(ep8#L2@zKs{+6Eh#E0K>^Yd4p zgoH}<_$tebD!L{SlU4bYNw@HTZLV~o(2vly=CpuM7+B3scK2i!I1 z)QqxjRu*u#qLf`~gKz`&`0){EqSlibG5k$P9~f1nPC=W(0W-DqOraf4uItQa>l1NS z=}kbQxUv2g#i+HJl{5ZnxJJs2_M$2Y&+#^87ur!m%0ywdg(T;YY6pLyJX||5S1q#( zrf2pAdy&9$N7r95DOVy(7ph|~UGb2HKa|{vmRWOwNJeokAfgVQS4QXRvU@DpJ?aee zVNrLMzqD$fai1Zt8GlK7fL zr8MEqf2R3kGzKtkIL`s1s9pE7JM6t^D)`HYURfY?!(`A_{*IW6OqkE&`H{7^j`0)a zs>%1iug_Er#*$)&+qPuVL4IJb z>N~5nB(Wc7qXQ=^DimZ6c{V$|kE(3Pxm*zblDL?m8?~2qf`u0+3OUXSq&B~U|DX1p z+waq@-Y)#$O;qLZXpa0bZ|m;tyE2IfWXFo7R(A{A&s5XRFr;_nj>E?vX#e!D}1nN2fl*cC=3C~Yvj;Uf~x*O?M0R3&vl5p5~6hbDY_Nd^zFhV^kmGl$Pq+U6%; z!nH=3caLk72d_3DbyH4-?k~;BtIsq1z-hmS4VT&^e6l&!S@s2#m}2_cY62V&r_k;Q z@Ikhi>`8>XEEg_ZHGzgtM<0GGpk6=3&C9}l3x7#tCjB>58%~8S8`sCr)Hy@|;gOnx zE5H7yCNgtq31K$3*M+g$&I5LHmb=X@6zV}lA<{SKs_6V7m)c<}&kQI_<<&^q?TjBO z+-QXoZt|11zmd*xfhcaa*vN%9eSvXOY;GH2g4PlAYey-s6DAk|9~CvDw>PsxCNqYV zU-(Fo9LM?Elo*+Tup%vbr;`iAT2n=Bojzhik}lQ;#bu^DC$+2DgJnBCUp>i`Izb^h8DLALPDsDA&zHP-yqV#t28xSw3EmwotVvImkKcvPb*{tco!q1O){67QeM@Wjsm1T0f@br(EF}<9LA#DO* zXJpB7TlZIy_-`F736j_#6Vwfk!5NRSHQtOcRZu0)D{PPVXlI5f_lKrnauJjbJQbFe zymOxpc9QbX^n9kh69)3Vxogn8uK{p+Z>U%?AOFuLA#JQ5J28 zVGh7273)0>Bg#*d!y<>67>im^Wi1Ktx}8VO#$D{px{lZQ)-18%-%OPvb&rLI^SV-exkc1EzhBxR?WteLmBvG zNqE^}I4kumdOxLRU9Bxqvuo5p!wl9OC5-@dBk5-ps}w~S3XU{6&cK>uQlrzaeTr&! zh!vuR*ZU=G>lh{q3GD(9EbLhg!+*DgqipHD^s1?MR8eR%wLMC#Z;2SEPDl>|yWEHi z##7<}5)Whvv&rz!ndcSd)RpU11N#J{l8j6-R-@>rVD9R(99N!bY?7w!PyxLK`2gqu zOE#TXLcMsW!@=lFUfOABq%Wp%;YXzg&}eskW@m@E(EK$ZfNq}vX$aNIor41uP>cn3T_YFK2YZ*No%*Z`>dJ&r`h z%4fCTWysHhO{*17`$TVaS*hxI;9bHDp5qQRgA){O`e8l96%E{^^5d-o`;=u7w$C9i z77^;yw}|t%3;FEl{*Wp-iS~XrcA|z8OZVl`yQy}c>hRwo--MYhf8fwdo_XvNEN6-c zCN-!tH4(U`y&a5aKF>Edwrd0;%lyN<2Twis)l7pj6gOl~+1?2m|IcV_7>JZpx1P}s zxrDT7ssZ)TRD9>WhUFUCdAjg|}3~S;IQ@sRkT@s?4BF~};uKvVB0;LoY;}Uds z=agaf%W{n*hxO3O=Dvfa8yCTRsnzPRL`A9%hgsNY zw#@i|PYnnNS}jX=|L`+6-9EVqV6TLCxF?Aa>-F%2(ToKIYs|p|Z;>#Mm#_8Ilfy!+ zw9Z4d8zEox20QQFs|MIf$Sq145*1~YT&r#@0$dw#n*62fJZV1K&4snAb(q)+_h*rh zK;%4{3fEO-7ktd{r{4`!Oi2k1s#RWrM;1Y=BGTJs`_Mni>|{AqoCgS7aQk+mo)KnO zLKOLHqrq7g==5*)h%n`ITLca|HEWR01P`k-qN_BDJ1mL)j(xG;an-~*D%3K>sjE;4 zW#(=VHtrEGly}eNy*24^GI8nO7pC0$6-GbBSDp`eig(!0hAT0k0nx?E3Vzhb7#m32 z`V||lJPl4zvtEqHSe>A(v4pB!uR*34|Io<$u&9r7&J|?W4=l(QU-kORO_8DLZLgyY z4nK_+H{~@S$n^JiH&1Q`3t~VxUsvhB(#ITNMUb9c=ghye@`SX;S5rVf1>vp`NNIhT zTs-mptMvFjAH;4$=klvu`-J$hjfrdPs$dwh-VQDh>|QJx1fVOejQZykn=43~1* zbY5*0br*9mA(v;u#}RLjXW7tpc`t2|Z9H?QFs>$96bXZIQ;rkbUj+WoxFlXm-#8cuv+@#Ij#Etg@6ofE?a0Jwl8HRP-eek}dJ{3e& zzJgw#y|x#G;icJe3Q9xy;}38%18EpbT_z>T0!vYKn)-!DfeVj=L~_Oo0z_xxE=Onu zvC_UTel;xQ9iJ-*DS7SY8TD+zkVuf$x#As1Iad0lZ#}u+y
s(LdHMaDMtj&hja zJh968&cM$*eLV}w9F#4m=Xd&EVP&CV#49*KtF!lur{A@TJZSV+i*ryUz&JU*P7BWa zy?$$Az>*pYZw`yzn1glbb?QQRE&#}i5(ri8IBhKw^J;^|H}7iV75r?FNtiO%HHAr> zk}7|nbtPdb?z+1+XoEcFu+UT9a|OT=Vq%@!#(r>pO5h`P#j*J+#12n{O8SsAi_X#(-Ukp_^jRp3M=o(#(eo$EAVtX4q>87 zDNn8S4HI@)f5UZJZ`*&-RZvCp!=>ccLL*$SW={b_2)y@~HxJcawqV4-R`(0=X>vt7{2Q!%s;ykixR5}=dX9ft^IJH@g1L@)2?BDxo8 zwtJZmRrWYRWlLi{-kfD$4a`&MRb~NC1B5E`8(ob!AGje0S-!{lhiX{9!r|}bTX`iP z07ovcVBdAZo-Ey7!MV%_eQ}q;L_bGhyz?mezt>9I+U$lV~;z$k>e+*Z9`K# z+ZDV)bwAH@S#SGmL}GG@V~{Qbww=62{S0b0iGp)B<JtNU3IO;xf^Y%o78J8S2)jDZ`l6Xc~-?_)N+op;)$XP&#jK^Mm9x(+W zn-tn9#ey|{m)tC?Kj52+@2I*Tv5Z5w8m3w-Sjnk?5?^6FR%7%Mw2G_arf^vCJfC2sr&`bFKRO}EO6KgkEt-3eIF8|K#VVxc2J z^*+(5gsHG(GUNfMzbqMA!~3#&*Ck?wjHCYzm^9Jyi?cH&lFdu@TDPzWV;%pn zh4I34iYIdwa77zIKX(ov*^{xSKd|3KrP+*e&%R@f{sgSTFRb}dJekwpr0Bq%O=@oB zg>Yd<$OU^o1C{QXA}iD3@aG4{vM)iz!xoa(phf z*6QiUmk_TL@_c;{Sn_oEI~}RYn`KrF37|Tg!VCHzkgxUPCwnly1>LtN=qaMQZv(j+ zpMu>o_ZngO#wx?531VCn@wuN_XM;F3@tz)zZ=_KT`_TP0O-9ZXClL^DUNmbyk1Xzm z{Own=UuTecgS@Q$9VRfS1bD!EAM`hG3=#w6n@vGy!Ga4)2GAQZFO#U`CVlS5uj0p% z(S|R0Na*ceGwUsA;8ceL3xIW>5P1b%-LEv6#~z4m7PSt3vNXMV51EX?JRb zkNHMxW~@LFma^^(O*n$J_&CeVc#}#h8H5q`|AMX%mc*NV&hw>SB4&&u!e|4tv&LGL zzJ-C#Iy5|c!$rT8r6mJ)Y9#_djxUsxZFwcte|$_%T@i<@u-XcHN76TE&YrPQXXLEp$i{tE$8#uM4P|cO#+|}c zE%(@9F7vkmw2{0<9V)Z~yAUO(1Q}UG6F~y!Bwpp7gF4zY0ivX;Rqrze=Y6wfZT7wP zt>(bW!|r=6a%m|BX^3u7ac7X6WeYdeD0RJk-G3TZvrxYwX=c!}5d-w?yis$La%9HV+hH_x?YOKB)x+Ns>hqLTKD^4fPxhcsY!OothmQ9cf$h1H>h{pKXfkQ`@rl4x)>(mZQNi-3D ziE?cxa=m*zaZ>yDInM?am+q7^L7Zd1qG}If#Zvc4)Fc;*mGuIr{RRB2qwv^p$CeIs z`pt|DOgVyvBhcIobWPW^7$OM_Aku#R$)3(cFDNnM0z*5qLP1f&u~paCtH+$= zg)0^%@iI-5wCBL^!Nsl+yK`{CZ40DV`71cGRp>@1gLB(Xbos)Ol8i(!ic%be0i}l5 zkeD>U{RW{+?ji(udL*SnVvp~pVxP@wchD!ebn5RT*z$5PISpeXf$cX6td*<9=y5+&4o z!Vr-3Otz3}zDD__9J;JuqWmj))J!Gv1?Q+Tu3#GIP}*g9ey3|gjN@hKssRoDH=A4a z@&uwY)HX;;J)>l$ijJ#t{3Mex_pZs=KH(Y##6%Z^6wr|qicLqdduzZM%19bZ2L(Jx zzo3EQQm>G)yu`x?B+Bq`%(mfy%F8r4+Xd|(KN}cM(sxCre@wH z-olrCN6V@I{Ojx8gKBZ37#|uE9po!2ovRI>We#)%A3F-dea9^a|J{6Tr8ta};QHJ= VI-!93ColX4qtJmyKP+Rm006yw^O67n literal 0 HcmV?d00001 diff --git a/opencodex-bar/translations/en.json b/opencodex-bar/translations/en.json new file mode 100644 index 00000000..def5af32 --- /dev/null +++ b/opencodex-bar/translations/en.json @@ -0,0 +1,143 @@ +{ + "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" + }, + "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": "ACTIVE", + "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")) From 7e8fcf1becd7ec9775891fc6595f20555326c779 Mon Sep 17 00:00:00 2001 From: wy3z Date: Sun, 23 Aug 2026 09:04:31 +0100 Subject: [PATCH 2/3] feat(opencodex-bar): add account controls --- opencodex-bar/README.md | 10 +- opencodex-bar/panel.luau | 209 ++++++++++++++++++++++++++++- opencodex-bar/plugin.toml | 2 +- opencodex-bar/service.luau | 125 +++++++++++++++-- opencodex-bar/translations/en.json | 24 +++- 5 files changed, 348 insertions(+), 22 deletions(-) diff --git a/opencodex-bar/README.md b/opencodex-bar/README.md index e22ec331..b858ba1f 100644 --- a/opencodex-bar/README.md +++ b/opencodex-bar/README.md @@ -1,6 +1,6 @@ # OpenCodexBar -Read-only [OpenCodex](https://github.com/lidge-jun/opencodex) account, quota, and usage monitor for Noctalia. +[OpenCodex](https://github.com/lidge-jun/opencodex) account, quota, usage, and account-routing control for Noctalia. ## Plugin @@ -9,7 +9,7 @@ Read-only [OpenCodex](https://github.com/lidge-jun/opencodex) account, quota, an | ID | `wy3z/opencodex-bar` | | Entries | Bar widget: `usage`; panel: `panel`; service: `service` | -Only `service` contacts OpenCodex. Widget and panel read a shared snapshot and never see the credential. +Only `service` contacts OpenCodex. Widget and panel use shared state and never see the credential. ## Requirements @@ -26,7 +26,7 @@ Install from the Noctalia plugin store and add the `usage` widget to a bar. Clic noctalia msg panel-toggle wy3z/opencodex-bar:panel ``` -- Accounts: health, reauth, quota windows, resets, Codex reset credits +- Accounts: 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`. @@ -49,9 +49,9 @@ Disabled OpenCodex providers are hidden the same way. Hidden providers are strip ## Notes -- Network: authenticated `GET` to `base_url` only (`X-OpenCodex-API-Key`). No mutating calls. Non-loopback HTTP is refused. +- 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. +- 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. diff --git a/opencodex-bar/panel.luau b/opencodex-bar/panel.luau index 65e38488..6ebf9dbb 100644 --- a/opencodex-bar/panel.luau +++ b/opencodex-bar/panel.luau @@ -50,11 +50,22 @@ 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() }) @@ -173,6 +184,161 @@ local function accountStatus(account) 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. @@ -238,16 +404,32 @@ local function accountCard(rows, provider, account, providerHasAccountQuota, nam -- 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 - table.insert(card, ui.row({ key = key .. ":credits", gap = 5, align = "center" }, { + 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)) }), + 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", @@ -261,6 +443,18 @@ local function accountCard(rows, provider, account, providerHasAccountQuota, nam 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. @@ -297,7 +491,8 @@ local function accountsTab(body) local named = #accounts > 1 if not named and accounts[1] ~= nil then - local status, statusColor = accountStatus(accounts[1]) + local account = accounts[1] + local status, statusColor = accountStatus(account) if status ~= nil then table.insert(header, ui.label({ text = status, color = statusColor, fontSize = 11, fontWeight = "bold" })) end @@ -697,6 +892,14 @@ noctalia.state.watch("snapshot", function(value) 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 diff --git a/opencodex-bar/plugin.toml b/opencodex-bar/plugin.toml index 57b9bf9b..db3dbfa2 100644 --- a/opencodex-bar/plugin.toml +++ b/opencodex-bar/plugin.toml @@ -5,7 +5,7 @@ plugin_api = 24 author = "wy3z" license = "MIT" icon = "brand-openai" -description = "Read-only OpenCodex account, quota and usage monitor." +description = "OpenCodex account, quota, usage, and routing control." tags = ["bar", "panel", "service", "ai", "indicator", "utility"] dependencies = ["xdg-open"] diff --git a/opencodex-bar/service.luau b/opencodex-bar/service.luau index d408b6d8..a8091e2a 100644 --- a/opencodex-bar/service.luau +++ b/opencodex-bar/service.luau @@ -1,6 +1,6 @@ --!nonstrict -- OpenCodexBar service. This is the only entry that talks to OpenCodex. --- The other entries receive only the normalized `snapshot` state. +-- The other entries exchange normalized snapshot, command, and action state. local common = require("./common.luau") @@ -32,6 +32,9 @@ 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() @@ -159,11 +162,12 @@ local function adminToken() return tokenCache.value end -local function errorFor(kind) - return { kind = kind } +local function errorFor(kind, status, code) + return { kind = kind, status = status, code = code } end -local function startRequest(path, callback) +local function startRequest(path, callback, options) + options = options or {} local root = requestBaseUrl() if root == nil then callback(nil, errorFor("api_error")) @@ -171,6 +175,9 @@ local function startRequest(path, callback) 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) @@ -178,8 +185,9 @@ local function startRequest(path, callback) local accepted = noctalia.http({ url = root .. path, - method = "GET", + method = options.method or "GET", headers = headers, + body = options.body, }, function(response) if type(response) ~= "table" then callback(nil, errorFor("offline")) @@ -193,18 +201,23 @@ local function startRequest(path, callback) return end if status == 401 or status == 403 then - callback(nil, errorFor("auth_error")) + 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")) + callback(nil, errorFor("not_found", status)) return end if status < 200 or status >= 300 then - callback(nil, errorFor("api_error")) + 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 @@ -236,13 +249,13 @@ local function pump() pumping = false end -local function request(path, callback) +local function request(path, callback, options) table.insert(queue, function() startRequest(path, function(data, err) activeRequests = activeRequests - 1 callback(data, err) pump() - end) + end, options) end) pump() end @@ -380,6 +393,7 @@ local function normalizeAccount(raw, activeId, isMain) return { id = id, label = accountLabel(raw, isMain), + isMain = isMain == true, active = raw.active == true or (activeId ~= nil and activeId == id), paused = tribool(raw.paused), needsReauth = tribool(raw.needsReauth), @@ -983,9 +997,89 @@ function refresh(force) 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" and command.type == "refresh" then + 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) @@ -997,6 +1091,14 @@ 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. @@ -1019,6 +1121,7 @@ function update() end publish(initialSnapshot()) +publishAction({ status = "idle" }) lastForcedRefreshMs = nowMs() noctalia.setUpdateInterval(1000) refresh(false) diff --git a/opencodex-bar/translations/en.json b/opencodex-bar/translations/en.json index def5af32..dd0df33e 100644 --- a/opencodex-bar/translations/en.json +++ b/opencodex-bar/translations/en.json @@ -45,7 +45,27 @@ }, "action": { "refresh": "Refresh quotas", - "dashboard": "Open the OpenCodex dashboard" + "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…", @@ -88,7 +108,7 @@ "days": "resets in {days}d {hours}h" }, "account": { - "active": "ACTIVE", + "active": "CURRENT", "paused": "PAUSED", "reauth": "REAUTH", "warning": "WARNING", From 742c1508f99fe7b9ccfa5f0fdff6418f914c9a17 Mon Sep 17 00:00:00 2001 From: wy3z Date: Sun, 23 Aug 2026 09:56:27 +0100 Subject: [PATCH 3/3] feat(opencodex-bar): show account subscription plans --- opencodex-bar/README.md | 4 +-- opencodex-bar/panel.luau | 47 ++++++++++++++++++++++++++---- opencodex-bar/service.luau | 14 +++++++-- opencodex-bar/translations/en.json | 2 +- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/opencodex-bar/README.md b/opencodex-bar/README.md index b858ba1f..96540bcf 100644 --- a/opencodex-bar/README.md +++ b/opencodex-bar/README.md @@ -26,7 +26,7 @@ Install from the Noctalia plugin store and add the `usage` widget to a bar. Clic noctalia msg panel-toggle wy3z/opencodex-bar:panel ``` -- Accounts: health, reauth, quota windows, active Codex account selection, and confirmed reset-credit use +- 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`. @@ -55,4 +55,4 @@ Disabled OpenCodex providers are hidden the same way. Hidden providers are strip - 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. +- 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/panel.luau b/opencodex-bar/panel.luau index 6ebf9dbb..faa027d9 100644 --- a/opencodex-bar/panel.luau +++ b/opencodex-bar/panel.luau @@ -170,6 +170,18 @@ local function usedColor(value) 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 @@ -376,11 +388,27 @@ local function accountCard(rows, provider, account, providerHasAccountQuota, nam if named then local status, statusColor = accountStatus(account) + local plan = accountPlan(account) local title = { - ui.label({ text = account.label or account.id, flexGrow = 1, fontWeight = "bold", maxWidth = 250 }), + 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 = 11, fontWeight = "bold" })) + 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 @@ -485,16 +513,25 @@ local function accountsTab(body) local hasAccountQuota = common.anyAccountQuota(provider) local header = { providerMark(provider.id, 16), - ui.label({ text = provider.label or provider.id, fontWeight = "bold", flexGrow = 1 }), + 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 = 11, fontWeight = "bold" })) + 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)) @@ -515,7 +552,7 @@ local function accountsTab(body) key = "pool:" .. provider.id, text = noctalia.tr("pool.strategy", { strategy = provider.pool.strategy }), color = "on_surface/0.6", - fontSize = 11, + fontSize = 10, })) end if index < #providers then diff --git a/opencodex-bar/service.luau b/opencodex-bar/service.luau index a8091e2a..2028a1f7 100644 --- a/opencodex-bar/service.luau +++ b/opencodex-bar/service.luau @@ -354,12 +354,19 @@ local function normalizeQuota(value) } end --- Deliberately never an email address: the panel lists accounts in the open, --- and an alias, log label or "Main account" identifies them just as well. +-- 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 - if isMain then return noctalia.tr("account.main") end return nonEmpty(account.id) or nonEmpty(account.accountId) or noctalia.tr("account.fallback") end @@ -393,6 +400,7 @@ local function normalizeAccount(raw, activeId, isMain) 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), diff --git a/opencodex-bar/translations/en.json b/opencodex-bar/translations/en.json index dd0df33e..ec501dfa 100644 --- a/opencodex-bar/translations/en.json +++ b/opencodex-bar/translations/en.json @@ -114,7 +114,7 @@ "warning": "WARNING", "fallback": "Account", "none": "No accounts", - "main": "Main account" + "main": "Main Account" }, "pool": { "strategy": "Pool: {strategy}"