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:
@@ -448,71 +448,69 @@ async function handleTurn(messages, onDelta, signal) {
|
|||||||
// with what `kimi` would show via its own /usage-equivalent.
|
// with what `kimi` would show via its own /usage-equivalent.
|
||||||
//
|
//
|
||||||
// Token source: the CLI's own OAuth creds file, kept fresh by the running
|
// 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
|
// `kimi` process (adolf-llm-home volume). We ONLY read the file's live
|
||||||
// its access_token is stale/expired we refresh in memory (POST
|
// access_token and never refresh here. Kimi's OAuth rotates the refresh_token
|
||||||
// https://auth.kimi.com/api/oauth/token, form-encoded, grant_type=
|
// on every refresh (single-use), so an independent refresh from this route
|
||||||
// refresh_token — endpoint + client_id taken from the same decompiled
|
// invalidates the refresh_token the CLI's file still holds -> the CLI's next
|
||||||
// KIMI_CODE_FLOW_CONFIG/refreshAccessToken) and cache the result in a
|
// refresh fails `invalid_grant` and wipes the whole login (kb#87: this was the
|
||||||
// module-level variable ONLY — we deliberately never write the refreshed
|
// recurring Adolf logout, incl. the 2026-07-17 06:15 wipe / task #86). Making
|
||||||
// token back to the creds file, since the live CLI owns that file and a
|
// the CLI the sole refresher removes that race.
|
||||||
// racing write from here could corrupt/rotate state it depends on.
|
//
|
||||||
|
// 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_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_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() {
|
async function loadKimiCreds() {
|
||||||
const raw = await fs.promises.readFile(KIMI_CREDS_PATH, 'utf8');
|
const raw = await fs.promises.readFile(KIMI_CREDS_PATH, 'utf8');
|
||||||
return JSON.parse(raw);
|
return JSON.parse(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshKimiToken(refreshToken) {
|
// Read the live access_token from the CLI's creds file. We deliberately do NOT
|
||||||
const body = new URLSearchParams({
|
// refresh here (see the note above): the Kimi CLI is the sole refresher, so
|
||||||
client_id: KIMI_CLIENT_ID,
|
// this route can never rotate the single-use refresh_token out from under it.
|
||||||
grant_type: 'refresh_token',
|
// A stale file token surfaces as an error -> /usage 502 -> "quota unavailable".
|
||||||
refresh_token: refreshToken,
|
async function getKimiAccessToken() {
|
||||||
}).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) {
|
|
||||||
const creds = await loadKimiCreds();
|
const creds = await loadKimiCreds();
|
||||||
const now = Math.floor(Date.now() / 1000);
|
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;
|
return creds.access_token;
|
||||||
}
|
}
|
||||||
if (!forceRefresh && kimiMemToken && now < kimiMemToken.expires_at - 30) {
|
throw new Error('kimi access token stale (CLI refreshes on next use); quota temporarily unavailable');
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchKimiUsagesRaw() {
|
async function fetchKimiUsagesRaw() {
|
||||||
let token = await getKimiAccessToken(false);
|
const token = await getKimiAccessToken();
|
||||||
let res = await fetch(KIMI_USAGES_URL, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' } });
|
const 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' } });
|
|
||||||
}
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
throw new Error(`kimi /usages HTTP ${res.status}: ${text.slice(0, 500)}`);
|
throw new Error(`kimi /usages HTTP ${res.status}: ${text.slice(0, 500)}`);
|
||||||
@@ -654,9 +652,26 @@ const server = http.createServer((req, res) => {
|
|||||||
try {
|
try {
|
||||||
const raw = await fetchKimiUsagesRaw();
|
const raw = await fetchKimiUsagesRaw();
|
||||||
const out = normalizeKimiUsage(raw);
|
const out = normalizeKimiUsage(raw);
|
||||||
|
writeKimiUsageCache(out);
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(out));
|
res.end(JSON.stringify({ ...out, stale: false }));
|
||||||
} catch (err) {
|
} 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.writeHead(502, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: String(err.message || err) }));
|
res.end(JSON.stringify({ error: String(err.message || err) }));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user