adolf-llm /usage: CLI-only token refresh (kb#87) + last-good cache fallback

Two related changes to the /usage quota route, committed together because they
are entangled in the same code path.

1. Stop refreshing the Kimi OAuth token from this route (kb#87). Was already
   present as uncommitted working-tree WIP, not authored in this commit's
   session. Kimi rotates the refresh_token on every refresh (single-use), so an
   independent refresh here invalidated the copy the CLI's creds file holds ->
   the CLI's next refresh failed invalid_grant and wiped the whole login (the
   recurring Adolf logout, incl. the 2026-07-17 06:15 wipe / task #86). Removes
   KIMI_OAUTH_HOST, KIMI_CLIENT_ID, refreshKimiToken() and the kimiMemToken
   cache; the CLI is now the sole refresher and this route only ever READS.

2. Serve the last good reading when the token is stale, instead of erroring.
   Measured 2026-07-22: the access token's expires_in is 900s, so it is only
   valid for 15 minutes after the CLI last refreshed it -- i.e. only within 15
   minutes of an actual Adolf turn. Adolf is idle most of the day, so bare
   reads failed far more often than they succeeded and quota gating was
   effectively blind. /usage now caches every success and, on a stale token,
   returns that payload with stale/as_of/age_s/stale_reason so callers can
   judge whether it is fresh enough. Cache is mirrored to the workspace volume
   so it survives restarts, and writes are best-effort so an unwritable volume
   cannot break the route. Auth behaviour is unchanged by this half.

Payload shape is additive only -- existing kimi-usage -q filters keep working.

Verified live after rebuild: fresh read returns weekly 16% / 5h 5% with
stale:false; cache file written to /workspace/.adolf-llm/usage-cache.json;
kimi-usage -q '.window_5h.pct' returns 5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 11:37:55 +00:00
parent b126e7aaed
commit 288cb6b36a

View File

@@ -448,71 +448,69 @@ async function handleTurn(messages, onDelta, signal) {
// with what `kimi` would show via its own /usage-equivalent.
//
// Token source: the CLI's own OAuth creds file, kept fresh by the running
// `kimi` process (adolf-llm-home volume). We only ever READ that file. If
// its access_token is stale/expired we refresh in memory (POST
// https://auth.kimi.com/api/oauth/token, form-encoded, grant_type=
// refresh_token — endpoint + client_id taken from the same decompiled
// KIMI_CODE_FLOW_CONFIG/refreshAccessToken) and cache the result in a
// module-level variable ONLY — we deliberately never write the refreshed
// token back to the creds file, since the live CLI owns that file and a
// racing write from here could corrupt/rotate state it depends on.
// `kimi` process (adolf-llm-home volume). We ONLY read the file's live
// access_token and never refresh here. Kimi's OAuth rotates the refresh_token
// on every refresh (single-use), so an independent refresh from this route
// invalidates the refresh_token the CLI's file still holds -> the CLI's next
// refresh fails `invalid_grant` and wipes the whole login (kb#87: this was the
// recurring Adolf logout, incl. the 2026-07-17 06:15 wipe / task #86). Making
// the CLI the sole refresher removes that race.
//
// Cost of that trade, measured 2026-07-22: the access token's `expires_in` is
// 900s, so it is only valid for 15 minutes after the CLI last refreshed it —
// i.e. only within 15 minutes of an actual Adolf turn. Adolf is idle most of
// the day, so a bare read failed far more often than it succeeded, which made
// quota gating effectively blind. Rather than refresh here (see above: that
// wipes the login), /usage now falls back to the LAST GOOD reading, clearly
// labelled `stale` with `as_of` + `age_s` so callers can decide whether it is
// fresh enough. The cache is written on every success and persisted to the
// workspace volume so it survives a container restart. Auth is untouched:
// this route still only ever READS the creds file.
const KIMI_CREDS_PATH = '/root/.kimi-code/credentials/kimi-code.json';
const KIMI_OAUTH_HOST = 'https://auth.kimi.com';
const KIMI_CLIENT_ID = '17e5f671-d194-4dfb-9706-5516cb48c098';
const KIMI_USAGES_URL = 'https://api.kimi.com/coding/v1/usages';
const KIMI_USAGE_CACHE_PATH = '/workspace/.adolf-llm/usage-cache.json';
let kimiMemToken = null; // { access_token, expires_at } — in-memory only, never persisted
// Last successful /usage payload, kept in memory and mirrored to disk.
let kimiUsageCache = null;
function readKimiUsageCache() {
if (kimiUsageCache) return kimiUsageCache;
try {
const parsed = JSON.parse(fs.readFileSync(KIMI_USAGE_CACHE_PATH, 'utf8'));
if (parsed && parsed.payload && parsed.cached_at) kimiUsageCache = parsed;
} catch { /* no cache yet, or unreadable — treated as "no cache" */ }
return kimiUsageCache;
}
function writeKimiUsageCache(payload) {
kimiUsageCache = { payload, cached_at: new Date().toISOString() };
try {
fs.mkdirSync(path.dirname(KIMI_USAGE_CACHE_PATH), { recursive: true });
fs.writeFileSync(KIMI_USAGE_CACHE_PATH, JSON.stringify(kimiUsageCache));
} catch { /* cache is best-effort; an unwritable volume must not break /usage */ }
}
async function loadKimiCreds() {
const raw = await fs.promises.readFile(KIMI_CREDS_PATH, 'utf8');
return JSON.parse(raw);
}
async function refreshKimiToken(refreshToken) {
const body = new URLSearchParams({
client_id: KIMI_CLIENT_ID,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}).toString();
const res = await fetch(`${KIMI_OAUTH_HOST}/api/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
body,
});
const data = await res.json().catch(() => ({}));
if (!res.ok || typeof data.access_token !== 'string') {
throw new Error(`kimi oauth refresh failed (HTTP ${res.status}): ${data.error || data.error_description || 'unknown error'}`);
}
return {
access_token: data.access_token,
expires_at: Math.floor(Date.now() / 1000) + Number(data.expires_in || 900),
};
}
// Resolve a usable access token, preferring the creds-file token (kept fresh
// by the live CLI) and falling back to an in-memory refresh only when that
// one is stale/expired.
async function getKimiAccessToken(forceRefresh) {
// Read the live access_token from the CLI's creds file. We deliberately do NOT
// refresh here (see the note above): the Kimi CLI is the sole refresher, so
// this route can never rotate the single-use refresh_token out from under it.
// A stale file token surfaces as an error -> /usage 502 -> "quota unavailable".
async function getKimiAccessToken() {
const creds = await loadKimiCreds();
const now = Math.floor(Date.now() / 1000);
if (!forceRefresh && creds.access_token && creds.expires_at && now < creds.expires_at - 30) {
if (creds.access_token && creds.expires_at && now < creds.expires_at - 30) {
return creds.access_token;
}
if (!forceRefresh && kimiMemToken && now < kimiMemToken.expires_at - 30) {
return kimiMemToken.access_token;
}
if (!creds.refresh_token) throw new Error('no refresh_token in kimi credentials file');
kimiMemToken = await refreshKimiToken(creds.refresh_token);
return kimiMemToken.access_token;
throw new Error('kimi access token stale (CLI refreshes on next use); quota temporarily unavailable');
}
async function fetchKimiUsagesRaw() {
let token = await getKimiAccessToken(false);
let res = await fetch(KIMI_USAGES_URL, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
if (res.status === 401) {
token = await getKimiAccessToken(true); // force one in-memory refresh + retry
res = await fetch(KIMI_USAGES_URL, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
}
const token = await getKimiAccessToken();
const res = await fetch(KIMI_USAGES_URL, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`kimi /usages HTTP ${res.status}: ${text.slice(0, 500)}`);
@@ -654,9 +652,26 @@ const server = http.createServer((req, res) => {
try {
const raw = await fetchKimiUsagesRaw();
const out = normalizeKimiUsage(raw);
writeKimiUsageCache(out);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(out));
res.end(JSON.stringify({ ...out, stale: false }));
} catch (err) {
// Token stale (the common case when Adolf has been idle >15min) or Kimi
// unreachable. Serve the last good reading rather than nothing, labelled
// so a caller can reject it if it is too old to gate on.
const cached = readKimiUsageCache();
if (cached) {
const ageS = Math.max(0, Math.round((Date.now() - Date.parse(cached.cached_at)) / 1000));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
...cached.payload,
stale: true,
as_of: cached.cached_at,
age_s: ageS,
stale_reason: String(err.message || err),
}));
return;
}
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(err.message || err) }));
}