diff --git a/package-lock.json b/package-lock.json index 679d709..4011737 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openusage", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openusage", - "version": "1.0.2", + "version": "1.0.3", "license": "Apache-2.0", "devDependencies": { "@types/node": "^24", diff --git a/package.json b/package.json index 528dc09..ac66dfc 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/scripts/usage-json.ts b/scripts/usage-json.ts index 7c898c7..e2b05e2 100644 --- a/scripts/usage-json.ts +++ b/scripts/usage-json.ts @@ -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[] @@ -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())) } diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 420e54a..cf33e1e 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -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 { @@ -81,11 +81,36 @@ async function newestSessionFile(codexHome: string): Promise 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 { + 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 { 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 @@ -103,13 +128,15 @@ async function fromSessions(dir: string): Promise { ] 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)}` }) @@ -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 { diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index d3d1a8c..f2caf5d 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -15,10 +15,11 @@ const LABELS: Record = { } /** 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 = () => @@ -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> { +/** github.com, plus any GitHub Enterprise host the GitHub CLI already has in hosts.yml. */ +async function allowedHosts(): Promise> { + const hosts = new Set(["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): Promise> { + 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 @@ -46,19 +70,25 @@ async function ghUser(): Promise { return /^github\.com:[\s\S]*?^\s+user:\s*(\S+)/m.exec(yml)?.[1] } -function ghToken(): Promise { +function ghToken(hostname: string): Promise { 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 { - 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 { + 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 { @@ -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(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(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 diff --git a/src/providers/devin.ts b/src/providers/devin.ts index 7ce99c0..01bcef2 100644 --- a/src/providers/devin.ts +++ b/src/providers/devin.ts @@ -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())), } diff --git a/src/providers/grok.ts b/src/providers/grok.ts index 73f70dd..dc646ae 100644 --- a/src/providers/grok.ts +++ b/src/providers/grok.ts @@ -1,4 +1,4 @@ -import { readFile, rename, writeFile } from "node:fs/promises" +import { readFile, rename, stat, unlink, writeFile } from "node:fs/promises" import { homedir } from "node:os" import { join } from "node:path" import { clampPct, jwtPayload, ProviderError, toMillis, type Provider, type ProviderUsage, type UsageExtra, type UsageWindow } from "./types.ts" @@ -20,12 +20,63 @@ interface Entry { oidc_client_id?: string } +function parseAuth(raw: string | undefined): Record | undefined { + if (!raw) return undefined + try { + const auth = JSON.parse(raw) + if (!auth || typeof auth !== "object" || Array.isArray(auth)) return undefined + if (!Object.values(auth).some((e: any) => e?.key)) return undefined + return auth + } catch { + return undefined + } +} + +/** + * `auth.json.tmp` is the crash journal: the new refresh token is written there before auth.json is replaced. + * Windows cannot rename over a file that already exists, so a failed replace leaves the new login in the journal. + */ +async function installJournal(path: string): Promise { + const tmp = `${path}.tmp` + try { + await rename(tmp, path) + } catch (err) { + if (process.platform !== "win32") throw err + await writeFile(path, await readFile(tmp, "utf8"), { mode: 0o600 }) + await unlink(tmp).catch(() => {}) + } +} + +/** Reads auth.json, preferring a newer crash journal. Does not replace the file. */ +async function loadAuth(): Promise<{ auth: Record; fromJournal: boolean }> { + const path = authPath() + const tmp = `${path}.tmp` + const [raw, journalRaw, fileStat, journalStat] = await Promise.all([ + readFile(path, "utf8").catch(() => undefined), + readFile(tmp, "utf8").catch(() => undefined), + stat(path).catch(() => undefined), + stat(tmp).catch(() => undefined), + ]) + const saved = parseAuth(raw) + const journal = parseAuth(journalRaw) + // A newer journal is a refresh that never landed in auth.json. It holds the rotated token. + const journalWins = !!journal && (!saved || !fileStat || !!(journalStat && journalStat.mtimeMs >= fileStat.mtimeMs)) + if (journal && journalWins) return { auth: journal, fromJournal: true } + // A leftover older journal stays on disk. Unlinking it here races a refresh that just rewrote the same path. + if (saved) return { auth: saved, fromJournal: false } + throw new ProviderError(raw || journalRaw ? "Grok: sign in again, run grok login" : "not signed in to Grok, run grok login") +} + /** What `grok login` saved: one entry per login, keyed "::". */ async function readAuth(): Promise> { - const raw = await readFile(authPath(), "utf8").catch(() => undefined) - if (!raw) throw new ProviderError("not signed in to Grok, run grok login") - const auth = JSON.parse(raw) - if (!auth || typeof auth !== "object" || !Object.values(auth).some((e: any) => e?.key)) throw new ProviderError("Grok: sign in again, run grok login") + const { auth, fromJournal } = await loadAuth() + if (fromJournal) { + try { + await installJournal(authPath()) + } catch { + // This refresh can still use the tokens; the next read tries to install them again. + } + } return auth } @@ -53,23 +104,37 @@ async function refresh(entryKey: string, e: Entry): Promise const access = typeof t?.access_token === "string" ? t.access_token.trim() : "" if (!access) return undefined const exp = Number(t.expires_in) > 0 ? Date.now() + Number(t.expires_in) * 1000 : (expiresAt({ key: access }) ?? Date.now() + 3_600_000) - // Re-read so an entry the CLI changed meanwhile, or another login, isn't lost. + const rotated = typeof t.refresh_token === "string" ? t.refresh_token : undefined + // The server has already invalidated the old refresh token. Don't use the new one until auth.json has it. + await persistRefresh(entryKey, access, exp, rotated, typeof t.id_token === "string" ? t.id_token : undefined) + return access +} + +/** Re-reads so another login isn't lost, writes the journal, then replaces auth.json. Retries a locked file. */ +async function persistRefresh(entryKey: string, access: string, exp: number, refreshToken: string | undefined, idToken: string | undefined): Promise { const path = authPath() - try { - const auth = JSON.parse(await readFile(path, "utf8")) - auth[entryKey] = { - ...auth[entryKey], - key: access, - ...(t.refresh_token ? { refresh_token: t.refresh_token } : {}), - ...(t.id_token ? { id_token: t.id_token } : {}), - expires_at: new Date(exp).toISOString(), + let last: unknown + for (let attempt = 0; attempt < 3; attempt++) { + try { + const { auth } = await loadAuth() + auth[entryKey] = { + ...auth[entryKey], + key: access, + ...(refreshToken ? { refresh_token: refreshToken } : {}), + ...(idToken ? { id_token: idToken } : {}), + expires_at: new Date(exp).toISOString(), + } + await writeFile(`${path}.tmp`, JSON.stringify(auth, null, 2), { mode: 0o600 }) + await installJournal(path) + const saved = parseAuth(await readFile(path, "utf8"))?.[entryKey] + if (saved?.key !== access || (refreshToken && saved.refresh_token !== refreshToken)) throw new Error("refreshed Grok login was not saved") + return + } catch (err) { + last = err + await new Promise((r) => setTimeout(r, 40 * (attempt + 1))) } - await writeFile(`${path}.tmp`, JSON.stringify(auth, null, 2), { mode: 0o600 }) - await rename(`${path}.tmp`, path) - } catch { - // The new token still works for this refresh. } - return access + throw new ProviderError("Grok: could not save the refreshed login", { cause: last }) } const headers = (token: string) => ({ diff --git a/src/providers/index.ts b/src/providers/index.ts index ccc40ef..f7e62ee 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -10,7 +10,7 @@ import { ollama } from "./ollama.ts" import { opencodeGo } from "./opencode-go.ts" import { openrouter } from "./openrouter.ts" import { zai } from "./zai.ts" -import { failed, type Provider, type ProviderId, type ProviderUsage } from "./types.ts" +import { failed, ProviderError, type Provider, type ProviderId, type ProviderUsage } from "./types.ts" export * from "./types.ts" @@ -31,6 +31,7 @@ export const PROVIDERS: Readonly> = { /** Never rejects: a failing provider comes back with `error` set. */ export async function fetchUsage(id: ProviderId, signal?: AbortSignal, profile?: string): Promise { const p = PROVIDERS[id] + if (!p) return failed({ id, name: id }, new ProviderError(`unknown provider: ${id}`)) const [usage, account] = await Promise.all([ p.fetch(signal, profile).catch((e) => failed(p, e)), p.account?.(profile).catch(() => undefined), @@ -43,14 +44,27 @@ export async function fetchUsage(id: ProviderId, signal?: AbortSignal, profile?: /** One result per signed-in account: the default one first, then any extra profiles. */ export async function fetchAllUsage(id: ProviderId, signal?: AbortSignal): Promise { - const profiles = (await PROVIDERS[id].profiles?.().catch(() => [])) ?? [] + const provider = PROVIDERS[id] + if (!provider) return [] + const profiles = (await provider.profiles?.().catch(() => [])) ?? [] const all = await Promise.all([fetchUsage(id, signal), ...profiles.map((dir) => fetchUsage(id, signal, dir))]) - // The same account copied into two folders shows up once. - const seen = new Set() - return all.filter((u, i) => { - if (i > 0 && u.error && !u.account) return false - if (!u.account) return true - const a = u.account.toLowerCase() - return seen.has(a) ? false : (seen.add(a), true) - }) + // The same account copied into two folders shows up once, preferring the card that actually loaded. + // An extra folder that failed before its email could be read stays: profiles() only returns folders with a session file. + const chosen: ProviderUsage[] = [] + const indexByAccount = new Map() + for (const u of all) { + if (!u.account) { + chosen.push(u) + continue + } + const key = u.account.toLowerCase() + const prev = indexByAccount.get(key) + if (prev == null) { + indexByAccount.set(key, chosen.length) + chosen.push(u) + continue + } + if (chosen[prev]?.error && !u.error) chosen[prev] = u + } + return chosen } diff --git a/src/providers/keys.ts b/src/providers/keys.ts index fe0182a..c12bc18 100644 --- a/src/providers/keys.ts +++ b/src/providers/keys.ts @@ -97,10 +97,23 @@ async function fromCrush(ids: readonly string[]): Promise { return undefined } +/** True when the base URL's hostname is `host` or a subdomain of it, not a lookalike that merely contains the text. */ +function pointsAt(baseUrl: unknown, host: string): boolean { + if (typeof baseUrl !== "string" || !baseUrl.trim()) return false + let hostname: string + try { + hostname = new URL(baseUrl).hostname.toLowerCase() + } catch { + return false + } + const expected = host.toLowerCase() + return hostname === expected || hostname.endsWith(`.${expected}`) +} + async function fromClaudeCode(host: string): Promise { const dir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude") const env = (await readJson(join(dir, "settings.json")))?.env - if (!String(env?.ANTHROPIC_BASE_URL ?? "").includes(host)) return undefined + if (!pointsAt(env?.ANTHROPIC_BASE_URL, host)) return undefined return literal(env.ANTHROPIC_AUTH_TOKEN) ?? literal(env.ANTHROPIC_API_KEY) } diff --git a/src/providers/ollama.ts b/src/providers/ollama.ts index 7b965a3..2cbc870 100644 --- a/src/providers/ollama.ts +++ b/src/providers/ollama.ts @@ -95,13 +95,17 @@ async function isLinked(): Promise { const cached = await readFile(linkedPath(), "utf8").then((raw) => JSON.parse(raw)?.linkedKey, () => undefined) if (cached === key.publicKey) return true const res = await call("POST", "/api/me", key, AbortSignal.timeout(8_000)).catch(() => undefined) - // An unlinked key still gets 200, with an empty user (all-zero ID, no email). const me: any = res?.ok ? await res.json().catch(() => undefined) : undefined - if (!me?.Email && !/[1-9a-f]/i.test(String(me?.ID ?? "").replace(/-/g, ""))) return false + if (!linkedUser(me)) return false await rememberLinked(key) return true } +/** An unlinked key still gets 200, with an empty user (all-zero ID, no email). */ +function linkedUser(me: any): boolean { + return !!(me?.Email || /[1-9a-f]/i.test(String(me?.ID ?? "").replace(/-/g, ""))) +} + const num = (v: unknown) => (v == null || v === "" || !Number.isFinite(Number(v)) ? undefined : Number(v)) const usd = (n: number) => `$${n.toFixed(2)}` @@ -118,8 +122,8 @@ export const ollama: Provider = { ), ]) if (balance.status === 401 || balance.status === 403) { - // Signed out since: the card goes away at the next start. - await rememberLinked(undefined) + // Clock skew and a balance-only rejection look like a sign-out. Forget the link only when /api/me says so. + if (me && !linkedUser(me)) await rememberLinked(undefined) throw new ProviderError("not signed in to Ollama Cloud, run ollama signin") } if (balance.status === 429) throw new ProviderError("Ollama: rate limited, will retry") diff --git a/src/providers/zai.ts b/src/providers/zai.ts index 7d15e5a..ac1143f 100644 --- a/src/providers/zai.ts +++ b/src/providers/zai.ts @@ -1,7 +1,7 @@ import { homedir } from "node:os" import { join } from "node:path" import { findKey, type KeySpec } from "./keys.ts" -import { clampPct, ProviderError, type Provider, type ProviderUsage, type UsageExtra, type UsageWindow } from "./types.ts" +import { clampPct, ProviderError, toMillis, type Provider, type ProviderUsage, type UsageExtra, type UsageWindow } from "./types.ts" // Undocumented, but they are what Z.ai's own subscription page calls. const QUOTA_URL = "https://api.z.ai/api/monitor/usage/quota/limit" @@ -21,7 +21,26 @@ const HOUR = 3_600_000 const UNIT_MS: Record = { 3: HOUR, 4: 24 * HOUR, 5: 30 * 24 * HOUR, 6: 7 * 24 * HOUR } const typeOf = (l: any) => l?.type ?? l?.name -const resetOf = (l: any) => (Number.isFinite(Number(l?.nextResetTime)) && l.nextResetTime ? Number(l.nextResetTime) : undefined) +const resetOf = (l: any) => { + const t = toMillis(l?.nextResetTime) + return t ? t : undefined +} + +/** `unit` 3 = hours, 4 = days, 5 = months, 6 = weeks, times `number`. */ +function limitLabel(unit: number, count: number): string { + if (unit === 3) { + const hours = Math.round(count) + if (hours < 24) return `${hours}-Hour` + if (hours < 24 * 7) return "Daily" + return "Weekly" + } + if (unit === 4) return count <= 1 ? "Daily" : count >= 28 ? "Monthly" : "Weekly" + if (unit === 5) return "Monthly" + if (unit === 6) return "Weekly" + return "Weekly" +} + +const labelRank = (label: string) => (label.endsWith("Hour") ? 0 : label === "Daily" || label.endsWith("Day") ? 1 : label === "Monthly" || label.endsWith("Month") ? 3 : 2) async function get(url: string, key: string, signal?: AbortSignal): Promise { return fetch(url, { signal: signal ?? AbortSignal.timeout(15_000), headers: { Authorization: `Bearer ${key}`, Accept: "application/json" } }) @@ -52,10 +71,9 @@ async function usage(key: string, signal?: AbortSignal): Promise for (const l of limits.filter((l) => ["CREDIT_LIMIT", "TOKENS_LIMIT"].includes(typeOf(l)))) { const ms = UNIT_MS[Number(l.unit)] if (!ms || !(Number(l.number) > 0) || l.percentage == null) continue - const label = ms * Number(l.number) < 24 * HOUR ? "5-Hour" : "Weekly" - windows.push({ label, usedPct: clampPct(l.percentage), resetsAt: resetOf(l) }) + windows.push({ label: limitLabel(Number(l.unit), Number(l.number)), usedPct: clampPct(l.percentage), resetsAt: resetOf(l) }) } - windows.sort((a, b) => (a.label === "5-Hour" ? -1 : b.label === "5-Hour" ? 1 : 0)) + windows.sort((a, b) => labelRank(a.label) - labelRank(b.label)) // TIME_LIMIT: the monthly count of web searches / web reader / Zread calls. const web = limits.find((l) => typeOf(l) === "TIME_LIMIT") if (web && Number(web.usage) > 0) {