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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openusage",
"version": "1.0.2",
"version": "1.0.3",
"description": "Floating Windows, macOS and Linux widget: usage limits of AI coding tools (Claude, Codex, Copilot, Cursor, Grok, Devin, OpenRouter, Z.ai, Ollama...)",
"type": "module",
"private": true,
Expand Down
8 changes: 5 additions & 3 deletions scripts/usage-json.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Prints every provider's usage as one JSON array. Used by the desktop widget.
// No ids: the providers set up on this machine. --detect: just their ids, without fetching anything.
import { fetchAllUsage, PROVIDERS, type ProviderId } from "../src/providers/index.ts"
import { failed, fetchAllUsage, PROVIDERS, type ProviderId } from "../src/providers/index.ts"

const detected = async () => {
const all = Object.keys(PROVIDERS) as ProviderId[]
Expand All @@ -12,6 +12,8 @@ const args = process.argv.slice(2)
if (args[0] === "--detect") {
process.stdout.write(JSON.stringify(await detected()))
} else {
const ids = (args.length ? args : await detected()) as ProviderId[]
process.stdout.write(JSON.stringify((await Promise.all(ids.map((id) => fetchAllUsage(id)))).flat()))
const requested = args.length ? args : await detected()
const ids = requested.filter((id): id is ProviderId => id in PROVIDERS)
const rows = await Promise.all(ids.map((id) => fetchAllUsage(id).catch((e) => [failed(PROVIDERS[id], e)])))
process.stdout.write(JSON.stringify(rows.flat()))
}
34 changes: 31 additions & 3 deletions src/providers/codex.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { existsSync } from "node:fs"
import { readdir, readFile, stat } from "node:fs/promises"
import { open, readdir, readFile, stat } from "node:fs/promises"
import { homedir } from "node:os"
import { join } from "node:path"
import {
Expand Down Expand Up @@ -81,11 +81,36 @@ async function newestSessionFile(codexHome: string): Promise<string | undefined>
return withTime[0] && join(dir, withTime[0].f)
}

/** The API was unreachable. A rejected login or an API-key account must not be replaced by an old log line. */
function isTransient(e: unknown): boolean {
if (e instanceof ProviderError || !(e instanceof Error)) return false
return e.name === "TimeoutError" || e.name === "AbortError" || e instanceof TypeError
}

/** Last 1 MiB of a session log, dropping a line the cap sliced in half. */
async function readTail(file: string): Promise<string> {
const fh = await open(file, "r")
try {
const size = (await fh.stat()).size
const length = Math.min(size, 1024 * 1024)
const buf = Buffer.alloc(length)
const { bytesRead } = await fh.read(buf, 0, length, Math.max(0, size - length))
let text = buf.subarray(0, bytesRead).toString("utf8")
if (size > length) {
const nl = text.indexOf("\n")
text = nl >= 0 ? text.slice(nl + 1) : ""
}
return text
} finally {
await fh.close()
}
}

/** Offline fallback: the latest rate_limits snapshot Codex wrote to its session log. */
async function fromSessions(dir: string): Promise<ProviderUsage> {
const file = await newestSessionFile(dir)
if (!file) throw new ProviderError("no Codex usage data yet")
const lines = (await readFile(file, "utf8")).trimEnd().split("\n")
const lines = (await readTail(file)).trimEnd().split("\n")
for (let i = lines.length - 1; i >= 0; i--) {
if (!lines[i]!.includes("rate_limits")) continue
let limits: any
Expand All @@ -103,13 +128,15 @@ async function fromSessions(dir: string): Promise<ProviderUsage> {
] as const) {
if (!w) continue
const resetsAt = toMillis(w.resets_at)
// A reset already in the past is a stale snapshot of that window.
if (resetsAt != null && resetsAt <= Date.now()) continue
windows.push({
label: windowLabel(w.window_minutes ? w.window_minutes * 60 : undefined, fallback),
usedPct: clampPct(w.used_percent),
// Past resets mean the snapshot is stale for that window.
resetsAt,
})
}
if (!windows.length) continue
const extras: UsageExtra[] = []
if (limits.credits?.balance != null && !limits.credits.unlimited)
extras.push({ label: "Credits Balance", value: `$${Number(limits.credits.balance).toFixed(2)}` })
Expand All @@ -126,6 +153,7 @@ export const codex: Provider = {
try {
return await fromApi(dir, signal)
} catch (e) {
if (!isTransient(e)) throw e
try {
return await fromSessions(dir)
} catch {
Expand Down
172 changes: 109 additions & 63 deletions src/providers/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ const LABELS: Record<string, string> = {
}

/** Where the Copilot editor plugins keep their sign-in (apps.json / hosts.json). */
const pluginDir = () =>
process.platform === "win32"
? join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "github-copilot")
: join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "github-copilot")
const pluginDir = () => {
if (process.platform === "win32") return join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "github-copilot")
if (process.platform === "darwin") return join(homedir(), "Library", "Application Support", "github-copilot")
return join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "github-copilot")
}

/** GitHub CLI's config folder, whose hosts.yml names the signed-in user. */
const ghDir = () =>
Expand All @@ -27,14 +28,37 @@ const ghDir = () =>
? join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "GitHub CLI")
: join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "gh"))

