-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add /quota slash command for GLM Coding Plan usage display #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| /** | ||
| * In-memory TTL cache for quota results. | ||
| * | ||
| * `/quota` is user-triggered, so the cache only guards against rapid repeats | ||
| * (e.g. double-entering the command). The TTL is intentionally short — 10s — | ||
| * so the displayed figures stay fresh while still debouncing bursts. There is | ||
| * no file persistence and no per-key sharding: a single process-wide slot. | ||
| */ | ||
|
|
||
| import type { QuotaResult } from "./types.js"; | ||
|
|
||
| /** How long a cached result is served before re-querying. */ | ||
| const TTL_MS = 10_000; | ||
|
|
||
| let slot: { result: QuotaResult; at: number } | null = null; | ||
|
|
||
| /** Inject a fake clock — only tests should need this. */ | ||
| let now: () => number = () => Date.now(); | ||
|
|
||
| /** Return the cached result if still fresh, else `null`. */ | ||
| export function getCached(): QuotaResult | null { | ||
| if (slot && now() - slot.at < TTL_MS) return slot.result; | ||
| return null; | ||
| } | ||
|
|
||
| /** Store a fresh result, stamping it with the current time. */ | ||
| export function setCached(result: QuotaResult): void { | ||
| slot = { result, at: now() }; | ||
| } | ||
|
|
||
| /** Clear the cache (test helper). */ | ||
| export function clearCache(): void { | ||
| slot = null; | ||
| } | ||
|
|
||
| /** Override the clock (test-only). Pass `undefined` to restore real time. */ | ||
| export function setClock(fn?: () => number): void { | ||
| now = fn ?? (() => Date.now()); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| /** | ||
| * GLM quota API HTTP client. | ||
| * | ||
| * Queries `bigmodel.cn` (CN) or `api.z.ai` (intl) for the current account's | ||
| * usage limits. Credentials come from the active ZCode provider in | ||
| * `~/.zcode/v2/config.json` via {@link loadZcodeCredentials} — the same apiKey | ||
| * the backend already uses for model calls. | ||
| */ | ||
|
|
||
| import { loadZcodeCredentials } from "../backend/credentials.js"; | ||
|
|
||
| /** Quota endpoint path, appended to the chosen host. */ | ||
| const QUOTA_PATH = "/api/monitor/usage/quota/limit"; | ||
|
|
||
| /** Request timeout (ms). The endpoint is fast; keep this tight. */ | ||
| const TIMEOUT_MS = 8000; | ||
|
|
||
| /** Hosts for the CN and intl deployments. */ | ||
| const HOST_CN = "https://open.bigmodel.cn"; | ||
| const HOST_INTL = "https://api.z.ai"; | ||
|
|
||
| /** Raw response from the quota endpoint, pre-parsing. */ | ||
| export interface QuotaResponse { | ||
| status: number; | ||
| json: unknown; | ||
| text: string; | ||
| } | ||
|
|
||
| /** | ||
| * Pick the quota host from a provider `baseURL`. | ||
| * | ||
| * `api.z.ai` → intl; anything else (including `open.bigmodel.cn`, | ||
| * `bigmodel.cn`, empty) → CN. This mirrors how the backend routes model | ||
| * traffic. | ||
| */ | ||
| export function resolveQuotaHost(baseURL: string): string { | ||
| return baseURL.includes("api.z.ai") ? HOST_INTL : HOST_CN; | ||
| } | ||
|
|
||
| /** | ||
| * Fetch the quota envelope from the GLM API. | ||
| * | ||
| * @throws if the active provider has no apiKey in config, or on network/timeout | ||
| * errors. The caller maps these to `unavailable`. | ||
| */ | ||
| export async function fetchQuotaResponse( | ||
| fetchImpl: typeof globalThis.fetch = globalThis.fetch, | ||
| ): Promise<QuotaResponse> { | ||
| const { ANTHROPIC_API_KEY, ZCODE_BASE_URL } = loadZcodeCredentials(); | ||
| if (!ANTHROPIC_API_KEY) { | ||
| throw new Error("no apiKey in ZCode config — cannot query quota"); | ||
| } | ||
|
|
||
| const url = resolveQuotaHost(ZCODE_BASE_URL ?? "") + QUOTA_PATH; | ||
| const resp = await fetchImpl(url, { | ||
| method: "GET", | ||
| headers: { | ||
| Accept: "application/json, text/plain, */*", | ||
| Authorization: `Bearer ${ANTHROPIC_API_KEY}`, | ||
| }, | ||
| signal: AbortSignal.timeout(TIMEOUT_MS), | ||
| }); | ||
|
|
||
| const text = await resp.text(); | ||
| let json: unknown = null; | ||
| try { | ||
| json = text ? JSON.parse(text) : null; | ||
| } catch { | ||
| // Non-JSON body — leave json null; the parser treats it as unavailable. | ||
| } | ||
|
|
||
| return { status: resp.status, json, text }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| /** | ||
| * Quota result formatting — renders a {@link QuotaResult} as a multi-line | ||
| * plain-text card for the `agent_message_chunk` feedback. | ||
| * | ||
| * The progress bar uses two block characters at {@link BAR_WIDTH}-cell width | ||
| * for a clean, precise fill (each cell = 10%): | ||
| * █ (U+2588, full) — used portion | ||
| * ░ (U+2591, light) — remaining portion | ||
| */ | ||
|
|
||
| import type { QuotaItem, QuotaResult } from "./types.js"; | ||
|
|
||
| /** Progress-bar cell count. Compact to fit chat-frame width without wrapping. */ | ||
| const BAR_WIDTH = 10; | ||
|
|
||
| /** Filled / empty cell characters. */ | ||
| const CHAR_FULL = "█"; | ||
| const CHAR_EMPTY = "░"; | ||
|
|
||
| /** Pad a percent number to 2 chars (right-aligned). */ | ||
| function padPercent(n: number): string { | ||
| return String(n).padStart(2); | ||
| } | ||
|
|
||
| /** | ||
| * Render a {@link BAR_WIDTH}-cell progress bar for a used-percent in [0, 100]. | ||
| * | ||
| * 0% → all empty; 100% → all full; otherwise the used portion is `█` and the | ||
| * rest is `░`, rounded to the nearest cell (each cell = 10%). | ||
| */ | ||
| export function renderBar(usedPercent: number): string { | ||
| const clamped = Math.max(0, Math.min(100, usedPercent)); | ||
| const filled = Math.round((clamped / 100) * BAR_WIDTH); | ||
| const empty = BAR_WIDTH - filled; | ||
| return CHAR_FULL.repeat(filled) + CHAR_EMPTY.repeat(empty); | ||
| } | ||
|
|
||
| /** Format a reset timestamp (ms) as a local `MM-DD HH:MM` string, or `null`. */ | ||
| function formatResetTime(nextResetTime?: number): string | null { | ||
| if (nextResetTime === undefined || !Number.isFinite(nextResetTime)) return null; | ||
| const d = new Date(nextResetTime); | ||
| if (Number.isNaN(d.getTime())) return null; | ||
| const mm = String(d.getMonth() + 1).padStart(2, "0"); | ||
| const dd = String(d.getDate()).padStart(2, "0"); | ||
| const hh = String(d.getHours()).padStart(2, "0"); | ||
| const min = String(d.getMinutes()).padStart(2, "0"); | ||
| return `${mm}-${dd} ${hh}:${min}`; | ||
| } | ||
|
|
||
| /** Capitalise the first letter of a plan level (e.g. "pro" → "Pro"). */ | ||
| function capitalise(s: string): string { | ||
| if (!s) return ""; | ||
| return s.charAt(0).toUpperCase() + s.slice(1); | ||
| } | ||
|
|
||
| /** | ||
| * Build the trailing annotation for an item: reset time first (so all items | ||
| * align), then `(used/total)` last (only some limits carry absolute counters). | ||
| */ | ||
| function formatTrailing(item: QuotaItem): string { | ||
| const parts: string[] = []; | ||
|
|
||
| const reset = formatResetTime(item.nextResetTime); | ||
| if (reset) parts.push(reset); | ||
|
|
||
| // Absolute counters — only some limit kinds carry them (MCP/TIME does, | ||
| // legacy TOKENS_LIMIT often does not). Placed last so the reset times of | ||
| // counter-less items (e.g. 5h) stay left-aligned with counter-bearing ones. | ||
| if ( | ||
| typeof item.usedCount === "number" && | ||
| typeof item.totalCount === "number" && | ||
| Number.isFinite(item.usedCount) && | ||
| Number.isFinite(item.totalCount) | ||
| ) { | ||
| parts.push(`(${item.usedCount}/${item.totalCount})`); | ||
| } | ||
|
|
||
| return parts.length > 0 ? ` · ${parts.join(" · ")}` : ""; | ||
| } | ||
|
|
||
| /** Right-pad a model code for aligned detail sub-lines. */ | ||
| const DETAIL_LABEL_WIDTH = 14; | ||
|
|
||
| /** Render one quota item line (+ indented detail sub-lines if present). */ | ||
| function formatItem(item: QuotaItem): string[] { | ||
| const lines: string[] = []; | ||
| const bar = renderBar(item.usedPercent); | ||
| lines.push( | ||
| `${item.label.padEnd(5)} ${bar} ${padPercent(item.usedPercent)}%${formatTrailing(item)}`, | ||
| ); | ||
|
|
||
| if (item.detail && item.detail.length > 0) { | ||
| const last = item.detail.length - 1; | ||
| item.detail.forEach((d, i) => { | ||
| const branch = i === last ? "└" : "├"; | ||
| const name = d.modelCode.padEnd(DETAIL_LABEL_WIDTH); | ||
| lines.push(` ${branch} ${name}${d.usage}`); | ||
| }); | ||
| } | ||
| return lines; | ||
| } | ||
|
|
||
| /** Error/fallback messages keyed by `kind`. */ | ||
| const STATUS_MESSAGES: Record<Exclude<QuotaResult["kind"], "success">, string> = { | ||
| auth_error: "🔒 Quota auth expired — re-login in the ZCode app", | ||
| rate_limited: "⏳ Quota service busy, try again shortly", | ||
| unavailable: "⚠ Quota info unavailable", | ||
| }; | ||
|
|
||
| /** | ||
| * Render a {@link QuotaResult} as a multi-line plain-text card. | ||
| * | ||
| * Success → header line + divider + one progress-bar line per item (plus | ||
| * indented per-model detail where present). Non-success kinds → a single | ||
| * explanatory line. | ||
| */ | ||
| export function formatQuota(result: QuotaResult): string { | ||
| if (result.kind !== "success") { | ||
| return STATUS_MESSAGES[result.kind]; | ||
| } | ||
|
|
||
| const header = `GLM Coding Plan${result.level ? ` · ${capitalise(result.level)}` : ""}`; | ||
| const divider = "─".repeat(50); | ||
| const body = result.items.flatMap(formatItem); | ||
| return [header, divider, ...body].join("\n"); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| /** | ||
| * Quota orchestration — the single entry point used by the `/quota` slash | ||
| * command. | ||
| * | ||
| * Flow: cache check → fetch → parse → cache write. Any thrown error (missing | ||
| * apiKey, network/timeout) degrades to `unavailable` rather than propagating, | ||
| * so the command always produces a user-visible message. | ||
| */ | ||
|
|
||
| import { getCached, setCached } from "./cache.js"; | ||
| import { fetchQuotaResponse } from "./client.js"; | ||
| import { log } from "../utils.js"; | ||
| import { parseQuotaEnvelope } from "./parse.js"; | ||
| import type { QuotaResult } from "./types.js"; | ||
|
|
||
| /** | ||
| * Query the GLM quota API and return a normalised {@link QuotaResult}. | ||
| * | ||
| * Serves a cached result when fresh (< 10s). On fetch failure the result is | ||
| * `unavailable`; on a successful fetch the parsed envelope is cached and | ||
| * returned. | ||
| */ | ||
| export async function queryQuota(): Promise<QuotaResult> { | ||
| const cached = getCached(); | ||
| if (cached) { | ||
| log("quota: serving cached result"); | ||
| return cached; | ||
| } | ||
|
|
||
| let result: QuotaResult; | ||
| try { | ||
| const resp = await fetchQuotaResponse(); | ||
| result = parseQuotaEnvelope(resp); | ||
| } catch (e) { | ||
| log(`quota: fetch failed (${e instanceof Error ? e.message : String(e)})`); | ||
| result = { kind: "unavailable" }; | ||
| } | ||
|
|
||
| setCached(result); | ||
| return result; | ||
| } | ||
|
|
||
| // Re-exports for consumers (slash handler + tests). | ||
| export { formatQuota } from "./format.js"; | ||
| export { parseLimit, parseQuotaEnvelope } from "./parse.js"; | ||
| export { renderBar } from "./format.js"; | ||
| export type { QuotaItem, QuotaResult, RawLimit } from "./types.js"; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Defensively handle potential missing or malformed properties in
item.detailelements (such asd.modelCodeord.usage) to prevent runtimeTypeErrorcrashes if the API response structure changes or contains unexpected values.