diff --git a/action/README.md b/action/README.md index c5b54f6..a71e4c0 100644 --- a/action/README.md +++ b/action/README.md @@ -36,6 +36,8 @@ jobs: CODEX_AUTH_JSON: ${{ secrets.KYORA_CODEX_AUTH }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.KYORA_CLAUDE_TOKEN }} KIMI_API_KEY: ${{ secrets.KYORA_KIMI_KEY }} + ZAI_API_KEY: ${{ secrets.KYORA_ZAI_KEY }} + QWEN_API_KEY: ${{ secrets.KYORA_QWEN_KEY }} GROK_API_KEY: ${{ secrets.KYORA_GROK_KEY }} ``` @@ -48,6 +50,8 @@ Seed whichever engines you pay for — the action auto-detects which credentials | codex | `KYORA_CODEX_AUTH` | log in locally, then `gh secret set KYORA_CODEX_AUTH < ~/.codex/auth.json` | | claude | `KYORA_CLAUDE_TOKEN` | `claude setup-token`, paste into `gh secret set KYORA_CLAUDE_TOKEN` | | kimi | `KYORA_KIMI_KEY` | API key from your Kimi membership (platform.kimi.ai) | +| glm | `KYORA_ZAI_KEY` | GLM Coding Plan key from z.ai | +| qwen | `KYORA_QWEN_KEY` | Alibaba Token Plan key (Model Studio console) | | grok | `KYORA_GROK_KEY` | API key from console.x.ai | Codex is subscription-OAuth: `auth.json` holds a refresh token, and the CLI rotates the access token on every run. With `persist-auth: true` (default) the refreshed file is cached between runs and preferred over the seeded secret, so you seed **once** and it keeps itself alive. Claude's `setup-token` output is long-lived; Kimi and Grok keys don't rotate. diff --git a/action/action.yml b/action/action.yml index 4bd96d1..c6f5cf6 100644 --- a/action/action.yml +++ b/action/action.yml @@ -35,7 +35,9 @@ runs: if: inputs.persist-auth == 'true' uses: actions/cache/restore@v4 with: - path: ~/.codex + path: | + ~/.codex + ~/.local/state/kyora-review key: kyora-review-auth-${{ runner.os }}-${{ github.run_id }} restore-keys: | kyora-review-auth-${{ runner.os }}- @@ -50,6 +52,8 @@ runs: { [ -n "$CODEX_AUTH_JSON" ] || [ -f "$HOME/.codex/auth.json" ]; } && engines="codex" [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ] && engines="$engines,claude" [ -n "$KIMI_API_KEY" ] && engines="$engines,kimi" + [ -n "$ZAI_API_KEY" ] && engines="$engines,glm" + [ -n "$QWEN_API_KEY" ] && engines="$engines,qwen" { [ -n "$GROK_API_KEY" ] || [ -n "$XAI_API_KEY" ]; } && engines="$engines,grok" engines="${engines#,}" fi @@ -69,15 +73,12 @@ runs: fi command -v codex >/dev/null 2>&1 || npm install -g @openai/codex fi - if has claude || has kimi; then + if has claude || has kimi || has glm || has qwen; then command -v claude >/dev/null 2>&1 || npm install -g @anthropic-ai/claude-code fi if has grok; then command -v grok >/dev/null 2>&1 || npm install -g @xai-official/grok fi - if has qwen; then - command -v qwen >/dev/null 2>&1 || npm install -g @qwen-code/qwen-code - fi - name: Run review if: steps.setup.outputs.engines != '' @@ -101,5 +102,7 @@ runs: if: always() && inputs.persist-auth == 'true' && steps.setup.outputs.engines != '' uses: actions/cache/save@v4 with: - path: ~/.codex + path: | + ~/.codex + ~/.local/state/kyora-review key: kyora-review-auth-${{ runner.os }}-${{ github.run_id }} diff --git a/packages/review/cli/README.md b/packages/review/cli/README.md index f9b7dcf..2e19d94 100644 --- a/packages/review/cli/README.md +++ b/packages/review/cli/README.md @@ -51,6 +51,22 @@ By default every available engine runs; pick explicitly with `--engines codex,ki `kyora-review.config.json` at the repo root can set the same keys permanently, plus per-engine overrides (`bin`, `args` with `{prompt}`/`{schema}`/`{out}` tokens, `env`) if a vendor CLI changes its flags. +## Quota awareness + +Every engine run records its outcome in local state (`~/.local/state/kyora-review/usage.json`). An engine that hits a usage limit is put on cooldown — vendor "try again in N hours" hints are parsed when present, otherwise `cooldownMinutes` (default 60) applies — and skipped on subsequent runs until it expires (`--ignore-quota` forces it). `kyora-review usage` shows the state. + +`--max-engines ` (or `maxEngines` in config) runs only the n least-recently-used healthy engines per review, rotating load across your subscriptions instead of burning all of them on every PR. In CI the state persists between runs via the action's cache. + +Engines with a vendor-side usage API additionally get a **live probe**, consulted before launching — a probed engine at 0% is skipped before spending a request, and any probe that can't run (missing auth, endpoint change) silently falls back to the cooldown mechanism: + +| engine | source | auth | +| --- | --- | --- | +| `claude` | `api.anthropic.com/api/oauth/usage` (5h + 7d windows) | Claude Code's own login (file or macOS Keychain) — nothing to configure | +| `glm` | `api.z.ai/api/monitor/usage/quota/limit` (5h + weekly + monthly) | the coding-plan key already used for inference | +| `kimi` | `api.kimi.com/coding/v1/usages` (weekly + 5h) | `KIMI_API_KEY` | +| `qwen` | Bailian console token-plan endpoint | `QWEN_USAGE_COOKIE` — console session cookie, expires after days; optional | +| `codex`, `grok` | no vendor endpoint exists for subscription limits | cooldown fallback only | + ## CI Use the GitHub Action — install once, seed each subscription's token as a repo secret, and tokens that rotate (Codex) are auto-refreshed via cache: see [`action/README.md`](https://github.com/eliahilse/kyora/tree/main/action). diff --git a/packages/review/cli/src/engines.ts b/packages/review/cli/src/engines.ts index 5d046d5..5d5d9f1 100644 --- a/packages/review/cli/src/engines.ts +++ b/packages/review/cli/src/engines.ts @@ -3,6 +3,68 @@ import { mkdtemp, rm } from "node:fs/promises" import { homedir, tmpdir } from "node:os" import { join } from "node:path" import type { EngineOverride, ReviewConfig } from "./types" +import { + looksRateLimited, + markRun, + parseClaudeOauthUsage, + parseQuotaWindows, + parseTokenPlanUsage, + parseZaiQuota, + resetHintMs, + type LiveUsage, +} from "./usage" + +async function probeJson(url: string, headers: Record): Promise { + try { + const response = await fetch(url, { headers, redirect: "error", signal: AbortSignal.timeout(5000) }) + if (!response.ok) return null + return await response.json() + } catch { + return null + } +} + +async function claudeToken(): Promise { + try { + const creds = JSON.parse(readFileSync(join(homedir(), ".claude", ".credentials.json"), "utf8")) + if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth.accessToken + } catch {} + if (process.platform === "darwin") { + const result = await Bun.$`security find-generic-password -s "Claude Code-credentials" -w`.quiet().nothrow() + if (result.exitCode === 0) { + try { + return JSON.parse(result.text().trim())?.claudeAiOauth?.accessToken ?? undefined + } catch {} + } + } + return undefined +} + +async function claudeUsageProbe(): Promise { + const token = await claudeToken() + if (!token) return null + const payload = await probeJson("https://api.anthropic.com/api/oauth/usage", { + authorization: `Bearer ${token}`, + "anthropic-beta": "oauth-2025-04-20", + }) + return payload ? parseClaudeOauthUsage(payload) : null +} + +async function kimiUsageProbe(): Promise { + const key = process.env.KIMI_API_KEY + if (!key) return null + const url = process.env.KIMI_USAGE_URL ?? "https://api.kimi.com/coding/v1/usages" + const payload = await probeJson(url, { authorization: `Bearer ${key}` }) + return payload ? parseQuotaWindows(payload) : null +} + +async function glmUsageProbe(): Promise { + const key = zaiKey() + if (!key) return null + const url = process.env.ZAI_USAGE_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit" + const payload = await probeJson(url, { authorization: `Bearer ${key}` }) + return payload ? parseZaiQuota(payload) : null +} export interface EngineDef { id: string @@ -17,9 +79,28 @@ export interface EngineDef { env?: () => Record /** engine writes its final message to the {out} file instead of stdout */ readsOutFile?: boolean + /** live remaining-quota query where the vendor exposes one; null = unavailable */ + usageProbe?: () => Promise authHint: string } +async function qwenUsageProbe(): Promise { + const cookie = process.env.QWEN_USAGE_COOKIE + if (!cookie) return null + const host = process.env.QWEN_CONSOLE_HOST ?? "bailian-singapore-cs.alibabacloud.com" + try { + const response = await fetch(`https://${host}/tokenplan/personal/api/v2/usage`, { + headers: { cookie, accept: "application/json" }, + redirect: "error", + signal: AbortSignal.timeout(5000), + }) + if (!response.ok) return null + return parseTokenPlanUsage(await response.json()) + } catch { + return null + } +} + function bailianKey(): string | undefined { if (process.env.QWEN_API_KEY) return process.env.QWEN_API_KEY try { @@ -79,6 +160,7 @@ export const ENGINES: EngineDef[] = [ label: "Claude Code (Anthropic)", bin: "claude", args: CLAUDE_ARGS, + usageProbe: claudeUsageProbe, authHint: "log in once via `claude`, or set CLAUDE_CODE_OAUTH_TOKEN (created with `claude setup-token`)", }, { @@ -93,6 +175,7 @@ export const ENGINES: EngineDef[] = [ ANTHROPIC_MODEL: process.env.KIMI_MODEL ?? "kimi-k3", ANTHROPIC_API_KEY: undefined, }), + usageProbe: kimiUsageProbe, authHint: "set KIMI_API_KEY (Kimi membership / platform.kimi.ai); optional KIMI_BASE_URL, KIMI_MODEL", }, { @@ -107,6 +190,7 @@ export const ENGINES: EngineDef[] = [ ANTHROPIC_MODEL: process.env.ZAI_MODEL ?? "glm-5.2", ANTHROPIC_API_KEY: undefined, }), + usageProbe: glmUsageProbe, authHint: "set ZAI_API_KEY (GLM Coding Plan), or log in once via `opencode auth login` — the key is picked up from there", }, { @@ -129,6 +213,7 @@ export const ENGINES: EngineDef[] = [ ANTHROPIC_MODEL: process.env.QWEN_MODEL ?? "qwen3.8-max-preview", ANTHROPIC_API_KEY: undefined, }), + usageProbe: qwenUsageProbe, authHint: "set QWEN_API_KEY (Token Plan key), or run `bl config agent` once — the key is picked up from there", }, ] @@ -162,6 +247,7 @@ export interface RawRun { ok: boolean raw: string error?: string + rateLimited?: boolean durationMs: number } @@ -219,14 +305,26 @@ export async function runEngineRaw( } const durationMs = Date.now() - started + const failureContext = timedOut || exitCode !== 0 || raw.includes('"is_error":true') + const rateLimited = + failureContext && looksRateLimited(`${stderr.slice(-2000)}\n${raw.slice(-2000)}`) + const cooldownMs = resetHintMs(`${stderr}\n${raw}`.slice(-3000)) ?? config.cooldownMinutes * 60_000 + markRun(engine.id, rateLimited ? "rate_limited" : failureContext ? "error" : "ok", cooldownMs) + if (timedOut) { - return { ok: false, raw, error: `timed out after ${Math.round(config.timeoutMs / 1000)}s`, durationMs } + return { ok: false, raw, error: `timed out after ${Math.round(config.timeoutMs / 1000)}s`, rateLimited, durationMs } } if (exitCode !== 0 && !raw.trim()) { const tail = stderr.trim().split("\n").slice(-4).join("\n") - return { ok: false, raw, error: `exit ${exitCode}: ${tail || "no output"}`, durationMs } + return { + ok: false, + raw, + error: `${rateLimited ? "rate-limited: " : ""}exit ${exitCode}: ${tail || "no output"}`, + rateLimited, + durationMs, + } } - return { ok: true, raw, durationMs } + return { ok: true, raw, rateLimited, durationMs } } catch (error) { return { ok: false, raw: "", error: String(error), durationMs: Date.now() - started } } finally { diff --git a/packages/review/cli/src/index.ts b/packages/review/cli/src/index.ts index e009dbc..930b58c 100644 --- a/packages/review/cli/src/index.ts +++ b/packages/review/cli/src/index.ts @@ -8,6 +8,7 @@ import { mergeFindings } from "./merge" import { renderReport } from "./report" import { FINDINGS_SCHEMA, reviewPrompt } from "./schema" import { SEVERITIES, SEVERITY_RANK, type EngineRun, type PrInfo, type ReviewConfig, type Severity } from "./types" +import { cooldownRemainingMs, lastRunAt, loadUsage } from "./usage" import { verifySingles } from "./verify" const DEFAULTS: ReviewConfig = { @@ -19,6 +20,8 @@ const DEFAULTS: ReviewConfig = { maxDiffBytes: 100_000, timeoutMs: 900_000, maxFindingsPerEngine: 20, + cooldownMinutes: 60, + maxEngines: 0, overrides: {}, } @@ -34,14 +37,17 @@ const HELP = `kyora-review — multi-engine AI code review on your own subscript usage: kyora-review [review] [options] review the working branch (diff vs --base) kyora-review doctor show engine availability and auth hints + kyora-review usage show per-engine quota state and cooldowns options: --pr review a GitHub PR (resolves base, enables --post) --base base ref for local mode (default: main) - --engines comma-separated: codex,claude,kimi,grok,qwen (default: all available) + --engines comma-separated: codex,claude,kimi,glm,grok,qwen (default: all available) --verify cross-examine single-engine findings with another engine --post submit results as a PR review (requires --pr + GITHUB_TOKEN or gh) --fail-on exit 1 if findings at/above severity (critical|major|minor|nit) + --max-engines run only the n least-recently-used healthy engines (spread quota) + --ignore-quota run engines even while they are cooling down after a rate limit --out also write the markdown report to a file --json print machine-readable JSON instead of markdown -h, --help this help @@ -65,6 +71,8 @@ interface Flags { verify?: boolean post?: boolean "fail-on"?: string + "max-engines"?: string + "ignore-quota"?: boolean out?: string json?: boolean help?: boolean @@ -97,6 +105,7 @@ async function review(flags: Flags): Promise { ...(flags.verify ? { verify: true } : {}), ...(flags.post ? { post: true } : {}), ...(flags["fail-on"] ? { failOn: flags["fail-on"] as Severity } : {}), + ...(flags["max-engines"] ? { maxEngines: parseInt(flags["max-engines"], 10) || 0 } : {}), overrides: { ...fileConfig.overrides }, } if (config.failOn !== "none" && !(SEVERITIES as string[]).includes(config.failOn)) { @@ -125,11 +134,46 @@ async function review(flags: Flags): Promise { return } - const selected = selectEngines(config) + let selected = selectEngines(config) if (selected.length === 0) { log("no review engines available on this machine — run `kyora-review doctor` to see how to enable them") return } + + const usage = loadUsage() + if (!flags["ignore-quota"]) { + for (const engine of selected) { + const remaining = cooldownRemainingMs(engine.id, usage) + if (remaining > 0) log(`${engine.id}: cooling down after a rate limit (${Math.ceil(remaining / 60_000)}m left) — skipped`) + } + selected = selected.filter((engine) => cooldownRemainingMs(engine.id, usage) === 0) + if (selected.length === 0) { + log("every available engine is cooling down — nothing launched (use --ignore-quota to force)") + return + } + } + const probed = await Promise.all( + selected.map(async (engine) => ({ engine, live: engine.usageProbe ? await engine.usageProbe() : null })), + ) + for (const { engine, live } of probed) { + if (live) log(`${engine.id}: live quota ${live.remainingPct}% — ${live.detail}`) + } + if (!flags["ignore-quota"]) { + for (const { engine, live } of probed) { + if (live !== null && live.remainingPct <= 0) log(`${engine.id}: out of quota per live probe — skipped`) + } + selected = probed.filter(({ live }) => live === null || live.remainingPct > 0).map(({ engine }) => engine) + if (selected.length === 0) { + log("every available engine is out of quota — nothing launched (use --ignore-quota to force)") + return + } + } + if (config.maxEngines > 0 && selected.length > config.maxEngines) { + selected = [...selected] + .sort((a, b) => lastRunAt(a.id, usage) - lastRunAt(b.id, usage)) + .slice(0, config.maxEngines) + log(`quota rotation: least-recently-used ${config.maxEngines} of the panel — ${selected.map((engine) => engine.id).join(", ")}`) + } log(`reviewing ${ctx.changedFiles.length} changed file(s) with: ${selected.map((engine) => engine.id).join(", ")}`) const ciCovered = await ciCoveredCommands(root) @@ -139,6 +183,7 @@ async function review(flags: Flags): Promise { selected.map(async (engine): Promise => { log(`${engine.id}: starting`) const raw = await runEngineRaw(engine, prompt, FINDINGS_SCHEMA, root, config) + if (raw.rateLimited) log(`${engine.id}: hit a usage limit — cooling down for future runs`) if (!raw.ok) { log(`${engine.id}: failed (${raw.error})`) return { engine: engine.id, ok: false, findings: [], error: raw.error ?? "failed", durationMs: raw.durationMs } @@ -189,6 +234,24 @@ async function review(flags: Flags): Promise { } } +async function usageReport(): Promise { + const state = loadUsage() + console.log("kyora-review usage\n") + for (const engine of ENGINES) { + const entry = state.engines[engine.id] + const live = engine.usageProbe ? await engine.usageProbe() : null + const liveNote = live ? ` live: ${live.remainingPct}% (${live.detail})` : "" + if (!entry?.lastRun) { + console.log(`${engine.id.padEnd(8)} never run${liveNote}`) + continue + } + const ago = Math.round((Date.now() - entry.lastRun) / 60_000) + const cooldown = cooldownRemainingMs(engine.id, state) + const status = cooldown > 0 ? `cooling down, ${Math.ceil(cooldown / 60_000)}m left` : (entry.lastOutcome ?? "ok") + console.log(`${engine.id.padEnd(8)} ${String(entry.runs ?? 0).padStart(3)} run(s) last: ${ago}m ago ${status}${liveNote}`) + } +} + async function doctor(): Promise { const root = await repoRoot() const fileConfig = root ? await loadConfig(root) : {} @@ -196,7 +259,9 @@ async function doctor(): Promise { for (const engine of ENGINES) { const status = engineStatus(engine, fileConfig.overrides?.[engine.id]) const mark = status.available ? "✓" : "✗" - console.log(`${mark} ${engine.id.padEnd(8)} ${engine.label}`) + const cooldown = cooldownRemainingMs(engine.id) + const quotaNote = cooldown > 0 ? ` (cooling down, ${Math.ceil(cooldown / 60_000)}m left)` : "" + console.log(`${mark} ${engine.id.padEnd(8)} ${engine.label}${quotaNote}`) console.log(` ${status.available ? "ready" : status.reason} — ${engine.authHint}`) } const available = ENGINES.filter((engine) => engineStatus(engine, fileConfig.overrides?.[engine.id]).available) @@ -218,6 +283,8 @@ async function main(): Promise { verify: { type: "boolean" }, post: { type: "boolean" }, "fail-on": { type: "string" }, + "max-engines": { type: "string" }, + "ignore-quota": { type: "boolean" }, out: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" }, @@ -230,6 +297,7 @@ async function main(): Promise { return } if (command === "doctor") return doctor() + if (command === "usage") return usageReport() if (command === "review") return review(flags) die(`unknown command "${command}" — try: review, doctor, help`) } diff --git a/packages/review/cli/src/types.ts b/packages/review/cli/src/types.ts index 84268cc..29168a5 100644 --- a/packages/review/cli/src/types.ts +++ b/packages/review/cli/src/types.ts @@ -74,5 +74,7 @@ export interface ReviewConfig { maxDiffBytes: number timeoutMs: number maxFindingsPerEngine: number + cooldownMinutes: number + maxEngines: number overrides: Record } diff --git a/packages/review/cli/src/usage.test.ts b/packages/review/cli/src/usage.test.ts new file mode 100644 index 0000000..c3dfac1 --- /dev/null +++ b/packages/review/cli/src/usage.test.ts @@ -0,0 +1,121 @@ +import { beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { cooldownRemainingMs, lastRunAt, loadUsage, looksRateLimited, markRun, parseClaudeOauthUsage, parseQuotaWindows, parseTokenPlanUsage, parseZaiQuota, resetHintMs } from "./usage" + +beforeEach(() => { + process.env.KYORA_REVIEW_STATE_DIR = mkdtempSync(join(tmpdir(), "kyora-usage-")) +}) + +describe("usage state", () => { + test("rate_limited sets a cooldown, ok clears it", () => { + markRun("codex", "rate_limited", 60_000) + expect(cooldownRemainingMs("codex")).toBeGreaterThan(0) + markRun("codex", "ok", 60_000) + expect(cooldownRemainingMs("codex")).toBe(0) + expect(loadUsage().engines.codex!.runs).toBe(2) + }) + + test("lastRunAt orders engines for rotation", () => { + markRun("grok", "ok", 0) + expect(lastRunAt("grok")).toBeGreaterThan(0) + expect(lastRunAt("never-ran")).toBe(0) + }) + + test("missing state file reads as empty", () => { + expect(loadUsage()).toEqual({ engines: {} }) + expect(cooldownRemainingMs("codex")).toBe(0) + }) +}) + +describe("looksRateLimited", () => { + test("matches vendor limit messages", () => { + expect(looksRateLimited("Claude usage limit reached. Your limit resets at 7pm")).toBe(true) + expect(looksRateLimited("HTTP 429 Too Many Requests")).toBe(true) + expect(looksRateLimited("insufficient_quota: You exceeded your current quota")).toBe(true) + expect(looksRateLimited("You've hit your usage limit.")).toBe(true) + }) + + test("ignores ordinary failures", () => { + expect(looksRateLimited("SyntaxError: unexpected token")).toBe(false) + expect(looksRateLimited("exit 1: command not found")).toBe(false) + }) +}) + +describe("parseTokenPlanUsage", () => { + test("finds used/total pairs regardless of casing and nesting", () => { + const payload = { code: 200, data: { subscription: { UsedCredit: 250, TotalCredit: 1000 } } } + expect(parseTokenPlanUsage(payload)).toEqual({ remainingPct: 75, detail: "750 of 1,000 credits left" }) + }) + + test("returns null for empty or zero-total payloads", () => { + expect(parseTokenPlanUsage({ data: { totalCount: 0 } })).toBeNull() + expect(parseTokenPlanUsage(null)).toBeNull() + expect(parseTokenPlanUsage("login")).toBeNull() + }) +}) + +describe("parseQuotaWindows", () => { + test("collects windows and reports the tightest one", () => { + const payload = { + usage: { limit: 2048, used: 512, remaining: 1536, resetTime: "2026-08-03T00:00:00Z" }, + limits: [{ window: "5h", limit: 200, used: 190, remaining: 10 }], + } + const live = parseQuotaWindows(payload) + expect(live!.remainingPct).toBe(5) + expect(live!.detail).toContain("512/2,048") + expect(live!.detail).toContain("190/200 5h") + }) + + test("returns null when no used/limit pairs exist", () => { + expect(parseQuotaWindows({ message: "ok" })).toBeNull() + expect(parseQuotaWindows(null)).toBeNull() + }) +}) + +describe("parseZaiQuota", () => { + test("reads percentage windows from the live monitor shape", () => { + const payload = { + code: 200, + data: { + limits: [ + { type: "TIME_LIMIT", unit: 5, number: 1, usage: 100, currentValue: 0, remaining: 100, percentage: 12 }, + { type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 87 }, + ], + }, + } + const live = parseZaiQuota(payload) + expect(live!.remainingPct).toBe(13) + expect(live!.detail).toBe("time 12% used · tokens 87% used") + }) + + test("falls back to used/limit pairs when no percentages exist", () => { + expect(parseZaiQuota({ data: { used: 30, limit: 100 } })!.remainingPct).toBe(70) + }) +}) + +describe("parseClaudeOauthUsage", () => { + test("reads window utilization in fraction or percent form", () => { + const live = parseClaudeOauthUsage({ five_hour: { utilization: 0.42 }, seven_day: { utilization: 61 } }) + expect(live!.remainingPct).toBe(39) + expect(live!.detail).toBe("5h 42% used · 7d 61% used") + }) + + test("returns null without usable windows", () => { + expect(parseClaudeOauthUsage({ subscriptionType: "max" })).toBeNull() + }) +}) + +describe("resetHintMs", () => { + test("parses duration hints", () => { + expect(resetHintMs("Please try again in 3 hours.")).toBe(3 * 3_600_000) + expect(resetHintMs("retry after 90 seconds")).toBe(90_000) + expect(resetHintMs("resets in 45 minutes")).toBe(45 * 60_000) + }) + + test("returns null without a parseable duration", () => { + expect(resetHintMs("limit resets at 7pm")).toBeNull() + expect(resetHintMs("try again later")).toBeNull() + }) +}) diff --git a/packages/review/cli/src/usage.ts b/packages/review/cli/src/usage.ts new file mode 100644 index 0000000..e59393e --- /dev/null +++ b/packages/review/cli/src/usage.ts @@ -0,0 +1,218 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +export type RunOutcome = "ok" | "rate_limited" | "error" + +export interface EngineUsage { + lastRun?: number + lastOutcome?: RunOutcome + cooldownUntil?: number + runs?: number +} + +export interface UsageState { + engines: Record +} + +function stateDir(): string { + return ( + process.env.KYORA_REVIEW_STATE_DIR ?? + join(process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state"), "kyora-review") + ) +} + +export function loadUsage(): UsageState { + try { + const parsed = JSON.parse(readFileSync(join(stateDir(), "usage.json"), "utf8")) as UsageState + return parsed.engines ? parsed : { engines: {} } + } catch { + return { engines: {} } + } +} + +export function markRun(engineId: string, outcome: RunOutcome, cooldownMs: number): void { + const state = loadUsage() + const entry: EngineUsage = state.engines[engineId] ?? {} + entry.lastRun = Date.now() + entry.lastOutcome = outcome + entry.runs = (entry.runs ?? 0) + 1 + if (outcome === "rate_limited") entry.cooldownUntil = Date.now() + cooldownMs + else delete entry.cooldownUntil + state.engines[engineId] = entry + try { + mkdirSync(stateDir(), { recursive: true }) + writeFileSync(join(stateDir(), "usage.json"), JSON.stringify(state, null, 2)) + } catch {} +} + +export function cooldownRemainingMs(engineId: string, state: UsageState = loadUsage()): number { + const until = state.engines[engineId]?.cooldownUntil ?? 0 + return Math.max(0, until - Date.now()) +} + +export function lastRunAt(engineId: string, state: UsageState = loadUsage()): number { + return state.engines[engineId]?.lastRun ?? 0 +} + +const RATE_LIMIT_PATTERN = + /rate.?limit|too many requests|\b429\b|usage.?limit|quota (?:exceeded|reached|exhausted)|(?:exceeded|reached|exhausted) (?:your )?quota|limit (?:reached|exceeded)|insufficient[_ ](?:quota|credits?|balance)|out of credits|overloaded_error/i + +export function looksRateLimited(text: string): boolean { + return RATE_LIMIT_PATTERN.test(text) +} + +export interface LiveUsage { + remainingPct: number + detail: string +} + +/** + * Tolerant extraction of used/total credit figures from the undocumented + * Bailian token-plan console payload — field casing and nesting drift, so any + * object carrying a used+total numeric pair counts. + */ +export function parseTokenPlanUsage(payload: unknown): LiveUsage | null { + const found = findUsagePair(payload) + if (!found || found.total <= 0) return null + const remaining = Math.max(0, found.total - found.used) + return { + remainingPct: Math.round((remaining / found.total) * 100), + detail: `${remaining.toLocaleString()} of ${found.total.toLocaleString()} credits left`, + } +} + +function findUsagePair(node: unknown): { used: number; total: number } | null { + if (node === null || typeof node !== "object") return null + const obj = node as Record + let used: number | null = null + let total: number | null = null + for (const [key, value] of Object.entries(obj)) { + if (typeof value !== "number") continue + const lower = key.toLowerCase() + if (lower.includes("used")) used = value + else if (lower.includes("total")) total = value + } + if (used !== null && total !== null) return { used, total } + for (const value of Object.values(obj)) { + const nested = findUsagePair(value) + if (nested) return nested + } + return null +} + +/** + * Generic quota-window extraction for vendor usage payloads (kimi, z.ai): + * collects objects carrying a used+limit numeric pair; the tightest window + * determines the remaining percentage. + */ +export function parseQuotaWindows(payload: unknown): LiveUsage | null { + const windows: { used: number; limit: number; label: string }[] = [] + collectWindows(payload, windows) + let worst = -1 + const parts: string[] = [] + for (const window of windows.slice(0, 3)) { + const pct = Math.max(0, Math.round((1 - window.used / window.limit) * 100)) + worst = worst === -1 ? pct : Math.min(worst, pct) + parts.push(`${window.used.toLocaleString()}/${window.limit.toLocaleString()}${window.label ? ` ${window.label}` : ""}`) + } + if (worst === -1) return null + return { remainingPct: worst, detail: parts.join(" · ") } +} + +function collectWindows(node: unknown, out: { used: number; limit: number; label: string }[]): void { + if (node === null || typeof node !== "object") return + if (Array.isArray(node)) { + for (const item of node) collectWindows(item, out) + return + } + const obj = node as Record + let used: number | null = null + let limit: number | null = null + for (const [key, value] of Object.entries(obj)) { + if (typeof value !== "number") continue + const lower = key.toLowerCase() + if (lower.includes("used") || lower === "usage") used = value + else if (lower.includes("limit") || lower.includes("total") || lower.includes("quota")) limit = value + } + if (used !== null && limit !== null && limit > 0) { + const label = ["window", "scope", "name", "type", "period"] + .map((key) => obj[key]) + .find((value): value is string => typeof value === "string") + out.push({ used, limit, label: label ?? "" }) + return + } + for (const value of Object.values(obj)) collectWindows(value, out) +} + +/** + * Z.ai's monitor payload reports per-window percentages (TIME_LIMIT / + * TOKENS_LIMIT entries with `percentage` used); the tightest window wins. + */ +export function parseZaiQuota(payload: unknown): LiveUsage | null { + const windows: { pct: number; label: string }[] = [] + collectPercentages(payload, windows) + if (windows.length === 0) return parseQuotaWindows(payload) + const worst = Math.max(...windows.map((window) => window.pct)) + return { + remainingPct: Math.max(0, Math.round(100 - worst)), + detail: windows + .slice(0, 3) + .map((window) => `${window.label}${Math.round(window.pct)}% used`) + .join(" · "), + } +} + +function collectPercentages(node: unknown, out: { pct: number; label: string }[]): void { + if (node === null || typeof node !== "object") return + if (Array.isArray(node)) { + for (const item of node) collectPercentages(item, out) + return + } + const obj = node as Record + if (typeof obj.percentage === "number") { + const label = typeof obj.type === "string" ? `${obj.type.toLowerCase().replace("_limit", "")} ` : "" + out.push({ pct: obj.percentage, label }) + } + for (const value of Object.values(obj)) collectPercentages(value, out) +} + +/** + * Claude's OAuth usage payload reports window utilization rather than raw + * counts: five_hour / seven_day objects with a percentage (0-1 or 0-100). + */ +export function parseClaudeOauthUsage(payload: unknown): LiveUsage | null { + if (payload === null || typeof payload !== "object") return null + const obj = payload as Record + const parts: string[] = [] + let worst = -1 + for (const [key, label] of [["five_hour", "5h"], ["seven_day", "7d"]] as const) { + const window = obj[key] + if (window === null || typeof window !== "object") continue + const util = utilizationOf(window as Record) + if (util === null) continue + worst = Math.max(worst, util) + parts.push(`${label} ${Math.round(util)}% used`) + } + if (worst === -1) return null + return { remainingPct: Math.max(0, Math.round(100 - worst)), detail: parts.join(" · ") } +} + +function utilizationOf(window: Record): number | null { + for (const [key, value] of Object.entries(window)) { + if (typeof value !== "number") continue + if (/util|percent|pct/i.test(key)) return value < 1 ? value * 100 : value + } + return null +} + +/** best-effort parse of "try again in 3 hours" / "retry after 90 seconds" style hints */ +export function resetHintMs(text: string): number | null { + const match = /(?:try again|retry|resets?)[^\d\n]{0,24}(\d+(?:\.\d+)?)\s*(hours?|h\b|minutes?|min\b|m\b|seconds?|s\b)/i.exec(text) + if (!match) return null + const value = parseFloat(match[1]!) + const unit = match[2]!.toLowerCase() + if (unit.startsWith("h")) return value * 3_600_000 + if (unit.startsWith("m")) return value * 60_000 + return value * 1000 +}