/** github.com entries of the plugins' apps.json / hosts.json: { user, oauth_token }. */
async function pluginLogins(): Promise<Array<{ user?: string; oauth_token?: string }>> {
/** github.com, plus any GitHub Enterprise host the GitHub CLI already has in hosts.yml. */
async function allowedHosts(): Promise<Set<string>> {
const hosts = new Set<string>(["github.com"])
const yml = await readFile(join(ghDir(), "hosts.yml"), "utf8").catch(() => "")
for (const line of yml.split(/\r?\n/)) {
const name = /^([^ \t#][^:]*):\s*(?:#.*)?$/.exec(line)?.[1]?.trim().toLowerCase()
if (name && (name === "github.com" || name.includes("."))) hosts.add(name)
}
return hosts
}

function hostnameOf(host: string): string | undefined {
try {
return new URL(host.includes("://") ? host : `https://${host}`).hostname.toLowerCase()
} catch {
return undefined
}
}

/** Entries of the plugins' apps.json / hosts.json whose host is exactly github.com or a known enterprise host. */
async function pluginLogins(hosts?: ReadonlySet<string>): Promise<Array<{ user?: string; oauth_token?: string }>> {
const allowed = hosts ?? (await allowedHosts())
const found = []
for (const file of ["apps.json", "hosts.json"]) {
const raw = await readFile(join(pluginDir(), file), "utf8").catch(() => undefined)
if (!raw) continue
try {
for (const [host, v] of Object.entries(JSON.parse(raw) ?? {})) if (host.startsWith("github.com")) found.push(v as any)
for (const [host, v] of Object.entries(JSON.parse(raw) ?? {})) {
const name = hostnameOf(host)
if (name && allowed.has(name)) found.push(v as any)
}
} catch {}
}
return found
Expand All @@ -46,19 +70,25 @@ async function ghUser(): Promise<string | undefined> {
return /^github\.com:[\s\S]*?^\s+user:\s*(\S+)/m.exec(yml)?.[1]
}

function ghToken(): Promise<string | undefined> {
function ghToken(hostname: string): Promise<string | undefined> {
return new Promise((resolve) =>
execFile("gh", ["auth", "token", "--hostname", "github.com"], { timeout: 10_000, windowsHide: true }, (err, stdout) =>
execFile("gh", ["auth", "token", "--hostname", hostname], { timeout: 10_000, windowsHide: true }, (err, stdout) =>
resolve(err ? undefined : stdout.trim() || undefined),
),
)
}

/** A GitHub sign-in: the Copilot editor plugins' first, else the GitHub CLI's. */
async function readToken(): Promise<string> {
const token = (await pluginLogins()).find((l) => l.oauth_token)?.oauth_token ?? (await ghToken())
if (!token) throw new ProviderError("not signed in, run gh auth login")
return token
/** Plugin tokens first, then `gh auth token` for each known host. A dead plugin token must not hide a working login. */
async function candidateTokens(): Promise<string[]> {
const hosts = await allowedHosts()
const tokens: string[] = []
const add = (token?: string) => {
const value = token?.trim()
if (value && !tokens.includes(value)) tokens.push(value)
}
for (const login of await pluginLogins(hosts)) add(login.oauth_token)
for (const host of hosts) add(await ghToken(host))
return tokens
}

function plan(body: any): string | undefined {
Expand All @@ -68,61 +98,77 @@ function plan(body: any): string | undefined {
return p === "individual" ? "pro" : p || undefined
}

function usageFrom(body: any): ProviderUsage {
const windows: UsageWindow[] = []
const extras: UsageExtra[] = []
// Paid plans: a snapshot per quota, most of them unlimited.
const resetsAt = toMillis(body?.quota_reset_date_utc ?? body?.quota_reset_date)
for (const [key, s] of Object.entries<any>(body?.quota_snapshots ?? {})) {
if (!s || s.unlimited || !(s.entitlement > 0)) continue
windows.push({ label: LABELS[key] ?? key, usedPct: clampPct(100 - s.percent_remaining), resetsAt })
if (key === "premium_interactions") {
extras.push({ label: "Premium Requests", value: `${Math.max(0, s.entitlement - s.remaining)} / ${s.entitlement}` })
if (s.overage_count > 0) extras.push({ label: "Overage", value: String(s.overage_count) })
}
}
// Free plan: what is left of each monthly quota.
const left = body?.limited_user_quotas
const total = body?.monthly_quotas
if (left && total) {
const freeReset = toMillis(body?.limited_user_reset_date)
for (const key of ["chat", "completions"]) {
// Some accounts report the same quota in quota_snapshots too.
if (!(total[key] > 0) || left[key] == null || windows.some((w) => w.label === LABELS[key])) continue
windows.push({ label: LABELS[key]!, usedPct: clampPct(((total[key] - left[key]) / total[key]) * 100), resetsAt: freeReset })
}
}
if (!windows.length && !extras.length) throw new ProviderError("Copilot: no usage data")
// The bigger quota first: it is the one the compact ring shows.
windows.sort((a, b) => (a.label === "Monthly Premium" ? -1 : b.label === "Monthly Premium" ? 1 : 0))
return {
id: "copilot",
name: "Copilot",
plan: plan(body),
...(body?.login ? { account: String(body.login) } : {}),
windows,
extras,
fetchedAt: Date.now(),
} satisfies ProviderUsage
}

const signedOut = (e: unknown) => e instanceof ProviderError && e.message.includes("sign in again")

export const copilot: Provider = {
id: "copilot",
name: "Copilot",
async fetch(signal) {
const body = await getJson(
USAGE_URL,
{
signal,
headers: {
Authorization: `token ${await readToken()}`,
Accept: "application/json",
"Editor-Version": "vscode/1.100.0",
"Editor-Plugin-Version": "copilot-chat/0.27.0",
"User-Agent": "GitHubCopilotChat/0.27.0",
"X-GitHub-Api-Version": "2025-04-01",
},
},
"Copilot",
)

const windows: UsageWindow[] = []
const extras: UsageExtra[] = []
// Paid plans: a snapshot per quota, most of them unlimited.
const resetsAt = toMillis(body?.quota_reset_date_utc ?? body?.quota_reset_date)
for (const [key, s] of Object.entries<any>(body?.quota_snapshots ?? {})) {
if (!s || s.unlimited || !(s.entitlement > 0)) continue
windows.push({ label: LABELS[key] ?? key, usedPct: clampPct(100 - s.percent_remaining), resetsAt })
if (key === "premium_interactions") {
extras.push({ label: "Premium Requests", value: `${Math.max(0, s.entitlement - s.remaining)} / ${s.entitlement}` })
if (s.overage_count > 0) extras.push({ label: "Overage", value: String(s.overage_count) })
}
}
// Free plan: what is left of each monthly quota.
const left = body?.limited_user_quotas
const total = body?.monthly_quotas
if (left && total) {
const freeReset = toMillis(body?.limited_user_reset_date)
for (const key of ["chat", "completions"]) {
// Some accounts report the same quota in quota_snapshots too.
if (!(total[key] > 0) || left[key] == null || windows.some((w) => w.label === LABELS[key])) continue
windows.push({ label: LABELS[key]!, usedPct: clampPct(((total[key] - left[key]) / total[key]) * 100), resetsAt: freeReset })
const tokens = await candidateTokens()
if (!tokens.length) throw new ProviderError("not signed in, run gh auth login")
let last: unknown
for (let i = 0; i < tokens.length; i++) {
try {
const body = await getJson(
USAGE_URL,
{
signal,
headers: {
Authorization: `token ${tokens[i]}`,
Accept: "application/json",
"Editor-Version": "vscode/1.100.0",
"Editor-Plugin-Version": "copilot-chat/0.27.0",
"User-Agent": "GitHubCopilotChat/0.27.0",
"X-GitHub-Api-Version": "2025-04-01",
},
},
"Copilot",
)
return usageFrom(body)
} catch (e) {
last = e
if (!signedOut(e) || i === tokens.length - 1) throw e
}
}
if (!windows.length && !extras.length) throw new ProviderError("Copilot: no usage data")
// The bigger quota first: it is the one the compact ring shows.
windows.sort((a, b) => (a.label === "Monthly Premium" ? -1 : b.label === "Monthly Premium" ? 1 : 0))
return {
id: "copilot",
name: "Copilot",
plan: plan(body),
...(body?.login ? { account: String(body.login) } : {}),
windows,
extras,
fetchedAt: Date.now(),
} satisfies ProviderUsage
throw last
},
async account() {
const fromPlugin = (await pluginLogins()).find((l) => l.user)?.user
Expand Down
16 changes: 14 additions & 2 deletions src/providers/devin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,28 @@ export const devin: Provider = {
const sources = [await cliAuth(), await appAuth()].filter((a): a is Auth => !!a)
if (!sources.length) throw new ProviderError("not signed in to Devin, run devin auth login")
// No token refresh: a rejected CLI key falls back to the app's login.
// A down CLI server must not hide a working editor login either.
let lastError: unknown = new ProviderError("Devin: sign in again, run devin auth login")
for (const auth of sources) {
if (auth !== sources[0] && auth.apiKey === sources[0]!.apiKey) continue
const res = await userStatus(auth, signal)
let res: Response
try {
res = await userStatus(auth, signal)
} catch (err) {
lastError = err
continue
}
if (res.status === 401 || res.status === 403) continue
if (res.status >= 500) {
lastError = new ProviderError(`Devin: HTTP ${res.status}`)
continue
}
if (!res.ok) throw new ProviderError(`Devin: HTTP ${res.status}`)
const body: any = await res.json()
if (!body?.userStatus) throw new ProviderError("Devin: no usage data")
return map(body.userStatus)
}
throw new ProviderError("Devin: sign in again, run devin auth login")
throw lastError
},
detect: async () => !!((await cliAuth()) ?? (await appAuth())),
}
Loading
Loading