Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions docs/cliproxy-rust-model-usage-research.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions image/desktop_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 2 additions & 10 deletions image/package
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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-")
Expand Down
19 changes: 18 additions & 1 deletion image/test_desktop_payload.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions roles/desktop/files/quickshell/ModelUsage/Panel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion roles/desktop/files/quickshell/ModelUsage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions roles/desktop/files/quickshell/ModelUsage/ResetBackend.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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…" : ""
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions roles/desktop/files/quickshell/ModelUsage/UsageBackend.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions roles/desktop/files/quickshell/ModelUsage/UsageLogic.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions roles/desktop/files/quickshell/ModelUsage/UsageSettings.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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." }
}
}

Expand Down Expand Up @@ -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." }
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
Loading
Loading