ai: restore the quota probe on Codex, rewrite the footer

The Codex migration left /usage returning 501 and no quota signal for the
governor. Codex does expose one after all — it just isn't an HTTP endpoint.

Probe: `codex app-server` is a JSON-RPC-over-stdio surface whose
`account/rateLimits/read` returns the same snapshot the interactive TUI
shows. Handshake is initialize -> `initialized` NOTIFICATION -> read; without
the notification the read never answers. adolf-llm's /usage now drives that
and normalises the result.

Shape change, and why the consumers had to be rewritten rather than repointed:
Kimi reported fixed buckets (window_5h / weekly / window_7d). Codex reports up
to two plan-defined windows, `primary` (long) and `secondary` (shorter burst,
often null), so the payload is now {plan, pct, primary, secondary,
limit_reached} with each row as {pct, window_mins, window_label, resets}. `pct`
is the max across live windows — the single number a gate can read without
knowing which window binds.

Probing spawns a codex process (~2s), so results are cached in memory and on
the workspace volume with a 5min TTL, concurrent probes are de-duped, and a
failed refresh serves the last good reading tagged stale/as_of/age_s rather
than nothing. ?force=1 bypasses the TTL.

kimi-quota-footer-plugin -> codex-quota-footer-plugin (id, mount path and the
openclaw.json entry key all renamed together — they must agree or the plugin
silently fails to load). It now renders whatever windows the plan actually
has, shortest first, and flags limit_reached and stale readings. quota-command
updated for the same payload.

Verified: /usage returns live data (30d 4%, plan free), warm cache serves in
17ms vs ~2s cold, the gateway reaches the route, adolf loads
codex-quota-footer, and the formatter degrades to no footer on empty/null
payloads instead of breaking the reply.

Note: the account reports planType "free", not a paid ChatGPT plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Y5QPagv4iun1ghpwM96Ff
This commit is contained in:
2026-08-01 08:19:35 +00:00
parent 9094d71e2f
commit 752d31475c
8 changed files with 291 additions and 108 deletions

View File

