Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,14 @@
"prepublishOnly": "pnpm run build && pnpm test"
},
"devDependencies": {
"@opencode-ai/plugin": "latest",
"@opencode-ai/plugin": "1.15.13",
"@opentui/core": "^0.4.5",
"@opentui/keymap": "^0.4.5",
"@opentui/solid": "^0.4.5",
"@types/node": "^25.5.0",
"oxfmt": "0.41.0",
"oxlint": "1.56.0",
"solid-js": "^1.9.14",
"typescript": "^5.0.0"
},
"peerDependencies": {
Expand Down
1,220 changes: 1,211 additions & 9 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const SOURCE_FILES = [
"credentials.ts",
"logger.ts",
"http.ts",
"usage.ts",
] as const

async function copySourceFiles(
Expand Down
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
transformBody,
transformResponseStream,
} from "./transforms.ts"
import { recordUsageFromHeaders } from "./usage.ts"
import {
getCachedCredentials,
reloadCredentialsFromSource,
Expand Down Expand Up @@ -60,6 +61,15 @@ export {
computeVersionSuffix,
extractFirstUserMessageText,
} from "./signing.ts"
export {
getUsageStatePaths,
parseUsageHeaders,
recordUsageFromHeaders,
resetUsageWriteCache,
writeUsageSnapshot,
type UsageSnapshot,
type UsageWindow,
} from "./usage.ts"

function getCliVersion(): string {
return process.env.ANTHROPIC_CLI_VERSION ?? config.ccVersion
Expand Down Expand Up @@ -600,6 +610,13 @@ const plugin: Plugin = async () => {
.catch(() => {})
}

// Quota headers ride on every /v1/messages response, including the
// 429 that says the quota is spent. Read from the response that is
// actually being returned — after the 401, rotation and long-context
// retries above — so the recorded numbers match the call that
// counted, not one of the attempts that was thrown away.
recordUsageFromHeaders(response.headers)

// A 401 that survived recovery carries an error body, not an SSE
// stream. Deciding here rather than from a flag set mid-flight
// makes the retried and non-retried paths behave identically.
Expand Down
232 changes: 232 additions & 0 deletions src/usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import assert from "node:assert/strict"
import { describe, it, beforeEach, afterEach } from "node:test"
import { mkdtempSync, readFileSync, existsSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir, homedir } from "node:os"
import {
getUsageStatePaths,
parseUsageHeaders,
recordUsageFromHeaders,
resetUsageWriteCache,
writeUsageSnapshot,
type UsageSnapshot,
} from "./usage.ts"

function restore(key: string, value: string | undefined): void {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}

/** A response carrying the full unified rate-limit family. */
function quotaHeaders(overrides: Record<string, string> = {}): Headers {
return new Headers({
"anthropic-ratelimit-unified-status": "allowed",
"anthropic-ratelimit-unified-representative-claim": "five_hour",
"anthropic-ratelimit-unified-5h-utilization": "0.26",
"anthropic-ratelimit-unified-5h-reset": "1775455200",
"anthropic-ratelimit-unified-7d-utilization": "0.19",
"anthropic-ratelimit-unified-7d-reset": "1775973600",
...overrides,
})
}

describe("parseUsageHeaders", () => {
it("reads both windows as fractions with epoch-second resets", () => {
const snapshot = parseUsageHeaders(quotaHeaders())

assert.ok(snapshot)
assert.equal(snapshot.status, "allowed")
assert.equal(snapshot.representativeClaim, "five_hour")
assert.deepEqual(snapshot.fiveHour, {
utilization: 0.26,
resetsAt: 1775455200,
})
assert.deepEqual(snapshot.sevenDay, {
utilization: 0.19,
resetsAt: 1775973600,
})
})

it("returns null when the response carries no quota headers", () => {
// count_tokens calls and request-validation errors come back without the
// family. Returning a blank snapshot would blank out a good reading.
const snapshot = parseUsageHeaders(
new Headers({ "content-type": "application/json" }),
)

assert.equal(snapshot, null)
})

it("keeps a window whose reset header is absent", () => {
const headers = quotaHeaders()
headers.delete("anthropic-ratelimit-unified-5h-reset")

const snapshot = parseUsageHeaders(headers)

assert.deepEqual(snapshot?.fiveHour, { utilization: 0.26, resetsAt: null })
})

it("reports a rejection, which is what a spent quota looks like", () => {
const snapshot = parseUsageHeaders(
quotaHeaders({
"anthropic-ratelimit-unified-status": "rejected",
"anthropic-ratelimit-unified-5h-utilization": "1",
}),
)

assert.equal(snapshot?.status, "rejected")
assert.equal(snapshot?.fiveHour?.utilization, 1)
})

it("survives a status header with no windows at all", () => {
const snapshot = parseUsageHeaders(
new Headers({ "anthropic-ratelimit-unified-status": "allowed" }),
)

assert.ok(snapshot)
assert.equal(snapshot.fiveHour, null)
assert.equal(snapshot.sevenDay, null)
})

it("ignores a non-numeric utilization rather than emitting NaN", () => {
const snapshot = parseUsageHeaders(
quotaHeaders({ "anthropic-ratelimit-unified-5h-utilization": "n/a" }),
)

assert.equal(snapshot?.fiveHour, null)
assert.ok(snapshot?.sevenDay)
})

it("captures the overage pool when present", () => {
const snapshot = parseUsageHeaders(
quotaHeaders({
"anthropic-ratelimit-unified-overage-status": "rejected",
"anthropic-ratelimit-unified-overage-disabled-reason":
"org_level_disabled",
}),
)

assert.equal(snapshot?.overage?.status, "rejected")
assert.equal(snapshot?.overage?.disabledReason, "org_level_disabled")
})

it("omits the overage pool when the account has none", () => {
assert.equal(parseUsageHeaders(quotaHeaders())?.overage, null)
})
})

describe("getUsageStatePaths", () => {
it("covers every root OpenCode may have been installed under", () => {
const paths = getUsageStatePaths()

assert.ok(paths.length >= 1)
assert.ok(paths.every((path) => path.endsWith("claude-usage.json")))
assert.ok(
paths.includes(
join(homedir(), ".local", "share", "opencode", "claude-usage.json"),
),
)
if (process.platform === "win32") {
// Different Windows install methods read different roots, and the TUI
// that consumes this has no say in which one it got.
assert.equal(paths.length, 2)
}
})
})

describe("writeUsageSnapshot", () => {
let tmpDir: string
let originalUserProfile: string | undefined
let originalHome: string | undefined
let originalLocalAppData: string | undefined

const snapshot: UsageSnapshot = {
updatedAt: 1,
status: "allowed",
representativeClaim: "five_hour",
fiveHour: { utilization: 0.26, resetsAt: 1775455200 },
sevenDay: { utilization: 0.19, resetsAt: 1775973600 },
overage: null,
}

beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "claude-auth-usage-test-"))
originalUserProfile = process.env.USERPROFILE
originalHome = process.env.HOME
originalLocalAppData = process.env.LOCALAPPDATA
// homedir() reads USERPROFILE on Windows and HOME elsewhere, so both have
// to move or this suite writes to the developer's real state file.
process.env.USERPROFILE = tmpDir
process.env.HOME = tmpDir
process.env.LOCALAPPDATA = join(tmpDir, "AppData", "Local")
resetUsageWriteCache()
})

afterEach(() => {
restore("USERPROFILE", originalUserProfile)
restore("HOME", originalHome)
restore("LOCALAPPDATA", originalLocalAppData)
rmSync(tmpDir, { recursive: true, force: true })
resetUsageWriteCache()
})

it("creates the directory and writes readable JSON", () => {
writeUsageSnapshot(snapshot)

const written = getUsageStatePaths().filter((path) => existsSync(path))
assert.ok(written.length > 0, "expected at least one snapshot on disk")
for (const path of written) {
assert.deepEqual(JSON.parse(readFileSync(path, "utf-8")), snapshot)
}
})

it("leaves no .tmp file behind, so a reader never sees a torn write", () => {
writeUsageSnapshot(snapshot)

for (const path of getUsageStatePaths()) {
assert.ok(!existsSync(`${path}.tmp`))
}
})

it("skips a write when only updatedAt differs", () => {
writeUsageSnapshot(snapshot)
const path = getUsageStatePaths().find((candidate) => existsSync(candidate))
assert.ok(path)
const first = readFileSync(path, "utf-8")

// updatedAt changes on every single response; deduplicating on it would
// defeat the check entirely and rewrite both files continuously.
writeUsageSnapshot({ ...snapshot, updatedAt: 999 })

assert.equal(readFileSync(path, "utf-8"), first)
})

it("writes again once a reading actually changes", () => {
writeUsageSnapshot(snapshot)
writeUsageSnapshot({
...snapshot,
updatedAt: 999,
fiveHour: { utilization: 0.5, resetsAt: 1775455200 },
})

const path = getUsageStatePaths().find((candidate) => existsSync(candidate))
assert.ok(path)
const stored = JSON.parse(readFileSync(path, "utf-8")) as UsageSnapshot
assert.equal(stored.fiveHour?.utilization, 0.5)
})

it("recordUsageFromHeaders persists a parsed response", () => {
recordUsageFromHeaders(quotaHeaders())

const path = getUsageStatePaths().find((candidate) => existsSync(candidate))
assert.ok(path)
const stored = JSON.parse(readFileSync(path, "utf-8")) as UsageSnapshot
assert.equal(stored.fiveHour?.utilization, 0.26)
})

it("recordUsageFromHeaders writes nothing for a quota-less response", () => {
recordUsageFromHeaders(new Headers({ "content-type": "application/json" }))

assert.ok(getUsageStatePaths().every((path) => !existsSync(path)))
})
})
Loading
Loading