mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-05 20:28:25 +02:00
add model-usage plugin
This commit is contained in:
@@ -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 |
@@ -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
@@ -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=(",", ":")))
|
||||
Reference in New Issue
Block a user