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: 4 additions & 0 deletions action/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
```

Expand All @@ -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.
Expand Down
15 changes: 9 additions & 6 deletions action/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}-
Expand All @@ -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
Expand All @@ -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 != ''
Expand All @@ -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 }}
16 changes: 16 additions & 0 deletions packages/review/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <n>` (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).
Expand Down
104 changes: 101 additions & 3 deletions packages/review/cli/src/engines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>): Promise<unknown | null> {
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<string | undefined> {
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<LiveUsage | null> {
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<LiveUsage | null> {
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<LiveUsage | null> {
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
Expand All @@ -17,9 +79,28 @@ export interface EngineDef {
env?: () => Record<string, string | undefined>
/** 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<LiveUsage | null>
authHint: string
}

async function qwenUsageProbe(): Promise<LiveUsage | null> {
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 {
Expand Down Expand Up @@ -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`)",
},
{
Expand All @@ -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",
},
{
Expand All @@ -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",
},
{
Expand All @@ -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",
},
]
Expand Down Expand Up @@ -162,6 +247,7 @@ export interface RawRun {
ok: boolean
raw: string
error?: string
rateLimited?: boolean
durationMs: number
}

Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading