add model-usage plugin

This commit is contained in:
Ryan Hughes
2026-05-27 16:44:24 -04:00
parent dfc450f2cb
commit 752ecaf6e7
12 changed files with 3011 additions and 4 deletions
+542
View File
@@ -0,0 +1,542 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "providers"
Item {
id: root
visible: false
property var settings: ({})
Claude {
id: claudeProvider
enabled: root.providerEnabled("claude")
providerSettings: root.settings && root.settings.providers && root.settings.providers.claude ? root.settings.providers.claude : ({})
onLastRefreshedAtMsChanged: root.scheduleSync()
onReadyChanged: root.scheduleSync()
}
Codex {
id: codexProvider
enabled: root.providerEnabled("codex")
providerSettings: root.settings && root.settings.providers && root.settings.providers.codex ? root.settings.providers.codex : ({})
onLastRefreshedAtMsChanged: root.scheduleSync()
onReadyChanged: root.scheduleSync()
}
property var providers: [claudeProvider, codexProvider]
property var enabledProviders: {
var rev = syncRevision
var running = syncRunning
var result = []
if (claudeProvider.enabled) result.push(displayProvider(claudeProvider))
if (codexProvider.enabled) result.push(displayProvider(codexProvider))
return result
}
property int activeIndex: 0
property var activeProvider: enabledProviders.length > 0 ? enabledProviders[Math.min(activeIndex, enabledProviders.length - 1)] : null
property bool refreshing: claudeProvider.refreshing || codexProvider.refreshing || syncRunning
property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0
property double lastRefreshedAtMs: Math.max(aggregateUpdatedAtMs, claudeProvider.lastRefreshedAtMs || 0, codexProvider.lastRefreshedAtMs || 0)
property string barDisplayMode: setting("barDisplayMode", "active")
property int barCycleIntervalSec: Math.max(1, Number(setting("barCycleIntervalSec", 5)))
property string barMetric: setting("barMetric", "prompts")
property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900)))
property var syncModeSetting: setting("syncMode", setting("syncEnabled", false))
property bool syncEnabled: parseSyncEnabled(syncModeSetting)
property string syncDir: String(setting("syncDir", ""))
property string syncFileName: String(setting("syncFileName", ""))
property string syncDeviceId: String(setting("syncDeviceId", ""))
readonly property string home: Quickshell.env("HOME") || ""
property string detectedHostname: ""
readonly property string syncEffectiveDir: expandPath(syncDir)
readonly property string syncEffectiveFileName: safeSnapshotFileName(syncFileName, syncDeviceId)
readonly property string syncEffectiveDeviceId: safeDeviceId(syncDeviceId || syncEffectiveFileName.replace(/\.json$/i, ""))
readonly property string syncSnapshotPath: syncConfigured() ? syncEffectiveDir + "/" + syncEffectiveFileName : home + "/.cache/omarchy/model-usage-disabled.json"
property var aggregateData: ({})
property int syncRevision: 0
property bool syncRunning: false
property bool syncRequestedWhileRunning: false
property string syncStatusText: ""
property int syncDeviceCount: syncConfigured() && aggregateData && aggregateData.deviceCount ? Number(aggregateData.deviceCount) : 0
onSyncEnabledChanged: syncSettingsChanged()
onSyncDirChanged: syncSettingsChanged()
onSyncFileNameChanged: if (syncConfigured()) scheduleSync()
onSyncDeviceIdChanged: if (syncConfigured()) scheduleSync()
Component.onCompleted: if (syncConfigured()) scheduleSync()
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
Timer {
interval: root.barCycleIntervalSec * 1000
running: root.barDisplayMode === "cycle" && root.enabledProviders.length > 1
repeat: true
onTriggered: root.activeIndex = (root.activeIndex + 1) % root.enabledProviders.length
}
Timer {
interval: root.refreshIntervalSec * 1000
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refreshAll()
}
Timer {
id: syncDebounce
interval: 1000
repeat: false
onTriggered: root.runSync()
}
Process {
id: syncMkdirProcess
running: false
onRunningChanged: root.updateSyncRunning()
onExited: function(exitCode) {
if (exitCode !== 0) {
if (root.syncConfigured()) root.syncStatusText = "Usage sync mkdir failed"
root.finishSyncRun()
return
}
root.writeSyncSnapshot()
}
}
Process {
id: syncScanProcess
running: false
onRunningChanged: root.updateSyncRunning()
onExited: function(exitCode) {
if (exitCode !== 0 && root.syncConfigured()) root.syncStatusText = "Usage sync scan failed"
root.finishSyncRun()
}
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.parseSyncScanOutput(text)
}
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: if (text.trim() !== "") console.warn("model-usage/sync", text.trim())
}
}
FileView {
id: syncSnapshotFile
path: root.syncSnapshotPath
watchChanges: false
atomicWrites: true
printErrors: false
}
FileView {
id: hostnameFile
path: "/etc/hostname"
watchChanges: false
printErrors: false
onLoaded: root.detectedHostname = String(text() || "").trim()
}
onEnabledProvidersChanged: {
if (enabledProviders.length === 0 || activeIndex >= enabledProviders.length) activeIndex = 0
}
function providerEnabled(id) {
if (!settings || !settings.providers || !settings.providers[id]) return id === "claude" || id === "codex"
return settings.providers[id].enabled !== false
}
function parseSyncEnabled(value) {
if (value === true) return true
var text = String(value || "").trim().toLowerCase()
return text === "on" || text === "enabled" || text === "true" || text === "yes" || text === "1"
}
function syncConfigured() {
return root.syncEnabled === true && String(root.syncDir || "").trim() !== ""
}
function syncSettingsChanged() {
if (syncConfigured()) {
scheduleSync()
} else {
syncDebounce.stop()
syncRequestedWhileRunning = false
aggregateData = ({})
syncStatusText = ""
syncRevision++
}
}
function updateSyncRunning() {
root.syncRunning = syncMkdirProcess.running || syncScanProcess.running
}
function scheduleSync() {
if (!syncConfigured()) return
syncDebounce.restart()
}
function runSync() {
if (!syncConfigured()) return
if (root.syncRunning) {
syncRequestedWhileRunning = true
return
}
syncRequestedWhileRunning = false
syncStatusText = ""
syncMkdirProcess.command = ["mkdir", "-p", root.syncEffectiveDir]
syncMkdirProcess.running = true
}
function writeSyncSnapshot() {
if (!syncConfigured()) {
finishSyncRun()
return
}
syncSnapshotFile.setText(JSON.stringify(localSnapshot(), null, 2) + "\n")
Qt.callLater(root.startSyncScan)
}
function startSyncScan() {
if (!syncConfigured()) {
finishSyncRun()
return
}
var script = "dir=$0; [[ -d \"$dir\" ]] || exit 0; shopt -s nullglob; for f in \"$dir\"/*.json; do [[ -f \"$f\" ]] || continue; printf '===%s===\\n' \"$f\"; cat \"$f\"; printf '\\n=== EOM ===\\n'; done"
syncScanProcess.command = ["bash", "-c", script, root.syncEffectiveDir]
syncScanProcess.running = true
}
function finishSyncRun() {
if (syncRequestedWhileRunning && syncConfigured()) {
syncRequestedWhileRunning = false
scheduleSync()
}
}
function expandPath(path) {
var value = String(path || "").trim()
if (value === "") return ""
if (value === "~") return home
if (value.indexOf("~/") === 0) return home + value.substring(1)
if (value.indexOf("$HOME/") === 0) return home + value.substring(5)
if (value.charAt(0) !== "/") return home + "/" + value
return value
}
function safeDeviceId(raw) {
var value = String(raw || "").trim()
if (value === "") value = Quickshell.env("HOSTNAME") || root.detectedHostname || Quickshell.env("HOST") || Quickshell.env("USER") || "device"
value = value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "")
if (value === "") value = "device"
return value.length > 80 ? value.substring(0, 80) : value
}
function safeSnapshotFileName(rawFileName, rawDeviceId) {
var value = String(rawFileName || "").trim()
if (value === "") value = safeDeviceId(rawDeviceId) + ".json"
value = value.split("/").pop().replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "")
if (value === "") value = safeDeviceId(rawDeviceId) + ".json"
if (!/\.json$/i.test(value)) value += ".json"
return value.length > 100 ? value.substring(0, 95) + ".json" : value
}
function parseSyncScanOutput(output) {
var lines = String(output || "").split("\n")
var snapshots = []
var currentPath = ""
var currentJson = []
function flush() {
if (currentPath === "") return
var raw = currentJson.join("\n").trim()
try {
var parsed = JSON.parse(raw)
if (parsed && parsed.providers) snapshots.push(parsed)
} catch (e) {
console.warn("model-usage/sync", "Ignoring bad snapshot", currentPath, e)
}
currentPath = ""
currentJson = []
}
for (var i = 0; i < lines.length; i++) {
var line = lines[i]
var start = line.match(/^===(.+)===$/)
if (start && line !== "=== EOM ===") {
flush()
currentPath = start[1]
currentJson = []
continue
}
if (line === "=== EOM ===") {
flush()
continue
}
if (currentPath !== "") currentJson.push(line)
}
flush()
aggregateData = aggregateSnapshots(snapshots)
syncStatusText = ""
syncRevision++
}
function cloneValue(value, fallback) {
if (value === undefined || value === null) return fallback
try {
return JSON.parse(JSON.stringify(value))
} catch (e) {
return fallback
}
}
function numberValue(value) {
var n = Number(value || 0)
return isFinite(n) ? Math.round(n) : 0
}
function dateString(date) {
var y = date.getFullYear()
var m = String(date.getMonth() + 1).padStart(2, "0")
var d = String(date.getDate()).padStart(2, "0")
return y + "-" + m + "-" + d
}
function recentDateStrings() {
var result = []
for (var offset = 6; offset >= 0; offset--) {
var date = new Date()
date.setDate(date.getDate() - offset)
result.push(dateString(date))
}
return result
}
function emptyTokenBucket() {
return { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }
}
function addObjectNumbers(target, source) {
if (!source) return
for (var key in source) target[key] = numberValue(target[key]) + numberValue(source[key])
}
function aggregateSnapshots(snapshots) {
var dates = recentDateStrings()
var devices = {}
var providers = {}
function providerAcc(id) {
if (providers[id]) return providers[id]
var recentByDay = {}
for (var d = 0; d < dates.length; d++) recentByDay[dates[d]] = 0
providers[id] = {
providerId: id,
providerName: "",
ready: false,
hasLocalStats: false,
todayPrompts: 0,
todaySessions: 0,
todayTotalTokens: 0,
todayTokensByModel: ({}),
recentByDay: recentByDay,
totalPrompts: 0,
totalSessions: 0,
modelUsage: ({}),
devices: ({})
}
return providers[id]
}
for (var i = 0; i < snapshots.length; i++) {
var snapshot = snapshots[i]
var device = safeDeviceId(snapshot.deviceId || "device")
devices[device] = true
var snapshotProviders = snapshot.providers || {}
for (var providerId in snapshotProviders) {
var stats = snapshotProviders[providerId] || {}
var acc = providerAcc(String(providerId))
acc.devices[device] = true
if (stats.providerName && acc.providerName === "") acc.providerName = String(stats.providerName)
acc.ready = acc.ready || stats.ready === true
acc.hasLocalStats = acc.hasLocalStats || stats.hasLocalStats !== false
acc.todayPrompts += numberValue(stats.todayPrompts)
acc.todaySessions += numberValue(stats.todaySessions)
acc.todayTotalTokens += numberValue(stats.todayTotalTokens)
acc.totalPrompts += numberValue(stats.totalPrompts)
acc.totalSessions += numberValue(stats.totalSessions)
addObjectNumbers(acc.todayTokensByModel, stats.todayTokensByModel || {})
var recent = Array.isArray(stats.recentDays) ? stats.recentDays : []
for (var r = 0; r < recent.length; r++) {
var day = recent[r] || {}
var date = String(day.date || "")
if (acc.recentByDay[date] !== undefined) acc.recentByDay[date] += numberValue(day.messageCount)
}
var usage = stats.modelUsage || {}
for (var modelId in usage) {
var bucket = acc.modelUsage[modelId]
if (!bucket) bucket = acc.modelUsage[modelId] = emptyTokenBucket()
var source = usage[modelId] || {}
bucket.inputTokens += numberValue(source.inputTokens)
bucket.outputTokens += numberValue(source.outputTokens)
bucket.cacheReadInputTokens += numberValue(source.cacheReadInputTokens)
bucket.cacheCreationInputTokens += numberValue(source.cacheCreationInputTokens)
}
}
}
var outProviders = {}
for (var id in providers) {
var acc = providers[id]
var recentDays = []
for (var di = 0; di < dates.length; di++) recentDays.push({ date: dates[di], messageCount: acc.recentByDay[dates[di]] || 0 })
var providerDevices = Object.keys(acc.devices).sort()
outProviders[id] = {
providerId: acc.providerId,
providerName: acc.providerName,
ready: acc.ready || providerDevices.length > 0,
hasLocalStats: acc.hasLocalStats,
todayPrompts: acc.todayPrompts,
todaySessions: acc.todaySessions,
todayTotalTokens: acc.todayTotalTokens,
todayTokensByModel: acc.todayTokensByModel,
recentDays: recentDays,
totalPrompts: acc.totalPrompts,
totalSessions: acc.totalSessions,
modelUsage: acc.modelUsage,
deviceCount: providerDevices.length,
devices: providerDevices
}
}
return {
schemaVersion: 1,
updatedAt: new Date().toISOString(),
updatedAtMs: Date.now(),
deviceCount: Object.keys(devices).length,
devices: Object.keys(devices).sort(),
providers: outProviders
}
}
function providerSnapshot(provider) {
return {
providerId: provider.providerId,
providerName: provider.providerName,
ready: provider.ready === true,
hasLocalStats: provider.hasLocalStats !== false,
todayPrompts: numberValue(provider.todayPrompts),
todaySessions: numberValue(provider.todaySessions),
todayTotalTokens: numberValue(provider.todayTotalTokens),
todayTokensByModel: cloneValue(provider.todayTokensByModel, ({})),
recentDays: cloneValue(provider.recentDays, []),
totalPrompts: numberValue(provider.totalPrompts),
totalSessions: numberValue(provider.totalSessions),
modelUsage: cloneValue(provider.modelUsage, ({}))
}
}
function localSnapshot() {
var providerMap = {}
for (var i = 0; i < providers.length; i++) {
var provider = providers[i]
if (provider.enabled) providerMap[provider.providerId] = providerSnapshot(provider)
}
return {
schemaVersion: 1,
deviceId: syncEffectiveDeviceId,
updatedAt: new Date().toISOString(),
providers: providerMap
}
}
function syncedStatsFor(providerId) {
var rev = syncRevision
if (!syncConfigured() || !aggregateData || !aggregateData.providers) return null
return aggregateData.providers[providerId] || null
}
function displayProvider(provider) {
var stats = syncedStatsFor(provider.providerId)
var synced = !!stats
var deviceCount = synced ? Number(stats.deviceCount || aggregateData.deviceCount || 0) : 0
return {
providerId: provider.providerId,
providerName: provider.providerName,
providerIcon: provider.providerIcon,
enabled: provider.enabled,
ready: provider.ready || synced,
refreshing: provider.refreshing || root.syncRunning,
lastRefreshedAtMs: Math.max(provider.lastRefreshedAtMs || 0, root.aggregateUpdatedAtMs || 0),
usageStatusText: provider.usageStatusText,
authHelpText: provider.authHelpText,
rateLimitPercent: provider.rateLimitPercent,
rateLimitLabel: provider.rateLimitLabel,
rateLimitResetAt: provider.rateLimitResetAt,
secondaryRateLimitPercent: provider.secondaryRateLimitPercent,
secondaryRateLimitLabel: provider.secondaryRateLimitLabel,
secondaryRateLimitResetAt: provider.secondaryRateLimitResetAt,
tierLabel: provider.tierLabel,
todayPrompts: synced ? numberValue(stats.todayPrompts) : provider.todayPrompts,
todaySessions: synced ? numberValue(stats.todaySessions) : provider.todaySessions,
todayTotalTokens: synced ? numberValue(stats.todayTotalTokens) : provider.todayTotalTokens,
todayTokensByModel: synced ? (stats.todayTokensByModel || ({})) : provider.todayTokensByModel,
recentDays: synced ? (stats.recentDays || []) : provider.recentDays,
totalPrompts: synced ? numberValue(stats.totalPrompts) : provider.totalPrompts,
totalSessions: synced ? numberValue(stats.totalSessions) : provider.totalSessions,
modelUsage: synced ? (stats.modelUsage || ({})) : provider.modelUsage,
hasLocalStats: synced ? (stats.hasLocalStats !== false) : provider.hasLocalStats,
syncEnabled: synced,
syncDeviceCount: deviceCount,
syncUpdatedAt: aggregateData && aggregateData.updatedAt ? aggregateData.updatedAt : "",
formatResetTime: function(isoTimestamp) { return provider.formatResetTime(isoTimestamp) }
}
}
function refresh() { refreshAll(true) }
function refreshAll(force) {
for (var i = 0; i < providers.length; i++) {
var p = providers[i]
if (p.enabled && typeof p.refresh === "function") p.refresh(force === true)
}
scheduleSync()
}
function formatTokenCount(n) {
if (n === undefined || n === null) return "0"
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B"
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"
return String(n)
}
function friendlyModelName(id) {
if (!id) return "Unknown"
var name = String(id).replace(/^claude-/, "").replace(/-\d{8}$/, "")
var parts = name.split("-")
if (parts.length >= 3) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1) + " " + parts[1] + "." + parts[2]
if (parts.length === 2) return parts[0].charAt(0).toUpperCase() + parts[0].slice(1) + " " + parts[1]
return name.charAt(0).toUpperCase() + name.slice(1)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="257" preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<svg fill="#fff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+46
View File
@@ -0,0 +1,46 @@
{
"schemaVersion": 1,
"id": "omarchy.model-usage",
"name": "Model Usage",
"version": "1.0.0",
"author": "Omarchy",
"license": "MIT",
"description": "Claude Code and Codex usage stats in a native Omarchy bar popup.",
"kinds": ["bar-widget"],
"activation": "on-demand",
"entryPoints": {
"barWidget": "Widget.qml"
},
"barWidget": {
"displayName": "Model Usage",
"description": "Shows AI coding assistant usage stats with a tabbed popup panel.",
"category": "AI",
"aliases": ["model-usage"],
"allowMultiple": false,
"defaults": {
"providers": {
"claude": {
"enabled": true,
"statsPath": "~/.claude/stats-cache.json",
"credentialsPath": "~/.claude/.credentials.json",
"projectsPath": "~/.claude/projects"
},
"codex": {
"enabled": true
}
},
"refreshIntervalSec": 900,
"syncMode": "Off",
"syncDir": "",
"syncFileName": "",
"syncDeviceId": ""
},
"schema": [
{ "key": "refreshIntervalSec", "type": "integer", "label": "Refresh interval (seconds)", "min": 30, "max": 3600, "step": 30, "defaultValue": 900 },
{ "key": "syncMode", "type": "enum", "label": "Synced aggregation", "options": ["Off", "On"], "defaultValue": "Off", "description": "When On, write this machine's local usage snapshot and merge snapshots from other machines." },
{ "key": "syncDir", "type": "path", "label": "Sync folder", "defaultValue": "", "description": "A folder synced by Syncthing, Dropbox, rsync, etc." },
{ "key": "syncFileName", "type": "string", "label": "Snapshot file name", "defaultValue": "", "description": "Optional. Defaults to <hostname>.json. Use a different file name on each machine, such as laptop.json or desktop.json." },
{ "key": "syncDeviceId", "type": "string", "label": "Device id", "defaultValue": "", "description": "Optional stable device name used inside synced aggregate snapshots." }
]
}
}
@@ -0,0 +1,501 @@
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: root
visible: false
property string providerId: "claude"
property string providerName: "Claude Code"
property string providerIcon: "ai"
property bool enabled: false
property bool ready: false
property bool refreshing: false
property double lastRefreshedAtMs: 0
property string usageStatusText: ""
property real rateLimitPercent: -1
property string rateLimitLabel: "Weekly (7-day)"
property string rateLimitResetAt: ""
property real secondaryRateLimitPercent: -1
property string secondaryRateLimitLabel: "Session (5-hour)"
property string secondaryRateLimitResetAt: ""
property int todayPrompts: 0
property int todaySessions: 0
property int todayTotalTokens: 0
property var todayTokensByModel: ({})
property var recentDays: []
property int totalPrompts: 0
property int totalSessions: 0
property var modelUsage: ({})
property var dailyActivity: []
property string tierLabel: ""
property string authHelpText: "Run `claude auth login` to restore authoritative usage."
property bool hasLocalStats: true
property bool hasProjectStats: false
property string oauthAccessToken: ""
property double oauthExpiresAtMs: 0
property string authMode: "none"
property string subscriptionType: ""
property string rateLimitTier: ""
property bool hasAuthoritativeRateLimit: false
property double lastProbeAtMs: 0
property int probeMinIntervalMs: 15 * 60 * 1000
property bool projectScanRerunForce: false
property var providerSettings: ({})
function resolvePath(p) {
if (p && p.startsWith("~"))
return (Quickshell.env("HOME") ?? "/home") + p.substring(1);
return p;
}
function pathFromUrl(url) {
const value = String(url || "");
if (value.indexOf("file://") === 0)
return decodeURIComponent(value.substring(7));
return value;
}
readonly property string projectScannerScriptPath: pathFromUrl(Qt.resolvedUrl("../scripts/claude_usage_scanner.py"))
FileView {
id: statsFile
path: root.resolvePath(root.providerSettings?.statsPath ?? "~/.claude/stats-cache.json")
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: root.parseStats(text())
}
FileView {
id: historyFile
path: root.resolvePath("~/.claude/history.jsonl")
watchChanges: true
onFileChanged: reload()
onLoaded: root.parseHistory(text())
onLoadFailed: error => {
if (error === FileViewError.FileNotFound)
console.error("model-usage/claude", "history.jsonl not found");
}
}
FileView {
id: credentialsFile
path: root.resolvePath(root.providerSettings?.credentialsPath ?? "~/.claude/.credentials.json")
watchChanges: true
onFileChanged: reload()
onLoaded: root.parseCredentials(text())
onLoadFailed: error => {
if (error === FileViewError.FileNotFound)
console.error("model-usage/claude", "credentials.json not found at", credentialsFile.path);
}
}
Process {
id: projectScanner
running: false
command: []
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.applyProjectUsageSummary(text)
}
stderr: StdioCollector {
waitForEnd: true
onStreamFinished: if (text.trim() !== "") console.warn("model-usage/claude", text.trim())
}
onExited: {
root.finishRefresh();
if (root.projectScanRerunForce) {
root.projectScanRerunForce = false;
root.startProjectScanner(true);
}
}
}
Timer {
interval: root.probeMinIntervalMs
running: root.enabled && root.oauthAccessToken !== ""
repeat: true
onTriggered: root.probeRateLimits(false)
}
function localDateString() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
return y + "-" + m + "-" + d;
}
function parseStats(content) {
try {
const data = JSON.parse(content);
const today = localDateString();
const dailyModelTokens = data.dailyModelTokens ?? [];
const todayTokenEntry = dailyModelTokens.find(d => d.date === today);
root.todayTokensByModel = todayTokenEntry?.tokensByModel ?? {};
let tokenSum = 0;
const toks = root.todayTokensByModel;
for (const k in toks)
tokenSum += toks[k];
root.todayTotalTokens = tokenSum;
root.dailyActivity = data.dailyActivity ?? [];
root.recentDays = root.dailyActivity.slice(-7);
root.modelUsage = data.modelUsage ?? {};
root.totalPrompts = data.totalMessages ?? 0;
root.totalSessions = data.totalSessions ?? 0;
root.ready = true;
} catch (e) {
console.error("model-usage/claude", "Failed to parse stats-cache.json:", e);
}
}
function applyProjectUsageSummary(content) {
try {
const data = JSON.parse(String(content || "{}"));
const prompts = Math.max(0, Number(data.totalPrompts || 0));
if (prompts <= 0)
return;
root.hasProjectStats = true;
root.todayPrompts = Math.max(0, Number(data.todayPrompts || 0));
root.todaySessions = Math.max(0, Number(data.todaySessions || 0));
root.todayTotalTokens = Math.max(0, Number(data.todayTotalTokens || 0));
root.todayTokensByModel = data.todayTokensByModel || ({});
root.recentDays = data.recentDays || [];
root.modelUsage = data.modelUsage || ({});
root.totalPrompts = prompts;
root.totalSessions = Math.max(0, Number(data.totalSessions || 0));
root.dailyActivity = data.dailyActivity || root.recentDays;
root.ready = true;
} catch (e) {
console.error("model-usage/claude", "Failed to parse project usage summary:", e);
}
}
function parseHistory(content) {
if (root.hasProjectStats)
return;
try {
const now = new Date();
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const lines = content.split("\n");
let prompts = 0;
const sessions = {};
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (!line)
continue;
try {
const entry = JSON.parse(line);
if ((entry.timestamp ?? 0) < startOfDay)
break;
prompts++;
if (entry.sessionId)
sessions[entry.sessionId] = true;
} catch (e) {
continue;
}
}
root.todayPrompts = prompts;
root.todaySessions = Object.keys(sessions).length;
} catch (e) {
console.error("model-usage/claude", "Failed to parse history.jsonl:", e);
}
}
function parseCredentials(content) {
try {
const data = JSON.parse(content);
const oauth = data.claudeAiOauth ?? {};
const fileAccessToken = oauth.accessToken ?? "";
const fileExpiresAtMs = root.normalizeExpiresAtMs(oauth.expiresAt);
const fileHasOAuth = fileAccessToken !== "";
const tokenChanged = (root.oauthAccessToken !== fileAccessToken || root.oauthExpiresAtMs !== fileExpiresAtMs);
root.oauthAccessToken = fileAccessToken;
root.oauthExpiresAtMs = fileExpiresAtMs;
if (tokenChanged)
root.clearAuthoritativeRateLimits();
root.authMode = fileHasOAuth ? "oauth" : "none";
root.subscriptionType = oauth.subscriptionType ?? "";
root.rateLimitTier = oauth.rateLimitTier ?? "";
root.tierLabel = formatTier();
if (root.oauthAccessToken && !root.oauthTokenExpired()) {
if (root.usageStatusText === "Waiting for auth")
root.clearUsageStatus();
root.probeRateLimits(false);
} else if (!root.oauthAccessToken) {
root.usageStatusText = "Waiting for auth";
root.clearAuthoritativeRateLimits();
} else {
root.clearUsageStatus();
}
} catch (e) {
console.error("model-usage/claude", "Failed to parse credentials.json:", e);
root.usageStatusText = "Waiting for auth";
root.clearAuthoritativeRateLimits();
}
}
function formatTier() {
if (!root.rateLimitTier)
return root.subscriptionType || "";
const match = root.rateLimitTier.match(/max_(\d+x)/i);
if (match)
return "Max " + match[1];
if (root.subscriptionType)
return root.subscriptionType.charAt(0).toUpperCase() + root.subscriptionType.slice(1);
return "";
}
function normalizeExpiresAtMs(value) {
const n = Number(value ?? 0);
return (isFinite(n) && n > 0) ? n : 0;
}
function oauthTokenExpired() {
if (!root.oauthAccessToken)
return true;
if (!root.oauthExpiresAtMs || !(root.oauthExpiresAtMs > 0))
return false;
return root.oauthExpiresAtMs <= Date.now();
}
function clearAuthoritativeRateLimits() {
root.hasAuthoritativeRateLimit = false;
root.rateLimitPercent = -1;
root.rateLimitLabel = "Weekly (7-day)";
root.rateLimitResetAt = "";
root.secondaryRateLimitPercent = -1;
root.secondaryRateLimitLabel = "Session (5-hour)";
root.secondaryRateLimitResetAt = "";
}
function clearUsageStatus() {
root.usageStatusText = "";
}
function parseNumber(value) {
if (value === null || value === undefined)
return NaN;
return parseFloat(String(value).trim().replace("%", ""));
}
function utilizationPayloadUsesPercentScale(values) {
for (let i = 0; i < values.length; i++) {
const n = parseNumber(values[i]);
if (n >= 1)
return true;
}
return false;
}
function normalizeUtilization(value, percentScale) {
const n = parseNumber(value);
if (!(n >= 0))
return -1;
// Anthropic's OAuth usage endpoint currently reports percentages
// (for example 37.0 or 1.0). Older clients/examples sometimes used
// fractions (0.37). Treat a payload containing any value >= 1 as
// percent-scaled so 1.0 renders as 1%, not 100%.
if (percentScale === true || n > 1)
return Math.min(1, n / 100);
return Math.min(1, n);
}
function normalizeResetAt(value) {
if (value === null || value === undefined)
return "";
const raw = String(value).trim();
if (raw === "")
return "";
if (/^\d+$/.test(raw)) {
let ts = parseInt(raw, 10);
if (ts < 1e12)
ts = ts * 1000;
const d = new Date(ts);
if (!isNaN(d.getTime()))
return d.toISOString();
}
const parsed = new Date(raw);
if (!isNaN(parsed.getTime()))
return parsed.toISOString();
return raw;
}
function oauthUsageBucket(payload, key) {
const bucket = payload?.[key];
if (bucket && typeof bucket === "object")
return bucket;
return null;
}
function applyAuthoritativeRateLimits(weekly, weeklyReset, session, sessionReset, sourceLabel) {
const percentScale = root.utilizationPayloadUsesPercentScale([weekly, session]);
const weeklyNorm = root.normalizeUtilization(weekly, percentScale);
const sessionNorm = root.normalizeUtilization(session, percentScale);
if (weeklyNorm < 0 && sessionNorm < 0)
return false;
root.hasAuthoritativeRateLimit = true;
root.rateLimitPercent = -1;
root.rateLimitLabel = "Weekly (7-day)";
root.rateLimitResetAt = "";
root.secondaryRateLimitPercent = -1;
root.secondaryRateLimitLabel = "Session (5-hour)";
root.secondaryRateLimitResetAt = "";
if (weeklyNorm >= 0)
root.rateLimitPercent = weeklyNorm;
if (sessionNorm >= 0)
root.secondaryRateLimitPercent = sessionNorm;
if (weeklyReset !== null && weeklyReset !== undefined)
root.rateLimitResetAt = root.normalizeResetAt(weeklyReset);
if (sessionReset !== null && sessionReset !== undefined)
root.secondaryRateLimitResetAt = root.normalizeResetAt(sessionReset);
if (root.rateLimitPercent < 0 && sessionNorm >= 0) {
root.rateLimitPercent = sessionNorm;
root.rateLimitLabel = root.secondaryRateLimitLabel;
root.rateLimitResetAt = root.secondaryRateLimitResetAt;
}
if (sourceLabel)
root.rateLimitLabel = root.rateLimitLabel + " (" + sourceLabel + ")";
return true;
}
function finishRefresh() {
root.refreshing = false;
root.lastRefreshedAtMs = Date.now();
}
function probeOAuthUsage() {
root.refreshing = true;
const xhr = new XMLHttpRequest();
xhr.open("GET", "https://api.anthropic.com/api/oauth/usage");
xhr.setRequestHeader("Authorization", "Bearer " + root.oauthAccessToken);
xhr.setRequestHeader("anthropic-beta", "oauth-2025-04-20");
xhr.setRequestHeader("Accept", "application/json");
xhr.onreadystatechange = function () {
if (xhr.readyState !== XMLHttpRequest.DONE)
return;
if (xhr.status >= 200 && xhr.status < 300) {
try {
const payload = JSON.parse(xhr.responseText ?? "{}");
const weeklyBucket = root.oauthUsageBucket(payload, "seven_day_oauth_apps") || root.oauthUsageBucket(payload, "seven_day");
const sessionBucket = root.oauthUsageBucket(payload, "five_hour");
if (root.applyAuthoritativeRateLimits(weeklyBucket?.utilization, weeklyBucket?.resets_at, sessionBucket?.utilization, sessionBucket?.resets_at, "")) {
root.clearUsageStatus();
root.finishRefresh();
return;
}
} catch (e) {
console.error("model-usage/claude", "Failed to parse oauth usage response:", e);
}
}
const body = xhr.responseText ? String(xhr.responseText).slice(0, 220) : "";
const retryAfter = xhr.getResponseHeader("retry-after") || "";
console.warn("model-usage/claude", "OAuth usage probe unavailable (status " + xhr.status + ")" + (body ? " body=" + body : ""));
if (!root.hasAuthoritativeRateLimit) {
root.usageStatusText = "Claude limits unavailable";
root.authHelpText = xhr.status === 429
? "Anthropic's usage endpoint is rate limiting checks right now" + (retryAfter ? " (retry after " + retryAfter + "s)" : "") + ". Local Claude Code stats are still shown."
: "Anthropic's usage endpoint returned status " + xhr.status + ". Local Claude Code stats are still shown.";
}
root.finishRefresh();
};
xhr.send();
}
function startProjectScanner(force) {
if (projectScanner.running) {
if (force === true)
root.projectScanRerunForce = true;
return false;
}
const command = ["python3", root.projectScannerScriptPath, root.resolvePath(root.providerSettings?.projectsPath ?? "~/.claude/projects")];
if (force === true)
command.push("--force");
projectScanner.command = command;
projectScanner.running = true;
return true;
}
function refresh(force) {
root.refreshing = true;
statsFile.reload();
historyFile.reload();
credentialsFile.reload();
root.startProjectScanner(force === true);
if (root.oauthAccessToken && root.authMode === "oauth" && !root.oauthTokenExpired())
root.probeRateLimits(force === true);
}
function formatResetTime(isoTimestamp) {
if (!isoTimestamp)
return "";
const reset = new Date(isoTimestamp);
const now = new Date();
const diffMs = reset.getTime() - now.getTime();
if (diffMs <= 0)
return "now";
const hours = Math.floor(diffMs / 3600000);
const mins = Math.floor((diffMs % 3600000) / 60000);
if (hours > 24)
return Math.floor(hours / 24) + "d " + (hours % 24) + "h";
if (hours > 0)
return hours + "h " + mins + "m";
return mins + "m";
}
function probeRateLimits(force) {
if (!root.oauthAccessToken || root.authMode !== "oauth") {
root.usageStatusText = "Waiting for auth";
root.clearAuthoritativeRateLimits();
root.finishRefresh();
return;
}
if (root.oauthTokenExpired()) {
root.clearUsageStatus();
root.finishRefresh();
return;
}
const nowMs = Date.now();
if (force !== true && root.lastProbeAtMs > 0 && (nowMs - root.lastProbeAtMs) < root.probeMinIntervalMs) {
root.finishRefresh();
return;
}
root.lastProbeAtMs = nowMs;
root.probeOAuthUsage();
}
}
@@ -0,0 +1,135 @@
import QtQuick
import Quickshell
import Quickshell.Io
Item {
id: root
visible: false
property string providerId: "codex"
property string providerName: "Codex"
property string providerIcon: "ai"
property bool enabled: false
property bool ready: false
property bool refreshing: false
property double lastRefreshedAtMs: 0
property real rateLimitPercent: -1
property string rateLimitLabel: ""
property string rateLimitResetAt: ""
property real secondaryRateLimitPercent: -1
property string secondaryRateLimitLabel: ""
property string secondaryRateLimitResetAt: ""
property int todayPrompts: 0
property int todaySessions: 0
property real todayTotalTokens: 0
property var todayTokensByModel: ({})
property var recentDays: []
property int totalPrompts: 0
property int totalSessions: 0
property var modelUsage: ({})
property string tierLabel: ""
property string usageStatusText: ""
property string authHelpText: "Run `codex login` to authenticate."
property bool hasLocalStats: true
property string configModel: ""
property var providerSettings: ({})
readonly property string scannerPath: String(Qt.resolvedUrl("../scripts/codex_usage_scanner.py")).replace("file://", "")
Process {
id: usageScanner
command: ["python3", root.scannerPath]
running: false
stdout: StdioCollector {
onStreamFinished: root.parseScannerOutput(text)
}
onExited: root.finishRefresh()
stderr: StdioCollector {
onStreamFinished: if (text.trim() !== "") console.warn("model-usage/codex", text.trim())
}
}
Timer {
interval: 5 * 60 * 1000
running: root.enabled
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
onEnabledChanged: if (enabled) refresh()
function finishRefresh() {
root.refreshing = false
root.lastRefreshedAtMs = Date.now()
}
function refresh(force) {
if (usageScanner.running)
return
root.refreshing = true
usageScanner.running = true
}
function parseScannerOutput(output) {
const raw = String(output || "").trim()
if (raw === "")
return
try {
const data = JSON.parse(raw.split("\n").pop())
root.ready = !!data.ready
root.hasLocalStats = data.hasLocalStats !== false
root.todayPrompts = data.todayPrompts || 0
root.todaySessions = data.todaySessions || 0
root.todayTotalTokens = data.todayTotalTokens || 0
root.todayTokensByModel = data.todayTokensByModel || ({})
root.recentDays = data.recentDays || []
root.totalPrompts = data.totalPrompts || 0
root.totalSessions = data.totalSessions || 0
root.modelUsage = data.modelUsage || ({})
root.rateLimitPercent = data.rateLimitPercent ?? -1
root.rateLimitLabel = data.rateLimitLabel || ""
root.rateLimitResetAt = data.rateLimitResetAt || ""
root.secondaryRateLimitPercent = data.secondaryRateLimitPercent ?? -1
root.secondaryRateLimitLabel = data.secondaryRateLimitLabel || ""
root.secondaryRateLimitResetAt = data.secondaryRateLimitResetAt || ""
root.tierLabel = data.tierLabel || ""
root.usageStatusText = data.usageStatusText || ""
root.authHelpText = data.authHelpText || "Run `codex login` to authenticate."
} catch (e) {
console.error("model-usage/codex", "Failed to parse scanner output:", e, raw)
root.usageStatusText = "Codex scan failed"
root.authHelpText = String(e)
root.ready = true
}
}
function formatResetTime(isoTimestamp) {
if (!isoTimestamp)
return ""
const reset = new Date(isoTimestamp)
const now = new Date()
const diffMs = reset.getTime() - now.getTime()
if (diffMs <= 0)
return "now"
const hours = Math.floor(diffMs / 3600000)
const mins = Math.floor((diffMs % 3600000) / 60000)
if (hours > 24)
return Math.floor(hours / 24) + "d " + (hours % 24) + "h"
if (hours > 0)
return hours + "h " + mins + "m"
return mins + "m"
}
}
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env python3
"""Stream Claude Code project JSONL files and emit compact usage stats.
This replaces the QML-side `rg --json ... | StdioCollector` path, which can
materialize 100MB+ of ripgrep JSON in the Quickshell process. The helper keeps
that work in a short-lived Python process, parses line-by-line, and returns a
single compact JSON object that matches the fields Claude.qml expects.
"""
from __future__ import annotations
import argparse
import datetime as dt
import fcntl
import hashlib
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
def expand_path(value: str) -> Path:
return Path(os.path.expandvars(os.path.expanduser(value))).resolve()
def date_string(value: dt.date) -> str:
return value.strftime("%Y-%m-%d")
def recent_date_strings() -> list[str]:
today = dt.datetime.now().date()
return [date_string(today - dt.timedelta(days=offset)) for offset in range(6, -1, -1)]
def local_date_string() -> str:
return date_string(dt.datetime.now().date())
def local_date_from_timestamp(value: Any) -> str:
if value is None:
return local_date_string()
if isinstance(value, (int, float)):
try:
seconds = float(value) / 1000.0 if float(value) > 10_000_000_000 else float(value)
return date_string(dt.datetime.fromtimestamp(seconds).date())
except Exception:
return local_date_string()
raw = str(value).strip()
if not raw:
return local_date_string()
# Claude JSONL timestamps are usually ISO-8601. Python accepts offsets but
# not a trailing Z until we normalize it to +00:00.
try:
parsed = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
if parsed.tzinfo is not None:
parsed = parsed.astimezone()
return date_string(parsed.date())
except Exception:
return local_date_string()
def usage_token(usage: dict[str, Any], snake_key: str, camel_key: str) -> int:
value = usage.get(snake_key, usage.get(camel_key, 0))
try:
return round(float(value or 0))
except Exception:
return 0
def empty_bucket() -> dict[str, int]:
return {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
}
def iter_jsonl_files(projects_path: Path):
if not projects_path.is_dir():
return
yield from projects_path.rglob("*.jsonl")
def scan(projects_path: Path) -> dict[str, Any]:
today = local_date_string()
recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
seen: set[str] = set()
sessions: set[str] = set()
today_sessions: set[str] = set()
today_tokens: dict[str, int] = {}
usage_by_model: dict[str, dict[str, int]] = {}
prompts = 0
today_prompt_count = 0
today_token_total = 0
malformed_lines = 0
scanned_files = 0
for path in iter_jsonl_files(projects_path) or []:
scanned_files += 1
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, 1):
# Cheap pre-filter before JSON parsing. Matches the old rg
# search and keeps files with unrelated lines inexpensive.
if '"usage":' not in line:
continue
try:
entry = json.loads(line)
except Exception:
malformed_lines += 1
continue
message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
if entry.get("type") != "assistant" and message.get("role") != "assistant":
continue
usage = message.get("usage") or entry.get("usage")
if not isinstance(usage, dict):
continue
message_id = message.get("id") or entry.get("messageId") or ""
unique_key = str(message_id) if message_id else f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}"
if unique_key in seen:
continue
seen.add(unique_key)
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
total = input_tokens + output_tokens + cache_read + cache_write
if total <= 0:
continue
model = str(message.get("model") or entry.get("model") or "claude")
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
session_key = str(entry.get("sessionId") or path)
sessions.add(session_key)
prompts += 1
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
# Preserve existing QML behavior: recentDays.messageCount
# is actually a token total, despite the legacy name.
recent[day]["messageCount"] += total
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
except Exception as exc:
print(f"Ignoring unreadable Claude project file {path}: {exc}", file=sys.stderr)
recent_days = [recent[day] for day in recent_dates]
return {
"schemaVersion": 1,
"todayPrompts": today_prompt_count,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_token_total,
"todayTokensByModel": today_tokens,
"recentDays": recent_days,
"modelUsage": usage_by_model,
"totalPrompts": prompts,
"totalSessions": len(sessions),
"dailyActivity": recent_days,
"scannedFiles": scanned_files,
"malformedLines": malformed_lines,
}
def cache_paths(projects_path: Path) -> tuple[Path, Path]:
cache_root = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "omarchy" / "model-usage"
cache_root.mkdir(parents=True, exist_ok=True)
digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16]
return cache_root / f"claude-projects-{digest}.json", cache_root / f"claude-projects-{digest}.lock"
def read_fresh_cache(path: Path, max_age_seconds: int) -> str | None:
if max_age_seconds <= 0 or not path.exists():
return None
try:
if time.time() - path.stat().st_mtime <= max_age_seconds:
return path.read_text(encoding="utf-8")
except Exception:
return None
return None
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("projects_path", nargs="?", default="~/.claude/projects")
parser.add_argument("--cache-seconds", type=int, default=20)
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
projects_path = expand_path(args.projects_path)
cache_file, lock_file = cache_paths(projects_path)
if not args.force:
cached = read_fresh_cache(cache_file, args.cache_seconds)
if cached is not None:
print(cached, end="" if cached.endswith("\n") else "\n")
return 0
with lock_file.open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
if not args.force:
cached = read_fresh_cache(cache_file, args.cache_seconds)
if cached is not None:
print(cached, end="" if cached.endswith("\n") else "\n")
return 0
summary = scan(projects_path)
output = json.dumps(summary, separators=(",", ":"), sort_keys=True) + "\n"
tmp = cache_file.with_suffix(".json.tmp")
tmp.write_text(output, encoding="utf-8")
tmp.replace(cache_file)
print(output, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,344 @@
import json
import os
import select
import shutil
import subprocess
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
def local_day(value):
if value is None:
return datetime.now().strftime("%Y-%m-%d")
if isinstance(value, (int, float)):
# pi message timestamps are milliseconds; Codex timestamps are usually seconds.
if value > 10_000_000_000:
value = value / 1000
return datetime.fromtimestamp(value).strftime("%Y-%m-%d")
text = str(value)
try:
if text.endswith("Z"):
dt = datetime.fromisoformat(text[:-1] + "+00:00")
else:
dt = datetime.fromisoformat(text)
if dt.tzinfo is not None:
dt = dt.astimezone()
return dt.strftime("%Y-%m-%d")
except Exception:
return datetime.now().strftime("%Y-%m-%d")
def number(value):
try:
return int(value or 0)
except Exception:
return 0
def model_name(raw):
value = str(raw or "codex")
return value if value else "codex"
def runtime_env():
home = str(Path.home())
path_parts = [
os.environ.get("PATH", ""),
f"{home}/.local/bin",
f"{home}/.npm-global/bin",
f"{home}/.local/share/mise/shims",
]
env = os.environ.copy()
env["PATH"] = os.pathsep.join(part for part in path_parts if part)
return env
ENV = runtime_env()
def find_command(name):
return shutil.which(name, path=ENV.get("PATH"))
now = datetime.now()
today = now.strftime("%Y-%m-%d")
recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)]
recent_set = set(recent_dates)
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
today_tokens_by_model = {}
model_usage = {}
sessions_by_day = {day: set() for day in recent_dates}
today_sessions = set()
today_prompts = 0
today_total_tokens = 0
total_prompts = 0
total_sessions = set()
seen_pi_messages = set()
usage_status = ""
usage_help = ""
def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write):
global today_prompts, today_total_tokens, total_prompts
total = input_tokens + output_tokens + cache_read + cache_write
total_prompts += 1
total_sessions.add(session_key)
bucket = model_usage.setdefault(model, {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
})
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
recent[day]["messageCount"] += total
sessions_by_day[day].add(session_key)
if day == today:
today_prompts += 1
today_sessions.add(session_key)
today_total_tokens += total
today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
def scan_pi_sessions():
root = Path.home() / ".pi" / "agent" / "sessions"
if not root.exists():
return
try:
rg = find_command("rg") or "rg"
proc = subprocess.Popen(
[rg, "--json", "-e", '"provider":"openai-codex"', "-e", '"api":"openai-codex"', str(root)],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
errors="replace",
env=ENV,
)
except FileNotFoundError:
return
assert proc.stdout is not None
for raw in proc.stdout:
try:
event = json.loads(raw)
if event.get("type") != "match":
continue
line = event.get("data", {}).get("lines", {}).get("text", "")
path = event.get("data", {}).get("path", {}).get("text", "pi-session")
entry = json.loads(line)
except Exception:
continue
if entry.get("type") != "message":
continue
message_key = path + ":" + str(entry.get("id") or "")
if message_key in seen_pi_messages:
continue
seen_pi_messages.add(message_key)
message = entry.get("message") or {}
if message.get("role") != "assistant":
continue
provider = str(message.get("provider") or "")
api = str(message.get("api") or "")
if provider != "openai-codex" and not api.startswith("openai-codex"):
continue
usage = message.get("usage") or {}
if not usage:
continue
total = number(usage.get("totalTokens"))
input_tokens = number(usage.get("input"))
output_tokens = number(usage.get("output"))
cache_read = number(usage.get("cacheRead"))
cache_write = number(usage.get("cacheWrite"))
if total and not (input_tokens or output_tokens or cache_read or cache_write):
input_tokens = total
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day(entry.get("timestamp") or message.get("timestamp"))
session_key = path
add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
try:
proc.wait(timeout=1)
except Exception:
proc.kill()
def scan_native_codex_sessions():
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
roots = [codex_home / "sessions", codex_home / "archived_sessions"]
files = []
cutoff = time.time() - 30 * 24 * 60 * 60
for root in roots:
if not root.exists():
continue
for path in root.rglob("*.jsonl"):
try:
if path.stat().st_mtime >= cutoff:
files.append(path)
except OSError:
pass
for path in files:
current_model = "codex"
try:
with path.open(errors="replace") as handle:
for raw in handle:
try:
entry = json.loads(raw)
except Exception:
continue
if entry.get("type") == "turn_context":
payload = entry.get("payload") or {}
current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model)
continue
payload = entry.get("payload") or entry
if entry.get("type") == "response_item" and isinstance(payload, dict):
payload = payload.get("payload") or payload
if not isinstance(payload, dict):
continue
if payload.get("type") != "token_count":
continue
info = payload.get("info") or {}
usage = info.get("total_token_usage") or {}
input_tokens = number(usage.get("input_tokens"))
output_tokens = number(usage.get("output_tokens")) + number(usage.get("reasoning_output_tokens"))
cache_read = number(usage.get("cached_input_tokens"))
cache_write = 0
if not (input_tokens or output_tokens or cache_read):
continue
day = local_day(entry.get("timestamp") or path.stat().st_mtime)
add_usage(day, str(path), current_model, input_tokens, output_tokens, cache_read, cache_write)
except Exception:
continue
def rpc_request(proc, request_id, method, params=None, timeout=8):
payload = {"id": request_id, "method": method, "params": params or {}}
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
deadline = time.time() + timeout
while time.time() < deadline:
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
if not ready:
continue
line = proc.stdout.readline()
if not line:
break
try:
message = json.loads(line)
except Exception:
continue
if message.get("id") == request_id:
return message
raise TimeoutError(method)
def fetch_codex_rpc():
result = {
"rateLimitPercent": -1,
"rateLimitLabel": "",
"rateLimitResetAt": "",
"secondaryRateLimitPercent": -1,
"secondaryRateLimitLabel": "",
"secondaryRateLimitResetAt": "",
"tierLabel": "",
}
codex = find_command("codex")
if not codex:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = "codex not found in PATH"
return result
try:
proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "untrusted", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
env=ENV,
)
except Exception as exc:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = str(exc)
return result
try:
rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-model-usage", "version": "1"}}, timeout=8)
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
proc.stdin.flush()
account_msg = rpc_request(proc, 2, "account/read", timeout=4)
limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4)
account = (account_msg.get("result") or {}).get("account") or {}
limits = (limits_msg.get("result") or {}).get("rateLimits") or {}
plan = limits.get("planType") or account.get("planType") or account.get("type") or ""
result["tierLabel"] = str(plan) if plan else ""
def fill(prefix, window):
if not isinstance(window, dict):
return
used = window.get("usedPercent")
if used is not None:
result[prefix + "Percent"] = float(used) / 100.0
mins = number(window.get("windowDurationMins"))
if mins:
if mins == 10080:
result[prefix + "Label"] = "Weekly (7-day)"
elif mins % 60 == 0:
result[prefix + "Label"] = f"{mins // 60}h window"
else:
result[prefix + "Label"] = f"{mins}m window"
reset = window.get("resetsAt")
if reset:
result[prefix + "ResetAt"] = datetime.fromtimestamp(number(reset), timezone.utc).isoformat()
fill("rateLimit", limits.get("primary"))
fill("secondaryRateLimit", limits.get("secondary"))
except Exception as exc:
result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = str(exc)
finally:
try:
proc.terminate()
proc.wait(timeout=1)
except Exception:
try:
proc.kill()
except Exception:
pass
return result
scan_pi_sessions()
scan_native_codex_sessions()
rpc = fetch_codex_rpc()
out = {
"ready": True,
"hasLocalStats": True,
"todayPrompts": today_prompts,
"todaySessions": len(today_sessions),
"todayTotalTokens": today_total_tokens,
"todayTokensByModel": today_tokens_by_model,
"recentDays": [recent[day] for day in recent_dates],
"totalPrompts": total_prompts,
"totalSessions": len(total_sessions),
"modelUsage": model_usage,
"usageStatusText": usage_status,
"authHelpText": usage_help,
}
out.update(rpc)
print(json.dumps(out, separators=(",", ":")))