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:
@@ -1,8 +1,8 @@
|
||||
{
|
||||
// Adolf P6 — OpenClaw gateway config for the "adolf" container.
|
||||
// Lives in the adolf-state VOLUME (mounted at /home/node/.openclaw), not
|
||||
// in the openai/ git repo. Secrets referenced below (${VAR}) are resolved
|
||||
// from this container's process env, itself sourced from openai/.env
|
||||
// in the ai/ git repo. Secrets referenced below (${VAR}) are resolved
|
||||
// from this container's process env, itself sourced from ai/.env
|
||||
// (gitignored) via docker-compose.yml — never inlined here.
|
||||
|
||||
gateway: {
|
||||
@@ -35,7 +35,7 @@
|
||||
// agent turn runs -- no plugin code needed, this is pure config. A voice
|
||||
// note sent to Adolf over the existing Matrix DM (source 1, see below)
|
||||
// gets transcribed by the `openai`-shaped entry below, which is redirected
|
||||
// via baseUrl/apiKey to the LOCAL faster-whisper server (openai/docker-
|
||||
// via baseUrl/apiKey to the LOCAL faster-whisper server (ai/docker-
|
||||
// compose.yml's `faster-whisper` service, same compose project as this
|
||||
// container, reachable by service name) instead of hosted OpenAI --
|
||||
// confirmed supported via src/media-understanding/runner.entries.ts's
|
||||
@@ -86,7 +86,7 @@
|
||||
models: [
|
||||
{
|
||||
provider: "openai",
|
||||
model: "deepdml/faster-whisper-large-v3-turbo-ct2", // must match WHISPER__MODEL in openai/docker-compose.yml
|
||||
model: "deepdml/faster-whisper-large-v3-turbo-ct2", // must match WHISPER__MODEL in ai/docker-compose.yml
|
||||
baseUrl: "http://faster-whisper:8000/v1",
|
||||
},
|
||||
],
|
||||
@@ -117,7 +117,7 @@
|
||||
},
|
||||
|
||||
// Model provider: adolf-llm (P2/P4), the Kimi-CLI OpenAI-compatible
|
||||
// wrapper on :8010. Its HTTP server (openai/adolf-llm/server.js) performs
|
||||
// wrapper on :8010. Its HTTP server (ai/adolf-llm/server.js) performs
|
||||
// NO api-key/Authorization validation at all -- ADOLF_KEY's value is
|
||||
// functionally irrelevant to adolf-llm itself. It's still wired through
|
||||
// env (not hardcoded) because OpenClaw's custom-provider schema requires
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
},
|
||||
|
||||
// MCP registry (P6) -- same servers as openai/shared-mcp.json,
|
||||
// MCP registry (P6) -- same servers as ai/shared-mcp.json,
|
||||
// expressed in OpenClaw's own mcp.servers schema. `type: "http"` is
|
||||
// OpenClaw's documented CLI-native alias for transport: "streamable-http".
|
||||
mcp: {
|
||||
@@ -193,12 +193,12 @@
|
||||
// tool bundle is built). CORRECTION (kb#144 second pass, 2026-07-22):
|
||||
// this does NOT reach the model on Adolf's kimi backbone -- Kimi CLI
|
||||
// (inside the separate adolf-llm container) reads its own
|
||||
// project-root .mcp.json, seeded from openai/shared-mcp.json, and
|
||||
// project-root .mcp.json, seeded from ai/shared-mcp.json, and
|
||||
// applies ITS OWN enabledTools/disabledTools (McpServerCommonFields,
|
||||
// computeEnabledNames). Live wire.jsonl verification (restart + one
|
||||
// real turn) proved OpenClaw's toolFilter alone left Kimi's actual
|
||||
// tool counts unchanged. This block is still correct for OpenClaw's
|
||||
// own MCP client surface -- see openai/shared-mcp.json for the layer
|
||||
// own MCP client surface -- see ai/shared-mcp.json for the layer
|
||||
// that actually scopes what the model sees.
|
||||
//
|
||||
// Scoped to Adolf's CORE memory ops: recall/retain/reflect (the
|
||||
@@ -270,7 +270,7 @@
|
||||
// network_mode: host on the Agap host), not a second copy. It can
|
||||
// place real orders on live marketplace accounts, so it's gated by a
|
||||
// shared bearer token (MARKETPLACE_MCP_TOKEN in Vaultwarden / this
|
||||
// container's env, injected via openai/.env -> docker-compose.yml).
|
||||
// container's env, injected via ai/.env -> docker-compose.yml).
|
||||
// Reached via host.docker.internal, same reasoning as kanboard above.
|
||||
// kb#144: scoped to READ-ONLY discovery (find_best/search/product/
|
||||
// recommendations/reviews/compare/status). Cuts the checkout
|
||||
@@ -316,7 +316,7 @@
|
||||
// open JSON-RPC listener handed ha_call_service/wiki_edit/todoist
|
||||
// writes to anyone). Same pattern as marketplace above:
|
||||
// AGAP_MCP_TOKEN lives in Vaultwarden, is injected into this
|
||||
// container via openai/.env -> docker-compose.yml, and is only
|
||||
// container via ai/.env -> docker-compose.yml, and is only
|
||||
// substituted here -- never inlined. The token maps to agent id
|
||||
// `adolf` in AGAP_MCP_AGENT_TOKENS, which is also what the kb#147
|
||||
// vault gate reads to allow vw_* (adolf = trust_class trusted).
|
||||
@@ -340,7 +340,7 @@
|
||||
entries: {
|
||||
// Hindsight memory plugin (kb #75, H3) — installed external plugin
|
||||
// under .openclaw/extensions/hindsight-memory, bind-mounted read-only
|
||||
// from openai/hindsight-openclaw-plugin (see that project's
|
||||
// from ai/hindsight-openclaw-plugin (see that project's
|
||||
// docker-compose.yml adolf.volumes). Structural successor to
|
||||
// cognee-memory above: LLM-free recall inject (before_prompt_build) +
|
||||
// async retain (agent_end) against the hindsight service, bank
|
||||
@@ -356,7 +356,7 @@
|
||||
hooks: { allowConversationAccess: true, allowPromptInjection: true },
|
||||
},
|
||||
// Kimi quota readout (kb #62) — installed external plugin, bind-mounted
|
||||
// read-only from openai/quota-command-openclaw-plugin (see that
|
||||
// read-only from ai/quota-command-openclaw-plugin (see that
|
||||
// project's docker-compose.yml adolf.volumes) onto
|
||||
// .openclaw/extensions/quota-command. Registers a `/quota` native
|
||||
// command; no hooks, so no allowConversationAccess/allowPromptInjection
|
||||
@@ -364,10 +364,10 @@
|
||||
"quota-command": {
|
||||
enabled: true,
|
||||
},
|
||||
// Kimi quota footer (kb #85) — installed external plugin, bind-mounted
|
||||
// read-only from openai/kimi-quota-footer-plugin (see that project's
|
||||
// Codex quota footer (kb #85) — installed external plugin, bind-mounted
|
||||
// read-only from ai/codex-quota-footer-plugin (see that project's
|
||||
// docker-compose.yml adolf.volumes) onto
|
||||
// .openclaw/extensions/kimi-quota-footer. Appends a compact Kimi
|
||||
// .openclaw/extensions/codex-quota-footer. Appends a compact Codex
|
||||
// usage line to every outgoing reply via the reply_payload_sending
|
||||
// hook (not a raw conversation hook, so no allowConversationAccess/
|
||||
// allowPromptInjection opt-in needed), reusing the same LLM-free
|
||||
@@ -376,11 +376,11 @@
|
||||
// deliverOutboundPayloadsInternal) as long as channels.matrix.streaming
|
||||
// stays unset/"off" as it is today — see the plugin's index.js header
|
||||
// comment for the streaming caveat if that ever changes.
|
||||
"kimi-quota-footer": {
|
||||
"codex-quota-footer": {
|
||||
enabled: true,
|
||||
},
|
||||
// Todoist idea capture (kb#170 component 1) — installed external
|
||||
// plugin, bind-mounted read-only from openai/todoist-capture-plugin
|
||||
// plugin, bind-mounted read-only from ai/todoist-capture-plugin
|
||||
// (see that project's docker-compose.yml adolf.volumes) onto
|
||||
// .openclaw/extensions/todoist-capture. Registers a `/idea` native
|
||||
// command; no hooks (no allowConversationAccess/allowPromptInjection
|
||||
|
||||
@@ -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',
|
||||
// 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',
|
||||
detail: 'Codex backend exposes no machine-readable quota endpoint.',
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
/**
|
||||
* Kimi Quota Footer (kb #85) — appends a compact Kimi usage line to the end of
|
||||
* Codex Quota Footer (kb #85) — appends a compact usage line to the end of
|
||||
* each of Adolf's outgoing replies, via OpenClaw's `reply_payload_sending`
|
||||
* hook (docs/plugins/hooks.md: "Mutate or cancel normalized reply payloads
|
||||
* before delivery... runs after payload normalization and before channel
|
||||
* delivery, including replies routed back to the originating channel").
|
||||
*
|
||||
* Source of the numbers: the LLM-free `GET /usage` route on adolf-llm (kb
|
||||
* #62), which talks straight to Kimi's managed-usage API — no model call
|
||||
* anywhere.
|
||||
* Source of the numbers: the LLM-free `GET /usage` route on adolf-llm, which
|
||||
* drives `codex app-server`'s `account/rateLimits/read` JSON-RPC method — the
|
||||
* same snapshot the interactive Codex TUI shows. No model call anywhere.
|
||||
*
|
||||
* Rewritten 2026-08-01 for the Kimi -> Codex migration. The old payload had
|
||||
* fixed Kimi buckets (window_5h / weekly / window_7d); Codex instead reports
|
||||
* up to two plan-defined windows, `primary` (long, e.g. 30d) and `secondary`
|
||||
* (shorter burst window, may be null), each already normalised by adolf-llm
|
||||
* to { pct, window_label, resets }. The footer therefore renders whatever
|
||||
* windows the plan actually has, labelled from the data, rather than
|
||||
* hardcoding bucket names that may not exist on this plan.
|
||||
*
|
||||
* Never blocks the send path: usage is cached and refreshed in the
|
||||
* background, so a reply is at most decorated with a slightly stale
|
||||
@@ -34,7 +42,8 @@ const DEFAULTS = {
|
||||
usageUrl: "http://adolf-llm:8010/usage",
|
||||
cacheTtlMs: 60000, // serve a cached snapshot for up to this long
|
||||
fetchTimeoutMs: 2500, // background fetch only; never on the send path
|
||||
prefix: "— Kimi:",
|
||||
prefix: "— Codex:",
|
||||
showPlan: false, // append the plan name (e.g. "free") when true
|
||||
};
|
||||
|
||||
function normalizeConfig(raw) {
|
||||
@@ -46,31 +55,37 @@ function normalizeConfig(raw) {
|
||||
cacheTtlMs: int(c.cacheTtlMs, DEFAULTS.cacheTtlMs),
|
||||
fetchTimeoutMs: int(c.fetchTimeoutMs, DEFAULTS.fetchTimeoutMs),
|
||||
prefix: typeof c.prefix === "string" && c.prefix ? c.prefix : DEFAULTS.prefix,
|
||||
showPlan: c.showPlan === true,
|
||||
};
|
||||
}
|
||||
|
||||
function pct(bucket) {
|
||||
if (!bucket || typeof bucket.pct !== "number") return null;
|
||||
return Math.round(bucket.pct);
|
||||
// One window -> "30d 4%". Falls back to a bare percentage when the backend
|
||||
// didn't report a window duration.
|
||||
function renderRow(row) {
|
||||
if (!row || typeof row.pct !== "number") return null;
|
||||
return row.window_label ? `${row.window_label} ${row.pct}%` : `${row.pct}%`;
|
||||
}
|
||||
|
||||
function formatFooter(usage, prefix) {
|
||||
function formatFooter(usage, cfg) {
|
||||
if (!usage) return null;
|
||||
const parts = [];
|
||||
const h5 = pct(usage.window_5h);
|
||||
const wk = pct(usage.weekly);
|
||||
const d7 = pct(usage.window_7d);
|
||||
if (h5 !== null) parts.push(`5h ${h5}%`);
|
||||
if (wk !== null) parts.push(`weekly ${wk}%`);
|
||||
if (d7 !== null) parts.push(`7d ${d7}%`);
|
||||
if (parts.length === 0) return null;
|
||||
return `${prefix} ${parts.join(" · ")}`;
|
||||
// Shortest window first — that's the one most likely to bite.
|
||||
const rows = [usage.secondary, usage.primary].map(renderRow).filter(Boolean);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
let line = `${cfg.prefix} ${rows.join(" · ")}`;
|
||||
if (cfg.showPlan && usage.plan) line += ` (${usage.plan})`;
|
||||
// A limit that has actually been hit matters more than the percentages.
|
||||
if (usage.limit_reached) line += " ⚠ limit reached";
|
||||
// Mark a reading served from a failed refresh so a stale number is never
|
||||
// mistaken for a live one.
|
||||
if (usage.stale) line += " (stale)";
|
||||
return line;
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "kimi-quota-footer",
|
||||
name: "Kimi Quota Footer",
|
||||
description: "Appends a compact Kimi usage line to the end of each outgoing reply.",
|
||||
id: "codex-quota-footer",
|
||||
name: "Codex Quota Footer",
|
||||
description: "Appends a compact Codex usage line to the end of each outgoing reply.",
|
||||
register(api) {
|
||||
const cfg = normalizeConfig(api.pluginConfig);
|
||||
|
||||
@@ -90,7 +105,7 @@ export default definePluginEntry({
|
||||
if (!res.ok) throw new Error(`/usage HTTP ${res.status}`);
|
||||
cache = { usage: await res.json(), ts: Date.now() };
|
||||
} catch (e) {
|
||||
api.logger?.debug?.(`kimi-quota-footer: usage refresh failed (${e?.message || e})`);
|
||||
api.logger?.debug?.(`codex-quota-footer: usage refresh failed (${e?.message || e})`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
refreshing = false;
|
||||
@@ -108,7 +123,7 @@ export default definePluginEntry({
|
||||
} else if (Date.now() - cache.ts > cfg.cacheTtlMs) {
|
||||
refresh();
|
||||
}
|
||||
return formatFooter(cache.usage, cfg.prefix);
|
||||
return formatFooter(cache.usage, cfg);
|
||||
}
|
||||
|
||||
api.on("reply_payload_sending", async (event) => {
|
||||
@@ -121,7 +136,7 @@ export default definePluginEntry({
|
||||
if (!footer || text.includes(footer)) return;
|
||||
return { payload: { ...payload, text: `${text}\n\n${footer}` } };
|
||||
} catch (e) {
|
||||
api.logger?.warn?.(`kimi-quota-footer: hook failed (${e?.message || e})`);
|
||||
api.logger?.warn?.(`codex-quota-footer: hook failed (${e?.message || e})`);
|
||||
}
|
||||
});
|
||||
},
|
||||
42
ai/codex-quota-footer-plugin/openclaw.plugin.json
Normal file
42
ai/codex-quota-footer-plugin/openclaw.plugin.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"id": "codex-quota-footer",
|
||||
"name": "Codex Quota Footer",
|
||||
"description": "Appends a compact Codex usage line (the plan's own rate-limit windows, e.g. 5h/30d %) to the end of each of Adolf's outgoing replies, via the reply_payload_sending hook. Reads the LLM-free adolf-llm:8010/usage route, which drives `codex app-server`'s account/rateLimits/read; cached + background-refreshed so it never blocks the send path.",
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"usageUrl": { "type": "string" },
|
||||
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 3600000 },
|
||||
"fetchTimeoutMs": { "type": "integer", "minimum": 200, "maximum": 30000 },
|
||||
"prefix": { "type": "string" },
|
||||
"showPlan": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"enabled": {
|
||||
"label": "Codex Quota Footer",
|
||||
"help": "Append a compact Codex usage line to the end of each reply."
|
||||
},
|
||||
"usageUrl": {
|
||||
"label": "Usage URL",
|
||||
"help": "adolf-llm /usage endpoint (default http://adolf-llm:8010/usage)."
|
||||
},
|
||||
"cacheTtlMs": {
|
||||
"label": "Cache TTL (ms)",
|
||||
"help": "How long a fetched usage snapshot is reused before a background refresh (default 60000)."
|
||||
},
|
||||
"prefix": {
|
||||
"label": "Footer Prefix",
|
||||
"help": "Text before the percentages (default \"— Codex:\")."
|
||||
},
|
||||
"showPlan": {
|
||||
"label": "Show Plan Name",
|
||||
"help": "Also show the ChatGPT plan the limits belong to (e.g. \"free\"). Off by default."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "kimi-quota-footer",
|
||||
"version": "1.0.0",
|
||||
"name": "codex-quota-footer",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"private": true
|
||||
@@ -373,11 +373,13 @@ services:
|
||||
# Cognee as Adolf's memory backend. Activated via
|
||||
# plugins.entries.hindsight-memory in openclaw.json.
|
||||
- ./hindsight-openclaw-plugin:/home/node/.openclaw/extensions/hindsight-memory:ro
|
||||
# kimi-quota-footer plugin (kb #85) — same pattern. Appends the Kimi
|
||||
# codex-quota-footer plugin (kb #85) — same pattern. Appends the Codex
|
||||
# usage line to every outgoing reply via reply_payload_sending, reusing
|
||||
# quota-command's adolf-llm:8010/usage route. Activated via
|
||||
# plugins.entries.kimi-quota-footer in openclaw.json.
|
||||
- ./kimi-quota-footer-plugin:/home/node/.openclaw/extensions/kimi-quota-footer:ro
|
||||
# plugins.entries.codex-quota-footer in openclaw.json (the mount path,
|
||||
# the plugin's own id and that entry key must all agree or the plugin
|
||||
# silently does not load). Renamed from kimi-quota-footer 2026-08-01.
|
||||
- ./codex-quota-footer-plugin:/home/node/.openclaw/extensions/codex-quota-footer:ro
|
||||
# todoist-capture plugin (kb#170 component 1) — same pattern.
|
||||
# Registers /idea (native command, zero Kimi calls); POSTs to
|
||||
# agap-mcp's /capture-idea (see agap-mcp/src/server.js + capture.js)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"id": "kimi-quota-footer",
|
||||
"name": "Kimi Quota Footer",
|
||||
"description": "Appends a compact Kimi usage line (5h/weekly/7d %) to the end of each of Adolf's outgoing replies, via the reply_payload_sending hook. Reads the LLM-free adolf-llm:8010/usage route (kb #62); cached + background-refreshed so it never blocks the send path.",
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"usageUrl": { "type": "string" },
|
||||
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 3600000 },
|
||||
"fetchTimeoutMs": { "type": "integer", "minimum": 200, "maximum": 30000 },
|
||||
"prefix": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"enabled": {
|
||||
"label": "Kimi Quota Footer",
|
||||
"help": "Append a compact Kimi usage line to the end of each reply."
|
||||
},
|
||||
"usageUrl": {
|
||||
"label": "Usage URL",
|
||||
"help": "adolf-llm /usage endpoint (default http://adolf-llm:8010/usage)."
|
||||
},
|
||||
"cacheTtlMs": {
|
||||
"label": "Cache TTL (ms)",
|
||||
"help": "How long a fetched usage snapshot is reused before a background refresh (default 60000)."
|
||||
},
|
||||
"prefix": {
|
||||
"label": "Footer Prefix",
|
||||
"help": "Text before the percentages (default \"— Kimi:\")."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,23 +40,32 @@ async function fetchUsage() {
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "quota-command",
|
||||
name: "Kimi Quota Command",
|
||||
name: "Codex Quota Command",
|
||||
description:
|
||||
"LLM-free /quota command: reads Adolf's Kimi usage from adolf-llm:8010/usage and replies with a compact readout.",
|
||||
"LLM-free /quota command: reads Adolf's Codex usage from adolf-llm:8010/usage and replies with a compact readout.",
|
||||
register(api) {
|
||||
api.registerCommand({
|
||||
name: "quota",
|
||||
description: "Show Kimi quota usage (5h / weekly / 7d) — no model call.",
|
||||
description: "Show Codex quota usage for the plan's rate-limit windows — no model call.",
|
||||
acceptsArgs: false,
|
||||
requireAuth: true,
|
||||
handler: async () => {
|
||||
try {
|
||||
const usage = await fetchUsage();
|
||||
const line = `Kimi: 5h ${pct(usage.window_5h)} · weekly ${pct(usage.weekly)} · 7d ${pct(usage.window_7d)}`;
|
||||
// Codex reports up to two plan-defined windows rather than Kimi's
|
||||
// fixed 5h/weekly/7d buckets; render whichever exist, shortest
|
||||
// first, labelled from the data itself.
|
||||
const rows = [usage.secondary, usage.primary]
|
||||
.filter((r) => r && typeof r.pct === "number")
|
||||
.map((r) => (r.window_label ? `${r.window_label} ${r.pct}%` : pct(r)));
|
||||
let line = rows.length ? `Codex: ${rows.join(" · ")}` : "Codex: no rate-limit windows reported";
|
||||
if (usage.plan) line += ` (${usage.plan} plan)`;
|
||||
if (usage.limit_reached) line += " ⚠ limit reached";
|
||||
if (usage.stale) line += ` — stale, ${usage.age_s}s old`;
|
||||
return { text: line, suppressReply: true };
|
||||
} catch (e) {
|
||||
api.logger?.warn?.(`quota-command: fetch failed (${e?.message || e})`);
|
||||
return { text: `Kimi quota unavailable: ${e?.message || e}`, suppressReply: true };
|
||||
return { text: `Codex quota unavailable: ${e?.message || e}`, suppressReply: true };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user