diff --git a/openai/adolf-llm/server.js b/openai/adolf-llm/server.js index 5693182..9e81cc3 100644 --- a/openai/adolf-llm/server.js +++ b/openai/adolf-llm/server.js @@ -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) })); }