@@ -560,21 +560,133 @@ async function handleTurn(messages, onDelta, signal) {
}
// ---------------------------------------------------------------------------
// Quota readout. The Kimi-specific implementation (kb #62/#87) was removed with
// the Codex migration: it authenticated against Kimi's managed-usage API using
// the Kimi CLI's OAuth creds file, and neither the endpoint nor the credential
// exists on this backend. Codex exposes no equivalent machine-readable quota
// endpoint, so /usage now reports "unsupported" rather than inventing numbers.
// Quota readout (the Codex-era replacement for the Kimi /usages implementation
// removed in the migration; kb #62/#87 for the original).
//
// The two consumers (kimi-quota-footer-plugin, quota-command-openclaw-plugin)
// both treat a non-OK /usage as "no data" and degrade quietly -- the footer is
// simply omitted. They still need a decision: retire them, or repoint them at
// whatever quota signal the Codex/ChatGPT plan actually exposes.
const USAGE_UNSUPPORTED = {
error: 'usage_unsupported',
backend: 'codex',
detail: 'Codex backend exposes no machine-readable quota endpoint.',
};
// Source: `codex app-server`, an experimental JSON-RPC-over-stdio surface the
// CLI ships. Method `account/rateLimits/read` returns the same snapshot the
// interactive TUI shows. Handshake is: `initialize` request, then an
// `initialized` NOTIFICATION (the read returns nothing without it), then the
// read. Shape as of codex-cli 0.146.0:
//
// { rateLimits: { planType, primary: { usedPercent, windowDurationMins,
// resetsAt /* unix seconds */ }, secondary: {…}|null } }
//
// `primary` is the long window (windowDurationMins 43200 = 30d on the current
// plan); `secondary`, when present, is the shorter burst window. Both are
// normalised below to the same {pct, window_mins, window_label, resets} rows
// so a consumer never has to know which is which.
//
// Cost: this spawns a codex process (~1-2s) and does a network round trip, so
// results are cached in memory + on the workspace volume, exactly as the Kimi
// implementation did. On failure we serve the last good reading tagged
// `stale` with `as_of`/`age_s`, so a caller can decide if it is fresh enough
// to gate on rather than being handed nothing.
const USAGE_CACHE_PATH = '/workspace/.adolf-llm/usage-cache.json';
const USAGE_TTL_MS = 5 * 60 * 1000; // don't spawn codex more than once per 5min
const USAGE_PROBE_TIMEOUT_MS = 45000;
let usageCache = null; // { payload, cached_at }
let usageInFlight = null; // de-dupe concurrent probes
function readUsageCache() {
if (usageCache) return usageCache;
try {
const parsed = JSON.parse(fs.readFileSync(USAGE_CACHE_PATH, 'utf8'));
if (parsed && parsed.payload && parsed.cached_at) usageCache = parsed;
} catch {}
return usageCache;
}
function writeUsageCache(payload) {
usageCache = { payload, cached_at: new Date().toISOString() };
try {
fs.mkdirSync(path.dirname(USAGE_CACHE_PATH), { recursive: true });
fs.writeFileSync(USAGE_CACHE_PATH, JSON.stringify(usageCache));
} catch {}
}
// Minutes -> a short human label ("5h", "7d", "30d") for display.
function windowLabel(mins) {
if (!mins || mins <= 0) return null;
if (mins % 1440 === 0) return `${mins / 1440}d`;
if (mins % 60 === 0) return `${mins / 60}h`;
return `${mins}m`;
}
function usageRow(raw) {
if (!raw || typeof raw.usedPercent !== 'number') return null;
return {
pct: Math.round(raw.usedPercent),
window_mins: raw.windowDurationMins ?? null,
window_label: windowLabel(raw.windowDurationMins),
resets: raw.resetsAt ? new Date(raw.resetsAt * 1000).toISOString() : null,
};
}
// Drive `codex app-server` for one rateLimits read. Resolves the raw result.
function probeRateLimits() {
return new Promise((resolve, reject) => {
const child = spawn('codex', ['app-server'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: USAGE_PROBE_TIMEOUT_MS,
});
let buf = '';
let stderr = '';
let settled = false;
const done = (err, val) => {
if (settled) return;
settled = true;
try { child.kill('SIGTERM'); } catch {}
err ? reject(err) : resolve(val);
};
child.stdout.on('data', d => {
buf += d;
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
let obj;
try { obj = JSON.parse(line); } catch { continue; }
if (obj.id === 1 && obj.result) {
// Handshake accepted -> `initialized` notification, then the read.
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'initialized', params: {} }) + '\n');
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'account/rateLimits/read', params: {} }) + '\n');
} else if (obj.id === 2) {
if (obj.error) done(new Error(`rateLimits/read: ${obj.error.message || JSON.stringify(obj.error)}`));
else done(null, obj.result);
}
}
});
child.stderr.on('data', d => { stderr += d; });
child.on('error', err => done(err));
child.on('close', code => done(new Error(`codex app-server exited ${code}: ${stderr.slice(0, 500)}`)));
child.stdin.write(JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { clientInfo: { name: 'adolf-llm', version: '1' } },
}) + '\n');
});
}
function normalizeUsage(result) {
const rl = (result && result.rateLimits) || {};
const primary = usageRow(rl.primary);
const secondary = usageRow(rl.secondary);
// Highest utilisation across the live windows — the number a gate should read
// without caring which window is the binding one.
const pcts = [primary, secondary].filter(Boolean).map(r => r.pct);
return {
backend: 'codex',
plan: rl.planType ?? null,
pct: pcts.length ? Math.max(...pcts) : null,
primary,
secondary,
limit_reached: Boolean(rl.rateLimitReachedType) || Boolean(rl.spendControlReached),
};
}
// ---------------------------------------------------------------------------
// OpenAI-compatible HTTP surface.
@@ -609,12 +721,52 @@ const server = http.createServer((req, res) => {
return;
}
if (req.method === 'GET' && req.url === '/usage') {
// 501 rather than 502: this is not a transient upstream failure, it is a
// capability the codex backend does not have. Consumers already treat any
// non-OK response as "no data" and omit the quota footer.
res.writeHead(501, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(USAGE_UNSUPPORTED));
if (req.method === 'GET' && req.url.split('?')[0] === '/usage') {
(async () => {
const force = /[?&]force=1/.test(req.url);
const cached = readUsageCache();
const ageMs = cached ? Date.now() - Date.parse(cached.cached_at) : Infinity;
// Serve a warm cache rather than spawning codex on every request — the
// footer plugin polls this on a timer.
if (!force && cached && ageMs < USAGE_TTL_MS) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
...cached.payload,
stale: false,
as_of: cached.cached_at,
age_s: Math.round(ageMs / 1000),
}));
return;
}
try {
// De-dupe: concurrent callers share one probe instead of each spawning.
if (!usageInFlight) {
usageInFlight = probeRateLimits().finally(() => { usageInFlight = null; });
}
const out = normalizeUsage(await usageInFlight);
writeUsageCache(out);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ...out, stale: false, as_of: usageCache.cached_at, age_s: 0 }));
} catch (err) {
// Serve the last good reading, clearly labelled, rather than nothing.
const prev = readUsageCache();
if (prev) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
...prev.payload,
stale: true,
as_of: prev.cached_at,
age_s: Math.max(0, Math.round((Date.now() - Date.parse(prev.cached_at)) / 1000)),
stale_reason: String(err.message || err),
}));
return;
}
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: String(err.message || err), backend: 'codex' }));
}
})();
return;
}