From 752d31475cff79c0d2f57f276c64aecc97e5a5bd Mon Sep 17 00:00:00 2001 From: alvis Date: Sat, 1 Aug 2026 08:19:35 +0000 Subject: [PATCH] ai: restore the quota probe on Codex, rewrite the footer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014Y5QPagv4iun1ghpwM96Ff --- adolf/openclaw.json | 34 ++-- ai/adolf-llm/server.js | 192 ++++++++++++++++-- .../index.js | 63 +++--- .../openclaw.plugin.json | 42 ++++ .../package.json | 4 +- ai/docker-compose.yml | 8 +- .../openclaw.plugin.json | 37 ---- ai/quota-command-openclaw-plugin/index.js | 19 +- 8 files changed, 291 insertions(+), 108 deletions(-) rename ai/{kimi-quota-footer-plugin => codex-quota-footer-plugin}/index.js (65%) create mode 100644 ai/codex-quota-footer-plugin/openclaw.plugin.json rename ai/{kimi-quota-footer-plugin => codex-quota-footer-plugin}/package.json (54%) delete mode 100644 ai/kimi-quota-footer-plugin/openclaw.plugin.json diff --git a/adolf/openclaw.json b/adolf/openclaw.json index dbdc8bc..5f96aee 100644 --- a/adolf/openclaw.json +++ b/adolf/openclaw.json @@ -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 diff --git a/ai/adolf-llm/server.js b/ai/adolf-llm/server.js index 7419e0f..840a401 100644 --- a/ai/adolf-llm/server.js +++ b/ai/adolf-llm/server.js @@ -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; } diff --git a/ai/kimi-quota-footer-plugin/index.js b/ai/codex-quota-footer-plugin/index.js similarity index 65% rename from ai/kimi-quota-footer-plugin/index.js rename to ai/codex-quota-footer-plugin/index.js index ef23c2b..913371b 100644 --- a/ai/kimi-quota-footer-plugin/index.js +++ b/ai/codex-quota-footer-plugin/index.js @@ -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})`); } }); }, diff --git a/ai/codex-quota-footer-plugin/openclaw.plugin.json b/ai/codex-quota-footer-plugin/openclaw.plugin.json new file mode 100644 index 0000000..5902b80 --- /dev/null +++ b/ai/codex-quota-footer-plugin/openclaw.plugin.json @@ -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." + } + } +} diff --git a/ai/kimi-quota-footer-plugin/package.json b/ai/codex-quota-footer-plugin/package.json similarity index 54% rename from ai/kimi-quota-footer-plugin/package.json rename to ai/codex-quota-footer-plugin/package.json index b130686..4f43dc3 100644 --- a/ai/kimi-quota-footer-plugin/package.json +++ b/ai/codex-quota-footer-plugin/package.json @@ -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 diff --git a/ai/docker-compose.yml b/ai/docker-compose.yml index 78698a9..e784aee 100644 --- a/ai/docker-compose.yml +++ b/ai/docker-compose.yml @@ -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) diff --git a/ai/kimi-quota-footer-plugin/openclaw.plugin.json b/ai/kimi-quota-footer-plugin/openclaw.plugin.json deleted file mode 100644 index 8891132..0000000 --- a/ai/kimi-quota-footer-plugin/openclaw.plugin.json +++ /dev/null @@ -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:\")." - } - } -} diff --git a/ai/quota-command-openclaw-plugin/index.js b/ai/quota-command-openclaw-plugin/index.js index e885bdf..03ec322 100644 --- a/ai/quota-command-openclaw-plugin/index.js +++ b/ai/quota-command-openclaw-plugin/index.js @@ -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 }; } }, });