mirror of
https://github.com/lemon07r/opencode-kimi-full.git
synced 2026-06-02 06:14:16 +02:00
fix: address usage review findings
This commit is contained in:
@@ -4,10 +4,12 @@
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "opencode-kimi-full",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.4.7",
|
||||
"dependencies": {
|
||||
"@opentui/core": "0.1.99",
|
||||
"@opentui/solid": "0.1.99",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.4.7",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
},
|
||||
|
||||
+4
-2
@@ -45,10 +45,12 @@
|
||||
"engines": {
|
||||
"opencode": ">=1.4.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "0.1.99",
|
||||
"@opentui/solid": "0.1.99"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.4.7",
|
||||
"@opentui/core": "0.1.99",
|
||||
"@opentui/solid": "0.1.99",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
|
||||
+74
-8
@@ -1,3 +1,4 @@
|
||||
import crypto from "node:crypto"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { PROVIDER_ID, REFRESH_SAFETY_WINDOW_MS } from "./constants.ts"
|
||||
@@ -13,6 +14,8 @@ import { refreshToken } from "./oauth.ts"
|
||||
const REFRESH_LOCK_WAIT_MS = 15_000
|
||||
const REFRESH_LOCK_POLL_MS = 100
|
||||
const REFRESH_LOCK_STALE_MS = 120_000
|
||||
const REFRESH_LOCK_HEARTBEAT_MS = 30_000
|
||||
const REFRESH_LOCK_OWNER_FILE = "owner.json"
|
||||
|
||||
type RefreshOptions = {
|
||||
force?: boolean
|
||||
@@ -24,6 +27,10 @@ function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown, code: string) {
|
||||
return (error as NodeJS.ErrnoException).code === code
|
||||
}
|
||||
|
||||
function sameAuth(left: OAuthAuth, right: OAuthAuth) {
|
||||
return left.access === right.access && left.refresh === right.refresh && left.expires === right.expires
|
||||
}
|
||||
@@ -42,26 +49,77 @@ export function isAuthExpiring(auth: OAuthAuth) {
|
||||
return auth.expires - Date.now() < REFRESH_SAFETY_WINDOW_MS
|
||||
}
|
||||
|
||||
function lockOwner(token: string) {
|
||||
return {
|
||||
token,
|
||||
pid: process.pid,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
async function writeLockOwner(ownerFile: string, token: string) {
|
||||
const tmpOwnerFile = `${ownerFile}.${process.pid}.${crypto.randomUUID()}.tmp`
|
||||
try {
|
||||
await fs.writeFile(tmpOwnerFile, JSON.stringify(lockOwner(token)), "utf8")
|
||||
await fs.rename(tmpOwnerFile, ownerFile)
|
||||
} catch (error) {
|
||||
await fs.rm(tmpOwnerFile, { force: true }).catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function ownsLock(ownerFile: string, token: string) {
|
||||
try {
|
||||
const data = JSON.parse(await fs.readFile(ownerFile, "utf8")) as { token?: unknown }
|
||||
return data.token === token
|
||||
} catch (error) {
|
||||
if (isNodeError(error, "ENOENT") || error instanceof SyntaxError) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStaleLock(lockDir: string, ownerFile: string) {
|
||||
const stat = await fs.stat(ownerFile).catch(async (error) => {
|
||||
if (!isNodeError(error, "ENOENT")) throw error
|
||||
return fs.stat(lockDir).catch((lockError) => {
|
||||
if (isNodeError(lockError, "ENOENT")) return
|
||||
throw lockError
|
||||
})
|
||||
})
|
||||
if (!stat) return true
|
||||
if (Date.now() - stat.mtimeMs <= REFRESH_LOCK_STALE_MS) return false
|
||||
|
||||
const staleDir = `${lockDir}.stale.${process.pid}.${Date.now()}.${crypto.randomUUID()}`
|
||||
try {
|
||||
await fs.rename(lockDir, staleDir)
|
||||
} catch (error) {
|
||||
if (isNodeError(error, "ENOENT")) return true
|
||||
throw error
|
||||
}
|
||||
await fs.rm(staleDir, { recursive: true, force: true })
|
||||
return true
|
||||
}
|
||||
|
||||
async function withRefreshLock<T>(work: () => Promise<T>) {
|
||||
const authFile = await resolveAuthStorePath()
|
||||
const lockDir = `${authFile}.${PROVIDER_ID}.refresh.lock`
|
||||
const ownerFile = path.join(lockDir, REFRESH_LOCK_OWNER_FILE)
|
||||
const ownerToken = crypto.randomUUID()
|
||||
await fs.mkdir(path.dirname(lockDir), { recursive: true })
|
||||
const deadline = Date.now() + REFRESH_LOCK_WAIT_MS
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
await fs.mkdir(lockDir)
|
||||
await writeLockOwner(ownerFile, ownerToken).catch(async (error) => {
|
||||
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
throw error
|
||||
})
|
||||
break
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== "EEXIST") throw error
|
||||
try {
|
||||
const stat = await fs.stat(lockDir)
|
||||
if (Date.now() - stat.mtimeMs > REFRESH_LOCK_STALE_MS) {
|
||||
await fs.rm(lockDir, { recursive: true, force: true })
|
||||
continue
|
||||
}
|
||||
} catch {}
|
||||
if (await removeStaleLock(lockDir, ownerFile)) continue
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error("kimi oauth: timed out waiting for the auth refresh lock")
|
||||
}
|
||||
@@ -69,10 +127,18 @@ async function withRefreshLock<T>(work: () => Promise<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
writeLockOwner(ownerFile, ownerToken).catch(() => undefined)
|
||||
}, REFRESH_LOCK_HEARTBEAT_MS)
|
||||
heartbeat.unref?.()
|
||||
|
||||
try {
|
||||
return await work()
|
||||
} finally {
|
||||
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined)
|
||||
clearInterval(heartbeat)
|
||||
await ownsLock(ownerFile, ownerToken)
|
||||
.then((owned) => (owned ? fs.rm(lockDir, { recursive: true, force: true }) : undefined))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-2
@@ -111,6 +111,8 @@ function UsageDialog(props: { api: TuiPluginApi; rows?: UsageRow[]; loading?: bo
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
let usageRequestId = 0
|
||||
|
||||
api.command.register(() => [
|
||||
{
|
||||
title: "Kimi usage",
|
||||
@@ -121,13 +123,28 @@ const tui: TuiPlugin = async (api) => {
|
||||
name: "kimi:usage",
|
||||
},
|
||||
onSelect: async () => {
|
||||
api.ui.dialog.replace(() => <UsageDialog api={api} loading />)
|
||||
const requestId = ++usageRequestId
|
||||
let dismissed = false
|
||||
let replacing = false
|
||||
const isCurrent = () => usageRequestId === requestId && !dismissed
|
||||
const markDismissed = () => {
|
||||
if (!replacing) dismissed = true
|
||||
}
|
||||
|
||||
api.ui.dialog.replace(() => <UsageDialog api={api} loading />, markDismissed)
|
||||
try {
|
||||
const auth = await ensureFreshStoredAuth()
|
||||
const payload = await fetchUsage(auth.access)
|
||||
const rows = parseUsagePayload(payload)
|
||||
api.ui.dialog.replace(() => <UsageDialog api={api} rows={rows} />)
|
||||
if (!isCurrent()) return
|
||||
replacing = true
|
||||
try {
|
||||
api.ui.dialog.replace(() => <UsageDialog api={api} rows={rows} />, markDismissed)
|
||||
} finally {
|
||||
replacing = false
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return
|
||||
api.ui.dialog.clear()
|
||||
api.ui.toast({
|
||||
message: error instanceof Error ? error.message : "Failed to fetch Kimi usage.",
|
||||
|
||||
+2
-2
@@ -78,8 +78,8 @@ function resetHint(data: Record<string, unknown>) {
|
||||
if (resetAt) return formatResetTime(String(resetAt))
|
||||
|
||||
const seconds = toInt(data.reset_in ?? data.resetIn ?? data.ttl ?? data.window)
|
||||
if (!seconds) return
|
||||
return `resets in ${formatDuration(seconds)}`
|
||||
if (seconds === undefined) return
|
||||
return seconds === 0 ? "reset now" : `resets in ${formatDuration(seconds)}`
|
||||
}
|
||||
|
||||
function toUsageRow(data: Record<string, unknown>, defaultLabel: string): UsageRow | undefined {
|
||||
|
||||
@@ -27,6 +27,10 @@ function authStorePath(base: string) {
|
||||
return path.join(base, "opencode", "auth.json")
|
||||
}
|
||||
|
||||
function refreshLockPath(base: string) {
|
||||
return `${authStorePath(base)}.${PROVIDER_ID}.refresh.lock`
|
||||
}
|
||||
|
||||
async function writeAuthStore(base: string, entry: unknown) {
|
||||
await fs.mkdir(path.dirname(authStorePath(base)), { recursive: true })
|
||||
await fs.writeFile(authStorePath(base), JSON.stringify({ [PROVIDER_ID]: entry }), "utf8")
|
||||
@@ -61,3 +65,69 @@ test("ensureFreshStoredAuth refreshes expiring auth and persists it", async () =
|
||||
expect(stored[PROVIDER_ID].refresh).toBe("refresh-2")
|
||||
expect(mock.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("ensureFreshStoredAuth removes stale refresh locks", async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-kimi-auth-refresh-"))
|
||||
previousXdgDataHome = process.env.XDG_DATA_HOME
|
||||
process.env.XDG_DATA_HOME = root
|
||||
await writeAuthStore(root, {
|
||||
type: "oauth",
|
||||
access: "stale",
|
||||
refresh: "refresh-1",
|
||||
expires: Date.now() - 1_000,
|
||||
})
|
||||
|
||||
const lockDir = refreshLockPath(root)
|
||||
await fs.mkdir(lockDir)
|
||||
await fs.writeFile(path.join(lockDir, "owner.json"), JSON.stringify({ token: "dead" }), "utf8")
|
||||
const stale = new Date(Date.now() - 180_000)
|
||||
await fs.utimes(path.join(lockDir, "owner.json"), stale, stale)
|
||||
await fs.utimes(lockDir, stale, stale)
|
||||
|
||||
mock = installFetchMock(() => ({
|
||||
body: {
|
||||
access_token: "fresh",
|
||||
refresh_token: "refresh-2",
|
||||
token_type: "Bearer",
|
||||
expires_in: 900,
|
||||
},
|
||||
}))
|
||||
|
||||
const auth = await ensureFreshStoredAuth()
|
||||
expect(auth.access).toBe("fresh")
|
||||
expect(
|
||||
await fs
|
||||
.access(lockDir)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false)
|
||||
expect(mock.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("ensureFreshStoredAuth does not fail when cleanup cannot verify lock ownership", async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-kimi-auth-refresh-"))
|
||||
previousXdgDataHome = process.env.XDG_DATA_HOME
|
||||
process.env.XDG_DATA_HOME = root
|
||||
await writeAuthStore(root, {
|
||||
type: "oauth",
|
||||
access: "stale",
|
||||
refresh: "refresh-1",
|
||||
expires: Date.now() - 1_000,
|
||||
})
|
||||
|
||||
mock = installFetchMock(async () => {
|
||||
await fs.writeFile(path.join(refreshLockPath(root!), "owner.json"), "{", "utf8")
|
||||
return {
|
||||
body: {
|
||||
access_token: "fresh",
|
||||
refresh_token: "refresh-2",
|
||||
token_type: "Bearer",
|
||||
expires_in: 900,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const auth = await ensureFreshStoredAuth()
|
||||
expect(auth.access).toBe("fresh")
|
||||
expect(mock.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -39,3 +39,15 @@ test("parseUsagePayload maps summary and rolling limits", () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("parseUsagePayload preserves immediate reset hints", () => {
|
||||
const rows = parseUsagePayload({
|
||||
usage: {
|
||||
limit: 100,
|
||||
used: 100,
|
||||
reset_in: 0,
|
||||
},
|
||||
})
|
||||
|
||||
expect(rows[0]?.resetHint).toBe("reset now")
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user