diff --git a/docs/cliproxy-rust-model-usage-research.md b/docs/cliproxy-rust-model-usage-research.md new file mode 100644 index 00000000..da18ea22 --- /dev/null +++ b/docs/cliproxy-rust-model-usage-research.md @@ -0,0 +1,257 @@ +# CLIProxyAPI Rust support for Model Usage + +Research date: 2026-10-03. Scope: CybexOS's Model Usage widget and the Rust +proxy running at `root@10.10.0.235`. + +The recommended change is a native Rust adapter in the upstream Model Usage +plugin, followed by a vendor update in CybexOS. Rust already exposes the +account inventory, subscription quota windows, and account activity needed +for the main widget. The existing server URL and management key work with +that API. A proxy upgrade is unnecessary for this first stage. + +## Verified deployment and failure + +The active service is `cli-proxy-api.service`, running +`/opt/cli-proxy-api-rust/current/cliproxyapi-rust`. Its current release is +`0.3.2+gui.5862bb7fdbe8`, based on upstream `v0.3.2`, with a local GUI +configuration editor. GitHub's latest official release was also `v0.3.2`. +The backend listens on TLS `127.0.0.1:8318`, behind the existing admission +gate on `:8317`. The configured widget URL, +`https://aiproxy.risk-bull.ts.net`, reaches it through Tailscale Serve. + +The installed RPM and this checkout have identical `usage-fetch.py` files. +Both use the Go implementation's `/v0/management/auth-files` and +`/v0/management/api-call` endpoints. The vendored plugin is version 1.1.1 at +`8266a07495674d2d425f6d76b67833872a69d466`; that was also the upstream plugin's +latest commit at inspection. Re-vendoring the current upstream alone cannot +resolve the incompatibility. + +Authenticated reads through the widget's configured URL produced: + +| Check | Observed result | Implication | +| --- | --- | --- | +| Existing client's account discovery | HTTP 401 and “CLIProxyAPI rejected the management key or remote management access.” | The current error suggests a credential problem. | +| `GET /api/accounts` with the same management key | HTTP 200; three Claude and three Codex OAuth accounts | The saved management key is valid; the API implementation differs. | +| `GET /api/accounts` with a deliberately invalid key | HTTP 401 | Native management authentication is enforced. | +| Native account quota data | Six accounts and nine windows in the feasibility snapshot | Claude had 5-hour and weekly windows; Codex exposed weekly windows at that moment. | +| Native account activity | All six accounts had `last_used` timestamps | Keeper is unnecessary for Rust account activity. | +| `GET /api/overview` | Reported the local Rust version and `round-robin` routing | Do not infer the deployed routing strategy from upstream's `least-used` default. | +| `GET /healthz` | HTTP 200 | The backend was available during inspection. | + +Upstream explicitly documents that `/v0/management` and the Redis usage +queue are unsupported. Unsupported paths can return 401 through the client +authentication middleware; 401 on the legacy path alone does not establish +that the management key is wrong. + +CPA Usage Keeper remains active, but its current logs repeatedly report +failed metadata reads and “management usage queue request returned status +401,” alongside a Redis protocol mismatch. Service activity does not prove +that Keeper is collecting new usage. Preserve its existing database and +historical records; Rust requires a different collection source. + +## Native data and feature limits + +`GET /api/accounts` returns a JSON array of account snapshots. It is an +authenticated read of cached server state, so the widget need not send +OAuth tokens or trigger provider quota calls. Rust normally polls Claude +and Codex quota about every five minutes, checks polling eligibility every +minute, and also observes quota information on model responses. + +| Widget data | Rust field or behavior | Proposed handling | +| --- | --- | --- | +| Provider and account label | `provider`, `email`, `label` | Reuse provider grouping, sanitization, and email hiding. | +| Account identity | `id`; OAuth IDs are `file:`; `file` is also present | Hash a canonical identity. Verify filename mapping against previous Go history and labels before claiming migration continuity. | +| Paused account | `disabled` | Retain it as paused and exclude it from capacity selection. | +| Quota amount | `quota.windows[].used`, a percentage from 0 to 100 | Map to `used` and `remaining = 100 - used`. Validate finite numbers; missing data remains unknown. | +| Window label and scope | `name`, optional `model` | Translate known names such as `5h` and `week`; preserve model scope and unknown names. | +| Reset and freshness | `resets_at`, `quota.updated_at` | Parse timezone-aware timestamps and retain the server's observation time. A successful HTTP read does not make an old quota fresh. | +| Plan | Optional `quota.plan` | Map Codex plan metadata when present. Claude plan metadata was absent in the live response. | +| Last account used | `last_used` | Select the latest account per provider, preserving ties as ambiguous. | +| Credits and banked resets | Absent from account snapshots | Keep values unknown and disable banked-reset actions for Rust. | +| Other provider quotas | Quota polling currently covers Claude and Codex | Show other providers with quota unavailable; preserve inventory and pause state. | + +The API does not guarantee that both 5-hour and weekly windows are present +on every read. Missing windows must not become zero usage or full capacity. +An initial cache can be empty after startup, and failed provider polling can +leave old values in it. A proposed stale policy should allow for the normal +five-minute polling interval and scheduling delay; its threshold still +needs an implementation decision and tests. + +Rust sets `last_used` when a tracked request finishes, including failed +requests. It does not identify an account serving a request still in +progress. Activity and counters reset on process restart. The widget should +describe this as the last completed request, including failures, and return +to unknown when the server has no recorded activity. + +Rust's `POST /api/accounts/{id}/reset` only clears local cooldowns, strikes, +and the last error. It must never substitute for spending a Codex banked +reset. The account API also omits paid credit balances, Claude extra usage, +and Codex code-review/additional quota buckets available to the existing Go +adapter. Full feature parity would require further server API work. + +## Proposed implementation + +Keep `usageSource: cliproxy` and the current connection settings. Detect the +implementation through authenticated account responses, then feed both +implementations into the existing normalized `schemaVersion: 1` contract. +An additive implementation identifier and capability fields can let QML +select activity and reset behavior without a new setup flow. + +1. Extend `scripts/usage-fetch.py` with a Rust client and account normalizer. + Probe `/api/accounts` and the legacy account endpoint within one bounded + deadline, accepting only validated response shapes. In particular, allow + a legacy 401 to be resolved by a successful native probe without treating + every authentication failure as proof of Rust. Preserve redirect refusal, + TLS validation, secret-file protections, response-size limits, and + provider/account bounds. Avoid `/api/overview` for routine discovery: its + response includes client API keys that the widget does not need. +2. Reuse the account grouping, best-capacity selection, quota display, and + history code. Separate fetch time from quota observation time, show stale + or unknown data honestly, and avoid appending unchanged stale observations + as fresh history. Verify canonical account and window identities so + existing private labels and quota history can survive the transition. +3. Adapt `scripts/proxy-activity.py` and `UsageActivityBackend.qml` to read + native `last_used` values for Rust. Remove the Keeper URL requirement for + that implementation and update the current Keeper-only notice. Reuse an + available account snapshot or make a cheap native read for the existing + 15-second activity refresh; do not repeat provider quota checks. +4. Make reset availability depend on implementation capabilities as well as + credit data. Update `ResetBackend.qml` and `scripts/reset-credit.py` so a + Rust connection cannot execute Go-only reset actions. Clear incompatible + cached credits when the detected implementation changes, even if the URL + is unchanged. Missing credits must remain unknown rather than zero. +5. Update the plugin's settings help and documentation to explain Rust + support and its limits. Keep local and T3 transcript cost sources working + independently of the quota adapter. + +Implement this in +[DigitalPals/omarchy-modelusage](https://github.com/DigitalPals/omarchy-modelusage) +first. CybexOS deliberately vendors its runtime files unchanged; local edits +would be overwritten by the next sync. Import the reviewed upstream commit +with `scripts/sync-model-usage `. Update +`Common/SettingsHelpers.js` only if the plugin's settings schema changes. +The shared runtime must ship through both checkout and ISO/RPM paths. + +## Persistent costs are separate work + +Rust has token counts in `GET /api/requests`, aggregate counters in +`/api/overview`, and live events on `/api/live`. These are in memory: +`src/state.rs` retains at most 300 recent request records and 60 minute +buckets. The request log stores an account label rather than a stable +account ID. Polling that ring cannot guarantee complete history across high +traffic, disconnects, or restarts. + +For reliable Rust-backed Keeper costs, add durable events with stable +account IDs, an instance/event identity, replay cursors, and deduplication, +then adapt Keeper ingestion. Preserve historical Go records. That work can +follow the quota/activity adapter; the widget's local/T3 cost collection +does not depend on it. No widget change can recover proxy usage that was +never persisted. + +## Validation required for implementation + +Add native fixtures and HTTP integration coverage for account discovery, +bad keys, implementation detection including the observed legacy 401, +empty inventories, disabled/unknown providers, missing and stale quotas, +model-scoped windows, malformed numbers/timestamps, oversized payloads, +restart behavior, and activity ties. Test private-label/history identity +continuity, capability changes at the same URL, and that Rust polling never +calls `api-call`, account mutation, or banked-reset endpoints. Retain the +existing Go behavior and tests. + +Run `python3 -B tests/model-usage.py`, the source gate `./tests/run`, and +`python3 -B image/check-source`. Add any necessary shared-payload parity +coverage. For live UI testing, use `tests/lib/quickshell-live` and the managed +service, including sole-process and current-invocation journal checks. A +release with changed runtime payload needs a new same-revision RPM/ISO and +installation qualification; fixture tests do not qualify an installation. + +The initial research validated source contracts, authenticated live reads, and an +in-memory normalization experiment. That experiment produced six unique +hashed account identities, nine quota windows, and six activity timestamps +without writing state or calling provider/model/action endpoints. It was +not a production implementation, widget UI test, identity migration test, +or end-to-end installation test. At that stage, only this research document +was added; server, installed desktop, credentials, and preferences were +unchanged. The subsequent implementation and live deployment are recorded +below. + +## Implementation and live verification + +The native adapter was implemented and applied to the workstation on +2026-10-03 at the user's request. Model Usage 1.2.0 is pinned to upstream +commit `d34db479f99ea4afc3edb539cfb8775a50a57433`, published to +`DigitalPals/omarchy-modelusage`'s `main` branch at the user's request. The +CybexOS vendored runtime and test manifest now pin the follow-up commit +`bc771a070ba1a18a3566a7006ecd1ba8c000713c`, which also accepts Rust nanosecond timestamps on +Python 3.10. The original live RPM below was built from `d34db47`; its +Python 3.14 runtime already accepts those timestamps. No proxy-server +change was made. + +The adapter detects a validated native account inventory after an unsupported +legacy endpoint response. Discovery shares one timeout budget. It normalizes +Claude and Codex OAuth quota windows, preserves filename-based account hashes, +and reads native last-completed-request activity without Keeper. Cached quota +observations older than ten minutes are marked stale; missing or expired +windows remain unavailable. Rust credits stay unknown, and banked-reset +actions are blocked in the UI and backend. + +The checkout and RPM packaging now share `prepare_quickshell`, with a +byte-for-byte Model Usage payload regression check. The live installation +received a development `cybexos-desktop` RPM through DNF, preserving the +installed baseline's other files, dependencies, package scripts, and existing +session-file customization. This scoped development build replaces Model +Usage and adds update provenance; it is not a full release build. + +Verification completed: + +- `python3 -B tests/model-usage.py` passed, including 158 upstream Python + tests and JavaScript assertions. Fourteen new Rust tests cover native + discovery, authentication failures, response limits, quota freshness and + identities, activity, and rejection of unsupported actions. +- `./tests/run` passed all 17 stages, including 1,173 unit tests and lint of + 327 QML files. The separate real-engine lifecycle stage was skipped because + the managed live shell was active; QML component runtime checks passed. +- `python3 -B image/check-source` passed image tooling, 276 image unit tests, + and installer JavaScript tests. These are source and payload parity checks, + not an end-to-end ISO installation qualification. +- Authenticated reads through the installed adapter detected Rust, three + Claude accounts, three Codex accounts, and nine fresh quota windows. Native + activity matched the normalized quota identities for both providers. +- The managed live Limits panel rendered both Claude and Codex, each with + three account cards and reset times. The Codex view displayed its plan + metadata and weekly limits. `rpm -V cybexos-desktop` reported no drift after + installation. +- The saved Model Usage preferences, management-key file contents and path, + and private key permissions were unchanged. The restarted + `quickshell.service` was active, its MainPID was the sole `qs` process, and + its current invocation journal contained no QML/runtime errors. Live checks + used `tests/lib/quickshell-live` with cleanup traps. + +After publishing, upstream CI exposed Python 3.10 rejecting native +nanosecond timestamps. The follow-up normalizes fractional seconds to +microsecond precision and adds coverage for one through nine fractional +digits and timezone offsets. All 159 upstream Python tests and JavaScript +assertions passed locally after that correction. + +The development RPM is retained for reinstalling the tested payload in +`/home/john/.local/share/cybexos/images/rust-widget-live.GHVEIEZX/` alongside +`SHA256SUMS`, `provenance.json`, and `rpm-validation.json`. Its filename is +`cybexos-desktop-0.0.0~dev-1.20260926095228.g4054070a4078.hwfix3.fc44.rustwidget1.x86_64.rpm` +and its size is 1,720,360,693 bytes (1.60 GiB). Build intermediates, temporary +test logs, and desktop screenshots were removed after verification. No test +VMs or mounts were created. No ISO was built or qualified; release qualification +remains separate from this live development test. + +## Sources + +- [Rust v0.3.2 release](https://github.com/IuCC123/CLIProxyAPI-Rust/releases/tag/v0.3.2). +- [Pinned Rust README and compatibility limits](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/README.md#coming-from-cliproxyapi). +- [Management routes and authentication](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/mgmt.rs#L292). +- [Account snapshots and filename identities](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/accounts.rs#L483). +- [Quota fields and polling](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/quota.rs). +- [Activity semantics](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/proxy.rs#L100) and [in-memory request retention](https://github.com/IuCC123/CLIProxyAPI-Rust/blob/3f937ce690d7507065a42be9ce11b26fc16eee06/src/state.rs#L195). +- [CybexOS vendoring contract](../roles/desktop/files/quickshell/ModelUsage/README.md), [existing proxy client](../roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py), and [installation parity requirements](installation-parity.md). +- Live observations from `root@10.10.0.235`, its local deployment records, + updater status, authenticated native API, and Keeper error logs on the + research date. No credentials or raw account identities are reproduced. diff --git a/image/desktop_payload.py b/image/desktop_payload.py index f0f314b4..f4e48797 100644 --- a/image/desktop_payload.py +++ b/image/desktop_payload.py @@ -4,6 +4,21 @@ import shutil +def prepare_quickshell(root, payload): + """Package the same shell sources deployed by the checkout desktop role.""" + quickshell = root / "roles/desktop/files/quickshell" + runtime = payload / "usr/share/cybexos/runtime/quickshell" + for source in sorted(quickshell.rglob("*")): + if not source.is_file() or source.is_symlink() or "__pycache__" in source.parts or source.suffix == ".pyc": + continue + target = runtime / source.relative_to(quickshell) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, target) + target.chmod(0o755 if source.parent.name == "scripts" and source.suffix != ".py" else 0o644) + if source.suffix in (".qml", ".js"): + target.write_text(target.read_text().replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) + + def prepare_defaults(root, payload, environment, inventory): contract = json.loads((root / "assets/desktop-contract.json").read_text()) vendor = payload / "usr/share/cybexos" diff --git a/image/package b/image/package index 51cb5a63..b7415032 100755 --- a/image/package +++ b/image/package @@ -14,7 +14,7 @@ import urllib.request import jinja2 import yaml from application_payload import relativize_seed_links, include_applications -from desktop_payload import prepare_defaults, prepare_session, split_seed, prepare_managed_defaults, prepare_app_launchers +from desktop_payload import prepare_defaults, prepare_session, split_seed, prepare_managed_defaults, prepare_app_launchers, prepare_quickshell from provision_payload import prepare_provision from release_metadata import install_update_channel, render_spec @@ -66,15 +66,7 @@ def main(): runtime = "usr/share/cybexos/runtime" copy("LICENSE", "usr/share/licenses/cybexos-desktop/LICENSE") copy("release-manifest.json", "usr/share/cybexos/release-manifest.json") - quickshell = ROOT / "roles/desktop/files/quickshell" - for source in sorted(quickshell.rglob("*")): - if not source.is_file() or source.is_symlink() or "__pycache__" in source.parts or source.suffix == ".pyc": - continue - relative = f"{runtime}/quickshell/{source.relative_to(quickshell)}" - copy(source.relative_to(ROOT), relative, source.parent.name == "scripts" and source.suffix != ".py") - if source.suffix in (".qml", ".js"): - target = payload / relative - target.write_text(target.read_text().replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-")) + prepare_quickshell(ROOT, payload) for name in ("hyprland.lua", "bindings.lua", "autostart.lua", "displays.lua", "input_preferences.lua"): content = (ROOT / "roles/desktop/files" / name).read_text() content = content.replace("/usr/local/libexec/cybexos-", "/usr/libexec/cybexos-") diff --git a/image/test_desktop_payload.py b/image/test_desktop_payload.py index 7d515d7d..8423f81d 100644 --- a/image/test_desktop_payload.py +++ b/image/test_desktop_payload.py @@ -13,7 +13,7 @@ import yaml from boot_branding import brand_boot_menu -from desktop_payload import prepare_defaults, split_seed +from desktop_payload import prepare_defaults, split_seed, prepare_quickshell ROOT = Path(__file__).resolve().parents[1] @@ -31,6 +31,23 @@ def load(name, relative): class DesktopPayload(unittest.TestCase): + def test_model_usage_runtime_matches_checkout_including_rust_adapter(self): + source = ROOT / "roles/desktop/files/quickshell/ModelUsage" + with tempfile.TemporaryDirectory() as temporary: + payload = Path(temporary) + prepare_quickshell(ROOT, payload) + installed = payload / "usr/share/cybexos/runtime/quickshell/ModelUsage" + files = {p.relative_to(source): p for p in source.rglob("*") + if p.is_file() and not p.is_symlink() and "__pycache__" not in p.parts and p.suffix != ".pyc"} + self.assertEqual(set(files), {p.relative_to(installed) for p in installed.rglob("*") if p.is_file()}) + for relative, path in files.items(): + expected = path.read_bytes() + if path.suffix in (".qml", ".js"): + expected = expected.replace(b"/usr/local/libexec/cybexos-", b"/usr/libexec/cybexos-") + self.assertEqual((installed / relative).read_bytes(), expected, str(relative)) + self.assertIn(b"def fetch_rust_account", (installed / "scripts/usage-fetch.py").read_bytes()) + self.assertIn(b"normalize_rust_activity", (installed / "scripts/proxy-activity.py").read_bytes()) + def test_portable_defaults_and_boot_assets_share_workstation_sources(self): environment = jinja2.Environment(undefined=jinja2.StrictUndefined) environment.filters.update(bool=bool, ternary=lambda value, yes, no: yes if value else no) diff --git a/roles/desktop/files/quickshell/ModelUsage/Panel.qml b/roles/desktop/files/quickshell/ModelUsage/Panel.qml index 6535ac17..2f2474c2 100644 --- a/roles/desktop/files/quickshell/ModelUsage/Panel.qml +++ b/roles/desktop/files/quickshell/ModelUsage/Panel.qml @@ -260,6 +260,7 @@ Ui.Panel { if (account.reading && account.reading.status === "disabled") text += "\nThis account is now paused." if (account.reading && account.reading.stale) text += "\nQuota is a last-known reading." if (activityBackend.fetchError !== "" && account.reading) text += "\nShowing last-known account activity." + if (activityBackend.rustProxy) text += "\nLatest completed request, including failures; activity resets when the proxy restarts." return text } @@ -1108,6 +1109,7 @@ Ui.Panel { objectName: "accountResetAction" anchors.fill: parent enabled: root.proxyMode && accountCard.account.status === "ok" + && accountCard.account.supportsBankedReset !== false && Number(accountCard.account.credits && accountCard.account.credits.resetCreditsAvailable) > 0 && !resetBackend.active && !resetBackend.busy hoverEnabled: true diff --git a/roles/desktop/files/quickshell/ModelUsage/README.md b/roles/desktop/files/quickshell/ModelUsage/README.md index 141d9f24..dffeb8a1 100644 --- a/roles/desktop/files/quickshell/ModelUsage/README.md +++ b/roles/desktop/files/quickshell/ModelUsage/README.md @@ -3,7 +3,7 @@ This directory is the [Model Usage](https://github.com/DigitalPals/omarchy-modelusage) Omarchy plugin, vendored as the shell's built-in Model Usage widget. -Commit: `8266a07495674d2d425f6d76b67833872a69d466` (version 1.1.1) +Commit: `bc771a070ba1a18a3566a7006ecd1ba8c000713c` (version 1.2.0) Everything here except this README is upstream's, unchanged: the QML and JavaScript, `assets/`, `scripts/`, `LICENSE` and `THIRD_PARTY_NOTICES.md`. The diff --git a/roles/desktop/files/quickshell/ModelUsage/ResetBackend.qml b/roles/desktop/files/quickshell/ModelUsage/ResetBackend.qml index b80c5e9a..ae9f7a2b 100644 --- a/roles/desktop/files/quickshell/ModelUsage/ResetBackend.qml +++ b/roles/desktop/files/quickshell/ModelUsage/ResetBackend.qml @@ -26,6 +26,7 @@ Item { function begin(account, label) { if (active || busy || usageBackend.usageSource !== "cliproxy" || !account || account.id !== "codex" || account.status !== "ok" + || account.supportsBankedReset === false || !(Number(account.credits && account.credits.resetCreditsAvailable) > 0) || !account.accountId) return false accountId = String(account.accountId) diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageActivityBackend.qml b/roles/desktop/files/quickshell/ModelUsage/UsageActivityBackend.qml index 9974efdf..f05ee619 100644 --- a/roles/desktop/files/quickshell/ModelUsage/UsageActivityBackend.qml +++ b/roles/desktop/files/quickshell/ModelUsage/UsageActivityBackend.qml @@ -16,8 +16,9 @@ Item { property string scriptPath: usageBackend.localPath(Qt.resolvedUrl("scripts/proxy-activity.py")) readonly property string keeperUrl: String(settings.costKeeperUrl || "") readonly property string passwordFile: String(settings.costKeeperPasswordFile || "") - readonly property bool trackingEnabled: usageBackend.usageSource === "cliproxy" && keeperUrl.trim() !== "" - readonly property string connectionId: JSON.stringify([usageBackend.connectionId, keeperUrl, passwordFile]) + readonly property bool rustProxy: usageBackend.proxyImplementation === "rust" + readonly property bool trackingEnabled: usageBackend.usageSource === "cliproxy" && (rustProxy || keeperUrl.trim() !== "") + readonly property string connectionId: JSON.stringify([usageBackend.connectionId, usageBackend.proxyImplementation, keeperUrl, passwordFile]) readonly property string notice: !trackingEnabled ? "Configure CPA Usage Keeper in settings to track the last-used account." : fetchError !== "" ? fetchError : lastSuccessAt <= 0 ? "Loading account activity…" : "" @@ -28,9 +29,10 @@ Item { launchPending = true pendingRefresh = false var command = ["python3", scriptPath, "--cliproxy-url", usageBackend.cliproxyUrl, - "--keeper-url", keeperUrl, "--timeout", "10"] + "--timeout", "10"] + if (!rustProxy && keeperUrl.trim() !== "") command.push("--keeper-url", keeperUrl) if (usageBackend.cliproxyKeyFile !== "") command.push("--cliproxy-key-file", usageBackend.cliproxyKeyFile) - if (passwordFile !== "") command.push("--keeper-password-file", passwordFile) + if (!rustProxy && passwordFile !== "") command.push("--keeper-password-file", passwordFile) process.command = command process.connectionId = connectionId process.running = true diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageBackend.qml b/roles/desktop/files/quickshell/ModelUsage/UsageBackend.qml index 2ce420bb..e3811fe1 100644 --- a/roles/desktop/files/quickshell/ModelUsage/UsageBackend.qml +++ b/roles/desktop/files/quickshell/ModelUsage/UsageBackend.qml @@ -25,6 +25,7 @@ Item { readonly property string cliproxyUrl: String(setting("cliproxyUrl", "http://127.0.0.1:8317")) readonly property string cliproxyKeyFile: String(setting("cliproxyKeyFile", "")) readonly property var providers: payload ? UsageLogic.listOrEmpty(payload.providers) : [] + readonly property string proxyImplementation: String(payload && payload.proxyImplementation || "") readonly property string connectionId: JSON.stringify([usageSource, cliproxyUrl, cliproxyKeyFile]) readonly property string scriptPath: localPath(Qt.resolvedUrl("scripts/usage-fetch.py")) readonly property double nextRefreshAt: lastAttemptAt > 0 diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageLogic.js b/roles/desktop/files/quickshell/ModelUsage/UsageLogic.js index 9db1b54a..faa1ba68 100644 --- a/roles/desktop/files/quickshell/ModelUsage/UsageLogic.js +++ b/roles/desktop/files/quickshell/ModelUsage/UsageLogic.js @@ -185,8 +185,17 @@ function errorTitle(provider) { function preserveProxyReadings(previous, next) { if (!previous || previous.source !== "cliproxy") return next + if (previous.proxyImplementation && next.proxyImplementation + && previous.proxyImplementation !== next.proxyImplementation) return next + if (!next.proxyImplementation && previous.proxyImplementation) + next.proxyImplementation = previous.proxyImplementation function retain(old, fresh) { if (!old || !fresh || fresh.status !== "error" || old.status !== "ok") return fresh + if (old.proxyImplementation && fresh.proxyImplementation + && old.proxyImplementation !== fresh.proxyImplementation) return fresh + if (fresh.errorKind === "malformed" || fresh.supportsBankedReset === false + && fresh.proxyImplementation !== old.proxyImplementation) return fresh + if (fresh.proxyImplementation === "rust" && fresh.errorKind === "quota_unavailable") return fresh return Object.assign({}, fresh, { status: "ok", stale: true, windows: old.windows, credits: old.credits, plan: old.plan, account: old.account, fetchedAt: old.fetchedAt, diff --git a/roles/desktop/files/quickshell/ModelUsage/UsageSettings.qml b/roles/desktop/files/quickshell/ModelUsage/UsageSettings.qml index 37147ce9..cacf9223 100644 --- a/roles/desktop/files/quickshell/ModelUsage/UsageSettings.qml +++ b/roles/desktop/files/quickshell/ModelUsage/UsageSettings.qml @@ -280,7 +280,7 @@ Column { Hint { text: "Save to discover the proxy’s providers and accounts." } Section { title: "Learn more" - Hint { text: "Use the key for the CLIProxyAPI management panel. It is saved privately on this device. Quota limits and estimated costs use separate data sources." } + Hint { text: "Use the management key for CLIProxyAPI or CLIProxyAPI-Rust. The implementation is detected automatically. The key is saved privately on this device. Quota limits and estimated costs use separate data sources." } } } @@ -411,7 +411,7 @@ Column { Hint { text: "Show the last-used account in the percentage menu bar." } Section { title: "Learn more" - Hint { text: "Use the Keeper connected to this proxy. Account activity updates every 15 seconds, independently of Costs. Leave the URL blank to disable this integration." } + Hint { text: "Go proxies use Keeper for account activity. Rust proxies report activity directly and do not need Keeper. Activity updates every 15 seconds, independently of Costs." } } } diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/proxy-activity.py b/roles/desktop/files/quickshell/ModelUsage/scripts/proxy-activity.py index f64870af..8ab6077e 100644 --- a/roles/desktop/files/quickshell/ModelUsage/scripts/proxy-activity.py +++ b/roles/desktop/files/quickshell/ModelUsage/scripts/proxy-activity.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Match Keeper's last recorded requests to current CLIProxyAPI accounts. +"""Read Rust account activity or match Keeper requests to Go proxy accounts. Only account hashes and timestamps leave this process. Never consume CPA's usage queue, fetch request contents, or run upstream quota calls here. @@ -76,9 +76,14 @@ def normalize_activity(entries, document, now=None): def collect(proxy_url, proxy_key_file, keeper_url, password_file, timeout): - client = keeper.Client(keeper_url, timeout) + deadline = time.monotonic() + timeout proxy = usage.CliProxyClient(proxy_url, usage.read_cliproxy_key(proxy_key_file)) - entries = proxy.auth_files(max(0.1, client.deadline - time.monotonic())) + entries = proxy.auth_files(timeout) + if proxy.implementation == "rust": + return normalize_rust_activity(entries) + if not keeper_url: + raise keeper.KeeperError("Configure CPA Usage Keeper to track last-used accounts on the Go proxy.") + client = keeper.Client(keeper_url, max(0.1, deadline - time.monotonic())) logged_in = False try: if password_file: @@ -98,11 +103,30 @@ def collect(proxy_url, proxy_key_file, keeper_url, password_file, timeout): pass +def normalize_rust_activity(entries, now=None): + now = time.time() if now is None else now + latest = {} + for entry in entries: + provider = entry["provider"] + timestamp = usage.rust_timestamp(entry.get("last_used"), observed=True, now=now) + if timestamp is None: + continue + account_id = usage.cliproxy_account_record(provider, entry)["accountId"] + previous = latest.get(provider) + if previous is None or timestamp > previous[0]: + latest[provider] = (timestamp, {account_id}) + elif timestamp == previous[0]: + previous[1].add(account_id) + return [{"id": provider, "status": "ok" if len(ids) == 1 else "ambiguous", + "accountId": next(iter(ids)) if len(ids) == 1 else "", "lastUsedAt": timestamp} + for provider, (timestamp, ids) in sorted(latest.items())] + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--cliproxy-url", required=True) parser.add_argument("--cliproxy-key-file", type=Path) - parser.add_argument("--keeper-url", required=True) + parser.add_argument("--keeper-url", default="") parser.add_argument("--keeper-password-file", type=Path) parser.add_argument("--timeout", type=float, default=10) args = parser.parse_args() diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/reset-credit.py b/roles/desktop/files/quickshell/ModelUsage/scripts/reset-credit.py index e402cf96..f958444f 100644 --- a/roles/desktop/files/quickshell/ModelUsage/scripts/reset-credit.py +++ b/roles/desktop/files/quickshell/ModelUsage/scripts/reset-credit.py @@ -80,6 +80,8 @@ def resolve_account(client, account_id, remaining): if len(matches) != 1: raise usage.ProviderFailure("config", "This account changed or is unavailable. Refresh its usage first.") entry = matches[0] + if entry.get("_rust") is True: + raise usage.ProviderFailure("unsupported", "Banked Codex resets are unavailable through CLIProxyAPI-Rust.") if entry.get("disabled") is True or entry.get("status") == "disabled": raise usage.ProviderFailure("config", "This account is paused in CLIProxyAPI.") claims = entry.get("id_token") if isinstance(entry.get("id_token"), dict) else {} diff --git a/roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py b/roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py index 4aeb7827..10d37c95 100644 --- a/roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py +++ b/roles/desktop/files/quickshell/ModelUsage/scripts/usage-fetch.py @@ -59,6 +59,7 @@ CODEX_RPC_READ_BYTES = 64 * 1024 MAX_CLIPROXY_ACCOUNTS = 32 MAX_CLIPROXY_PROVIDERS = 32 +RUST_QUOTA_STALE_SECONDS = 10 * 60 class ProviderFailure(Exception): @@ -914,14 +915,17 @@ class CliProxyClient: def __init__(self, address: str, key: str): self.base_url = normalize_cliproxy_url(address) self.key = key + self.implementation = "auto" - def request(self, path: str, timeout: float, payload: dict[str, Any] | None = None) -> Any: + def request(self, path: str, timeout: float, payload: dict[str, Any] | None = None, + *, native: bool = False) -> Any: headers = {"Authorization": "Bearer " + self.key, "Accept": "application/json"} body = None if payload is not None: headers["Content-Type"] = "application/json" body = json.dumps(payload).encode("utf-8") - request = urllib.request.Request(self.base_url + "/v0/management/" + path, data=body, headers=headers) + prefix = "/api/" if native else "/v0/management/" + request = urllib.request.Request(self.base_url + prefix + path, data=body, headers=headers) try: opener = urllib.request.build_opener(NoRedirects()) with opener.open(request, timeout=timeout) as response: @@ -929,10 +933,13 @@ def request(self, path: str, timeout: float, payload: dict[str, Any] | None = No except urllib.error.HTTPError as exc: exc.close() if exc.code in (401, 403): - raise ProviderFailure("config", "CLIProxyAPI rejected the management key or remote management access.") from None - if exc.code == 429: - raise ProviderFailure("rate_limited", "CLIProxyAPI is rate limiting management requests.") from None - raise ProviderFailure("http", f"CLIProxyAPI management endpoint returned HTTP {exc.code}.") from None + error = ProviderFailure("config", "CLIProxyAPI rejected the management key or remote management access.") + elif exc.code == 429: + error = ProviderFailure("rate_limited", "CLIProxyAPI is rate limiting management requests.") + else: + error = ProviderFailure("http", f"CLIProxyAPI management endpoint returned HTTP {exc.code}.") + error.http_status = exc.code + raise error from None except (TimeoutError, urllib.error.URLError, OSError) as exc: reason = getattr(exc, "reason", exc) if isinstance(reason, TimeoutError) or "timed out" in str(reason).lower(): @@ -942,17 +949,67 @@ def request(self, path: str, timeout: float, payload: dict[str, Any] | None = No raise ProviderFailure("malformed", "CLIProxyAPI returned unexpectedly large usage data.") try: return json.loads(raw) - except (ValueError, UnicodeDecodeError): + except (ValueError, UnicodeDecodeError, RecursionError): raise ProviderFailure("malformed", "CLIProxyAPI returned unreadable JSON.") from None def auth_files(self, timeout: float) -> list[dict[str, Any]]: - payload = self.request("auth-files", timeout) + deadline = time.monotonic() + timeout + if self.implementation == "rust": + return self.rust_accounts(timeout) + try: + payload = self.request("auth-files", timeout) + except ProviderFailure as legacy_error: + # Rust's unsupported legacy paths can pass through client auth and + # return 401. Only a valid authenticated native inventory proves Rust. + if self.implementation == "go" or getattr(legacy_error, "http_status", None) not in (401, 403, 404, 405): + raise + remaining = deadline - time.monotonic() + if remaining <= 0: + raise ProviderFailure("timeout", "CLIProxyAPI account discovery timed out.") from None + try: + return self.rust_accounts(remaining) + except ProviderFailure as native_error: + if getattr(native_error, "http_status", None) in (401, 403, 404, 405): + raise legacy_error from None + raise if not isinstance(payload, dict) or not isinstance(payload.get("files"), list): raise ProviderFailure("malformed", "CLIProxyAPI returned an invalid account list.") + self.implementation = "go" return [entry for entry in payload["files"] if isinstance(entry, dict)] + def rust_accounts(self, timeout: float) -> list[dict[str, Any]]: + payload = self.request("accounts", timeout, native=True) + if not isinstance(payload, list) or len(payload) > 4096: + raise ProviderFailure("malformed", "CLIProxyAPI-Rust returned an invalid account list.") + entries, seen = [], set() + for row in payload: + if (not isinstance(row, dict) or not isinstance(row.get("id"), str) + or not 0 < len(row["id"]) <= 512 or not isinstance(row.get("provider"), str) + or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", row["provider"]) + or not isinstance(row.get("disabled"), bool) + or row.get("kind") not in ("oauth", "api-key", "service-account")): + raise ProviderFailure("malformed", "CLIProxyAPI-Rust returned an invalid account list.") + identity = row["id"] + # Go's OAuth auth-file ID is its filename. Keep hashes/private + # labels stable across a Go-to-Rust migration of the same files. + if identity.startswith("file:"): + filename = identity[5:] + if (not filename or filename in (".", "..") or "/" in filename or "\\" in filename + or row.get("file") != filename): + raise ProviderFailure("malformed", "CLIProxyAPI-Rust returned an invalid account identity.") + identity = filename + marker = (row["provider"], identity) + if marker in seen: + raise ProviderFailure("malformed", "CLIProxyAPI-Rust returned duplicate account identities.") + seen.add(marker) + entries.append(dict(row, id=identity, _rust=True)) + self.implementation = "rust" + return entries + def usage(self, entry: dict[str, Any], url: str, headers: dict[str, str], timeout: float, data: dict[str, Any] | None = None) -> Any: + if entry.get("_rust") is True or self.implementation == "rust": + raise ProviderFailure("unsupported", "CLIProxyAPI-Rust does not expose upstream account actions.") index = entry.get("auth_index") or entry.get("authIndex") if not isinstance(index, str) or not index.strip(): raise ProviderFailure("config", "CLIProxyAPI account has no auth_index. Check the account in its management panel.") @@ -1052,10 +1109,90 @@ def cliproxy_account_record(provider_id: str, entry: dict[str, Any]) -> dict[str result.update(source="CLIProxyAPI management API", authCommand="", accountId=hashlib.sha256((provider_id + ":" + identity).encode()).hexdigest()[:16], account=clean_message(entry.get("email") or entry.get("label") or entry.get("account") or "Managed account")) + result.update(proxyImplementation="rust" if entry.get("_rust") is True else "go", + supportsBankedReset=entry.get("_rust") is not True) + return result + + +def rust_timestamp(value: Any, *, observed: bool = False, now: float | None = None) -> float | None: + if value is None: + return None + try: + if not isinstance(value, str) or not 0 < len(value) <= 64: + raise ValueError + # Rust emits nanoseconds; Python 3.10's ISO parser accepts only + # millisecond or microsecond precision. Normalize to microseconds. + value = re.sub(r"(:\d{2}\.)(\d+)(?=(?:Z|[+-]\d{2}:\d{2})$)", + lambda match: match[1] + (match[2] + "000000")[:6], value) + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + stamp = parsed.timestamp() + current = time.time() if now is None else now + if parsed.tzinfo is None or stamp <= 0 or (observed and stamp > current + 60): + raise ValueError + return stamp + except (ValueError, TypeError, OverflowError): + raise ProviderFailure("malformed", "CLIProxyAPI-Rust returned an invalid quota or activity timestamp.") from None + + +def fetch_rust_account(provider_id: str, entry: dict[str, Any]) -> dict[str, Any]: + result = cliproxy_account_record(provider_id, entry) + result.update(source="CLIProxyAPI-Rust accounts API", supportsBankedReset=False) + if entry.get("disabled") is True: + result.update(status="disabled", notice="This account is paused in CLIProxyAPI.") + return result + if provider_id not in ("claude", "codex") or entry.get("kind") != "oauth": + result.update(status="unsupported", notice="CLIProxyAPI-Rust does not expose subscription quotas for this account.") + return result + try: + quota = entry.get("quota") + if not isinstance(quota, dict) or not isinstance(quota.get("windows"), list) or len(quota["windows"]) > 128: + raise ProviderFailure("malformed", "CLIProxyAPI-Rust returned invalid quota data.") + now = time.time() + observed = rust_timestamp(quota.get("updated_at"), observed=True, now=now) + windows, seen = [], set() + for row in quota["windows"]: + if not isinstance(row, dict) or not isinstance(row.get("name"), str) or not 0 < len(row["name"]) <= 128: + raise ProviderFailure("malformed", "CLIProxyAPI-Rust returned an invalid quota window.") + used = number(row.get("used")) if isinstance(row.get("used"), (int, float)) else None + model = row.get("model") + if used is None or not 0 <= used <= 100 or (model is not None and (not isinstance(model, str) or not 0 < len(model) <= 128)): + raise ProviderFailure("malformed", "CLIProxyAPI-Rust returned an invalid quota amount or model scope.") + name = row["name"] + window_id = {"5h": "session" if provider_id == "claude" else "codex-primary", + "week": "weekly" if provider_id == "claude" else "codex-secondary"}.get(name) + window_id = window_id if window_id and not model else "rust-" + hashlib.sha256(json.dumps([name, model]).encode()).hexdigest()[:16] + if window_id in seen: + raise ProviderFailure("malformed", "CLIProxyAPI-Rust returned duplicate quota windows.") + seen.add(window_id) + reset = rust_timestamp(row.get("resets_at"), now=now) + # A cached window past its reset cannot claim fresh capacity. + if reset is not None and reset <= now: + continue + seconds = {"5h": FIVE_HOURS, "week": SEVEN_DAYS, "day": 86400}.get(name) + label = {"5h": "5 hour limit", "week": "Weekly limit", "day": "Daily limit"}.get(name, name.title()) + if model: + label = model.title() + " · " + label + windows.append(make_window(window_id, label, used, reset, seconds)) + if observed is None or not windows: + raise ProviderFailure("quota_unavailable", "Subscription quotas are not available in the Rust proxy cache yet.") + raw_plan = quota.get("plan") + if raw_plan is not None and (not isinstance(raw_plan, str) or len(raw_plan) > 128): + raise ProviderFailure("malformed", "CLIProxyAPI-Rust returned invalid plan metadata.") + result.update(windows=windows, quotaUpdatedAt=observed, + fetchedAt=datetime.fromtimestamp(observed, timezone.utc).isoformat(), + planType=clean_message(raw_plan or ""), + plan=clean_message(CODEX_PLAN_LABELS.get(raw_plan, raw_plan or "") if provider_id == "codex" else raw_plan or ""), + stale=now - observed > RUST_QUOTA_STALE_SECONDS) + if result["stale"]: + result["notice"] = "Last known reading · The Rust proxy's quota cache is more than 10 minutes old." + except ProviderFailure as exc: + result.update(status="error", errorKind=exc.kind, message=exc.message) return result def fetch_cliproxy_account(provider_id: str, entry: dict[str, Any], client: CliProxyClient, timeout: float) -> dict[str, Any]: + if entry.get("_rust") is True: + return fetch_rust_account(provider_id, entry) result = cliproxy_account_record(provider_id, entry) if entry.get("disabled") is True or entry.get("status") == "disabled": result.update(status="disabled", notice="This account is paused in CLIProxyAPI.") @@ -1104,12 +1241,15 @@ def fetch_cliproxy_account(provider_id: str, entry: dict[str, Any], client: CliP def collect_cliproxy(provider_ids: list[str], timeout: float, address: str, key_path: Path, - discover: bool = False) -> list[dict[str, Any]]: + discover: bool = False, metadata: dict[str, Any] | None = None) -> list[dict[str, Any]]: if not provider_ids and not discover: return [] + overall_deadline = time.monotonic() + timeout if discover else None try: client = CliProxyClient(address, read_cliproxy_key(key_path)) entries = client.auth_files(timeout) + if metadata is not None: + metadata["proxyImplementation"] = client.implementation if client.implementation != "auto" else "go" except ProviderFailure as exc: if discover: provider_ids = ["cliproxy"] @@ -1121,7 +1261,7 @@ def collect_cliproxy(provider_ids: list[str], timeout: float, address: str, key_ provider_ids = sorted(provider for provider in discovered if re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", provider)) if len(provider_ids) > MAX_CLIPROXY_PROVIDERS: provider_ids = provider_ids[:MAX_CLIPROXY_PROVIDERS] - discovery_deadline = time.monotonic() + timeout if discover else None + discovery_deadline = overall_deadline if discover else None def fetch(provider_id: str) -> dict[str, Any]: matching = [entry for entry in entries @@ -1141,11 +1281,13 @@ def account(entry: dict[str, Any]) -> dict[str, Any]: with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: readings = list(pool.map(account, matching[:MAX_CLIPROXY_ACCOUNTS])) - successful = [reading for reading in readings if reading["status"] == "ok"] + successful = [reading for reading in readings if reading["status"] == "ok" and not reading.get("stale")] # A rotating pool still has capacity when one account is exhausted. # Select by the binding window, never sum unrelated percentages. active = [reading for reading in readings if reading["status"] != "disabled"] - best = dict(max(successful, key=lambda reading: 100 - used if (used := dynamic_window_used(reading)) is not None else -1) if successful else (active or readings)[0]) + stale = [reading for reading in active if reading["status"] == "ok"] + candidates = successful or stale + best = dict(max(candidates, key=lambda reading: 100 - used if (used := dynamic_window_used(reading)) is not None else -1) if candidates else (active or readings)[0]) if discover: best["accounts"] = readings best["accountCount"] = len(matching) @@ -1153,6 +1295,7 @@ def account(entry: dict[str, Any]) -> dict[str, Any]: notes = [best["notice"]] if best["notice"] else [] if len(matching) > 1 and best["status"] not in ("disabled", "unsupported"): notes.append(f"{len(successful)} of {len(matching)} accounts checked successfully. Showing the account with the most remaining quota." if successful + else "Only last-known quota readings are available." if stale else f"None of {len(matching)} accounts could be checked.") if len(matching) > MAX_CLIPROXY_ACCOUNTS: notes.append(f"Only the first {MAX_CLIPROXY_ACCOUNTS} accounts were checked.") @@ -1257,11 +1400,14 @@ def update_and_attach_history( samples = [row for row in history["providers"].get(provider_id, []) if cutoff <= row[0] <= stamp + 300] provider = by_id.get(provider_id) used = dynamic_window_used(provider) if provider else None + observation = int(provider.get("quotaUpdatedAt", stamp)) if provider else stamp + if provider and provider.get("proxyImplementation") == "rust" and (provider.get("stale") or (samples and observation <= samples[-1][0])): + used = None if used is not None: - if samples and stamp - samples[-1][0] < 60: - samples[-1] = [stamp, round(used, 2)] + if samples and observation - samples[-1][0] < 60: + samples[-1] = [observation, round(used, 2)] else: - samples.append([stamp, round(used, 2)]) + samples.append([observation, round(used, 2)]) changed = True samples = samples[-HISTORY_MAX_SAMPLES:] history["providers"][provider_id] = samples @@ -1323,8 +1469,9 @@ def build_payload( source: str = "direct", cliproxy_url: str = "http://127.0.0.1:8317", key_file: Path | None = None, ) -> dict[str, Any]: + metadata: dict[str, Any] = {} if source == "cliproxy": - providers = collect_cliproxy(provider_ids, timeout, cliproxy_url, key_file or cliproxy_key_path(), discover=True) + providers = collect_cliproxy(provider_ids, timeout, cliproxy_url, key_file or cliproxy_key_path(), discover=True, metadata=metadata) try: server = normalize_cliproxy_url(cliproxy_url) except ProviderFailure: @@ -1339,6 +1486,7 @@ def build_payload( "generatedAt": now_iso(), "providers": providers, "source": source, + **metadata, } diff --git a/tests/model-usage/fixtures/cliproxy-rust-accounts.json b/tests/model-usage/fixtures/cliproxy-rust-accounts.json new file mode 100644 index 00000000..5cdb4f11 --- /dev/null +++ b/tests/model-usage/fixtures/cliproxy-rust-accounts.json @@ -0,0 +1,6 @@ +[ + {"id":"file:claude-one.json","file":"claude-one.json","provider":"claude","kind":"oauth","disabled":false,"email":"one@example.invalid","label":"Claude One","last_used":"2030-01-01T00:00:30.123456789Z","quota":{"updated_at":"2030-01-01T00:00:00Z","windows":[{"name":"5h","used":25,"resets_at":"2030-01-01T05:00:00Z"},{"name":"week","used":50,"resets_at":"2030-01-08T00:00:00Z"},{"name":"week opus","model":"opus","used":70,"resets_at":"2030-01-08T00:00:00Z"}]}}, + {"id":"file:claude-two.json","file":"claude-two.json","provider":"claude","kind":"oauth","disabled":true,"label":"Paused","last_used":"2030-01-01T00:00:31Z","quota":{"updated_at":null,"windows":[]}}, + {"id":"file:codex-one.json","file":"codex-one.json","provider":"codex","kind":"oauth","disabled":false,"email":"codex@example.invalid","last_used":"2030-01-01T00:00:25Z","quota":{"updated_at":"2030-01-01T00:00:00Z","plan":"pro","windows":[{"name":"week","used":0,"resets_at":"2030-01-08T00:00:00Z"}]}}, + {"id":"gemini:opaque","file":null,"provider":"gemini","kind":"api-key","disabled":false,"label":"API key","last_used":null,"quota":{"updated_at":null,"windows":[]}} +] diff --git a/tests/model-usage/manifest.json b/tests/model-usage/manifest.json index 56ba6673..1621a52a 100644 --- a/tests/model-usage/manifest.json +++ b/tests/model-usage/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "digitalpals.model-usage", "name": "Model Usage", - "version": "1.1.1", + "version": "1.2.0", "author": "DigitalPals", "license": "MIT", "description": "Track Claude Code, OpenAI Codex, and Kimi Code subscription limits from your Omarchy menu bar. Connect local CLIs or CLIProxyAPI to monitor multiple accounts, remaining quotas, reset times, and credits. Explore 24-hour, 7-day, and 30-day token history and estimated API costs from local Codex/Claude transcripts and remote T3 Code servers, with cache-aware pricing, custom model rates, and provider/model breakdowns. Includes private account labels, quota history, and keyboard-friendly settings.", @@ -58,7 +58,7 @@ "cliproxy" ], "defaultValue": "direct", - "description": "Read local CLI sign-ins directly or query accounts managed by CLIProxyAPI. Costs has its own source setting." + "description": "Read local CLI sign-ins or query CLIProxyAPI and CLIProxyAPI-Rust accounts. Proxy implementations are detected automatically. Costs has its own source setting." }, { "key": "cliproxyUrl", @@ -192,7 +192,7 @@ "type": "string", "label": "CPA Usage Keeper URL", "defaultValue": "", - "description": "Optional Keeper URL for last-used quota account activity. Not used for Costs." + "description": "Optional Keeper URL for Go proxy account activity. Rust proxies report activity directly. Not used for Costs." }, { "key": "costKeeperPasswordFile", diff --git a/tests/model-usage/test_cliproxy_rust.py b/tests/model-usage/test_cliproxy_rust.py new file mode 100644 index 00000000..5e834da5 --- /dev/null +++ b/tests/model-usage/test_cliproxy_rust.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import copy +import importlib.util +import json +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from contextlib import contextmanager +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from unittest import mock + +from test_backend import ROOT, BACKEND, fixture, usage + +NOW = 1893456060 + + +def account(**overrides): + return dict(fixture("cliproxy-rust-accounts.json")[0], _rust=True, **overrides) + + +@contextmanager +def proxy_server(document=None, legacy_status=401, native_status=200): + requests = [] + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_GET(self): + requests.append((self.command, self.path)) + if self.headers.get("Authorization") != "Bearer test-management-key": + status, payload = 401, {"error": "unauthorized"} + elif self.path == "/v0/management/auth-files": + status, payload = legacy_status, {"error": "unsupported"} + elif self.path == "/api/accounts": + status, payload = native_status, document if document is not None else fixture("cliproxy-rust-accounts.json") + else: + status, payload = 404, {} + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(payload).encode()) + + def do_POST(self): + requests.append((self.command, self.path)) + self.send_error(500, "Actions must not be called") + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", requests + finally: + server.shutdown() + server.server_close() + thread.join() + + +class RustQuotaTests(unittest.TestCase): + def setUp(self): + patch = mock.patch.object(usage.time, "time", return_value=NOW) + patch.start() + self.addCleanup(patch.stop) + + def test_windows_plan_and_unavailable_credits(self): + row = usage.fetch_rust_account("claude", account()) + self.assertEqual(row["status"], "ok") + self.assertEqual([w["remaining"] for w in row["windows"]], [75, 50, 30]) + self.assertEqual([w["windowSeconds"] for w in row["windows"][:2]], [18000, 604800]) + self.assertIn("Opus", row["windows"][2]["label"]) + self.assertEqual(row["quotaUpdatedAt"], NOW - 60) + self.assertFalse(row["stale"]) + self.assertFalse(row["supportsBankedReset"]) + self.assertIsNone(row["credits"]) + codex = dict(fixture("cliproxy-rust-accounts.json")[2], _rust=True) + row = usage.fetch_rust_account("codex", codex) + self.assertEqual(row["planType"], "pro") + self.assertEqual(row["plan"], "ChatGPT Pro") + self.assertEqual(len(row["windows"]), 1) + self.assertEqual(row["windows"][0]["used"], 0) + + def test_missing_cache_expired_windows_and_invalid_times_do_not_claim_capacity(self): + for quota in ({"windows": [], "updated_at": None}, + {"windows": [], "updated_at": "2030-01-01T00:00:00Z"}, + {"windows": [{"name": "5h", "used": 80, "resets_at": "2029-12-31T00:00:00Z"}], "updated_at": "2030-01-01T00:00:00Z"}, + {"windows": [{"name": "5h", "used": 80}], "updated_at": "2030-01-01T00:00:00"}, + {"windows": [{"name": "5h", "used": 80}], "updated_at": "2031-01-01T00:00:00Z"}): + with self.subTest(quota=quota): + row = usage.fetch_rust_account("claude", account(quota=quota)) + self.assertEqual(row["status"], "error") + self.assertEqual(row["windows"], []) + self.assertIsNone(row["credits"]) + + def test_invalid_quota_numbers_windows_and_metadata_are_errors(self): + for change in ({"used": True}, {"used": "20"}, {"used": -1}, {"used": 101}, + {"used": float("nan")}, {"used": float("inf")}, {"used": None}, + {"model": {}}, {"resets_at": "bad"}, {"name": ""}): + quota = copy.deepcopy(account()["quota"]) + quota["windows"][0].update(change) + with self.subTest(change=change): + self.assertEqual(usage.fetch_rust_account("claude", account(quota=quota))["errorKind"], "malformed") + for change in ({"plan": {}}, {"windows": [{}] * 129}, {"windows": [account()["quota"]["windows"][0]] * 2}): + quota = dict(account()["quota"], **change) + self.assertEqual(usage.fetch_rust_account("claude", account(quota=quota))["status"], "error") + + def test_disabled_and_unqueried_providers_stay_visible(self): + self.assertEqual(usage.fetch_rust_account("claude", account(disabled=True))["status"], "disabled") + self.assertEqual(usage.fetch_rust_account("gemini", account(provider="gemini"))["status"], "unsupported") + self.assertEqual(usage.fetch_rust_account("codex", account(kind="api-key"))["status"], "unsupported") + + def test_stale_cache_and_repeated_observations_do_not_extend_history(self): + row = usage.fetch_rust_account("claude", account()) + with tempfile.TemporaryDirectory() as temporary: + state = Path(temporary) + usage.update_and_attach_history([row], state, now=NOW) + original = (state / "history.json").read_text() + usage.update_and_attach_history([row], state, now=NOW + 120) + self.assertEqual((state / "history.json").read_text(), original) + with mock.patch.object(usage.time, "time", return_value=NOW + 601): + stale = usage.fetch_rust_account("claude", account()) + self.assertTrue(stale["stale"]) + usage.update_and_attach_history([stale], state, now=NOW + 601) + self.assertEqual((state / "history.json").read_text(), original) + self.assertEqual(stale["fetchedAt"], row["fetchedAt"]) + + +class RustIntegrationTests(unittest.TestCase): + def test_legacy_auth_failures_and_missing_paths_detect_rust_with_same_key(self): + for status in (401, 403, 404, 405): + with self.subTest(status=status), proxy_server(legacy_status=status) as (url, requests): + client = usage.CliProxyClient(url, "test-management-key") + rows = client.auth_files(2) + self.assertEqual(client.implementation, "rust") + self.assertEqual(len(rows), 4) + self.assertEqual(rows[0]["id"], "claude-one.json") + self.assertEqual(usage.cliproxy_account_record("claude", rows[0])["accountId"], + usage.cliproxy_account_record("claude", {"id": "claude-one.json"})["accountId"]) + client.auth_files(2) + self.assertEqual(requests[-1], ("GET", "/api/accounts")) + with self.assertRaises(usage.ProviderFailure): + client.usage(rows[0], "https://upstream.invalid/action", {}, 1, data={}) + self.assertTrue(all(method == "GET" for method, _ in requests)) + + def test_wrong_key_and_invalid_native_shape_never_succeed(self): + with proxy_server() as (url, requests): + client = usage.CliProxyClient(url, "wrong-key") + with self.assertRaises(usage.ProviderFailure) as error: + client.auth_files(2) + self.assertEqual(error.exception.kind, "config") + self.assertEqual(client.implementation, "auto") + for document in ({"files": []}, [None], [{"id": "x", "provider": "codex"}], + [dict(account(), disabled="false")], [dict(account(), file="mismatch.json")], + [account(), account()], [{}] * 4097): + with self.subTest(document=str(document)[:80]), proxy_server(document=document) as (url, requests): + client = usage.CliProxyClient(url, "test-management-key") + with self.assertRaises(usage.ProviderFailure) as error: + client.auth_files(2) + self.assertEqual(error.exception.kind, "malformed") + self.assertEqual(client.implementation, "auto") + + def test_empty_inventory_is_valid_and_legacy_outages_are_not_reinterpreted(self): + with proxy_server(document=[]) as (url, requests): + client = usage.CliProxyClient(url, "test-management-key") + self.assertEqual(client.auth_files(2), []) + self.assertEqual(client.implementation, "rust") + with proxy_server(legacy_status=500) as (url, requests): + with self.assertRaises(usage.ProviderFailure): + usage.CliProxyClient(url, "test-management-key").auth_files(2) + self.assertEqual(requests, [("GET", "/v0/management/auth-files")]) + + def test_detection_shares_a_deadline(self): + client = usage.CliProxyClient("https://proxy.invalid", "key") + failure = usage.ProviderFailure("config", "Rejected") + failure.http_status = 401 + with mock.patch.object(client, "request", side_effect=failure) as request, \ + mock.patch.object(usage.time, "monotonic", side_effect=[100, 102]): + with self.assertRaises(usage.ProviderFailure) as error: + client.auth_files(1) + self.assertEqual(error.exception.kind, "timeout") + self.assertEqual(request.call_count, 1) + + def test_native_response_limit_is_enforced(self): + with proxy_server(document=[{"padding": "x" * (usage.MAX_HTTP_RESPONSE_BYTES + 1)}]) as (url, requests): + with self.assertRaises(usage.ProviderFailure) as error: + usage.CliProxyClient(url, "test-management-key").auth_files(2) + self.assertEqual(error.exception.kind, "malformed") + + def test_subprocess_discovery_and_native_activity_only_read_account_api(self): + document = fixture("cliproxy-rust-accounts.json") + now = time.time() + for row in document: + row["last_used"] = datetime.fromtimestamp(now - 30, timezone.utc).isoformat() if row["last_used"] else None + if row["quota"]["updated_at"]: + row["quota"]["updated_at"] = datetime.fromtimestamp(now - 20, timezone.utc).isoformat() + with proxy_server(document=document) as (url, requests), tempfile.TemporaryDirectory() as temporary: + key = Path(temporary) / "key" + key.write_text("test-management-key") + key.chmod(0o600) + args = ["--cliproxy-url", url, "--cliproxy-key-file", str(key), "--timeout", "2"] + output = subprocess.check_output([sys.executable, "-B", str(BACKEND), "--source", "cliproxy", + "--state-dir", temporary, *args], text=True) + result = json.loads(output) + self.assertEqual(result["proxyImplementation"], "rust") + self.assertEqual(len(result["providers"]), 3) + self.assertEqual(next(p for p in result["providers"] if p["id"] == "codex")["status"], "ok") + activity = json.loads(subprocess.check_output([sys.executable, "-B", str(ROOT / "scripts/proxy-activity.py"), *args])) + self.assertEqual(activity["error"], "") + self.assertEqual(len(activity["providers"]), 2) + self.assertEqual(next(p for p in activity["providers"] if p["id"] == "claude")["status"], "ambiguous") + self.assertNotIn("test-management-key", output) + self.assertNotIn("file:", output) + self.assertTrue(all(method == "GET" and path in ("/api/accounts", "/v0/management/auth-files") for method, path in requests)) + + def test_reset_script_rejects_native_account_before_any_action(self): + document = fixture("cliproxy-rust-accounts.json") + with proxy_server(document=document) as (url, requests), tempfile.TemporaryDirectory() as temporary: + key = Path(temporary) / "key" + key.write_text("test-management-key") + key.chmod(0o600) + account_id = usage.cliproxy_account_record("codex", {"id": "codex-one.json"})["accountId"] + args = [sys.executable, "-B", str(ROOT / "scripts/reset-credit.py"), "--action", "prepare", + "--cliproxy-url", url, "--cliproxy-key-file", str(key), "--account-id", account_id] + result = json.loads(subprocess.check_output(args)) + self.assertFalse(result["ok"]) + self.assertIn("unavailable through CLIProxyAPI-Rust", result["message"]) + self.assertTrue(all(method == "GET" for method, _ in requests)) + + +class RustActivityTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location("rust_activity_test", ROOT / "scripts/proxy-activity.py") + cls.activity = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.activity) + + def test_activity_is_private_preserves_paused_latest_and_clears_after_restart(self): + entries = [dict(row, _rust=True) for row in fixture("cliproxy-rust-accounts.json")] + result = self.activity.normalize_rust_activity(entries, now=NOW) + claude = next(p for p in result if p["id"] == "claude") + self.assertEqual(claude["accountId"], self.activity.usage.cliproxy_account_record("claude", entries[1])["accountId"]) + self.assertNotIn("example.invalid", json.dumps(result)) + self.assertNotIn("claude-two.json", json.dumps(result)) + for entry in entries: + entry["last_used"] = None + self.assertEqual(self.activity.normalize_rust_activity(entries, now=NOW), []) + + def test_bad_activity_timestamps_are_not_accepted(self): + for value in ("2030-01-01T00:00:00", "bad", "2031-01-01T00:00:00Z", 123): + with self.subTest(value=value), self.assertRaises(self.activity.usage.ProviderFailure): + self.activity.normalize_rust_activity([account(last_used=value)], now=NOW) + + def test_activity_timestamps_accept_rust_fractional_precision(self): + for fraction in ("1", "12", "123", "1234", "12345", "123456", "123456789"): + for timestamp in (f"2030-01-01T00:00:30.{fraction}Z", + f"2030-01-01T01:00:30.{fraction}+01:00"): + with self.subTest(timestamp=timestamp): + expected = NOW - 30 + int((fraction + "000000")[:6]) / 1_000_000 + self.assertEqual(self.activity.usage.rust_timestamp(timestamp, observed=True, now=NOW), expected) diff --git a/tests/model-usage/test_usage_logic.js b/tests/model-usage/test_usage_logic.js index 041b51a0..4faaca5b 100644 --- a/tests/model-usage/test_usage_logic.js +++ b/tests/model-usage/test_usage_logic.js @@ -121,6 +121,17 @@ const resetStale = context.preserveProxyReadings({ source: "cliproxy", providers { source: "cliproxy", providers: [{ ...resetFailure, accounts: [resetFailure] }] }); assert.equal(context.accountResetLabel(resetStale.providers[0].accounts[0]), "2 banked resets"); assert.equal(resetStale.providers[0].accounts[0].stale, true); +const goSnapshot = { source: "cliproxy", proxyImplementation: "go", + providers: [{ ...resetAccount, proxyImplementation: "go", accounts: [{ ...resetAccount, proxyImplementation: "go" }] }] }; +const nativeFailure = { ...resetFailure, windows: [], proxyImplementation: "rust", supportsBankedReset: false }; +const rustSnapshot = context.preserveProxyReadings(goSnapshot, + { source: "cliproxy", proxyImplementation: "rust", providers: [{ ...nativeFailure, accounts: [nativeFailure] }] }); +assert.equal(rustSnapshot.providers[0].accounts[0].credits, null, "implementation changes never restore Go reset credits"); +assert.equal(rustSnapshot.providers[0].accounts[0].windows.length, 0); +const malformedNative = context.preserveProxyReadings( + { source: "cliproxy", providers: [{ ...priorAccount, proxyImplementation: "rust" }] }, + { source: "cliproxy", providers: [{ ...failedAccount, proxyImplementation: "rust", errorKind: "malformed" }] }); +assert.equal(malformedNative.providers[0].status, "error", "invalid native data cannot become a successful retained reading"); const customRows = [{model: " vendor/Model[1m] ", inputCostPerMillionTokens: "2", outputCostPerMillionTokens: "8", cacheReadCostPerMillionTokens: "0", cacheWriteCostPerMillionTokens: ""}];