Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ export async function newSession(
if (!sid) throw new Error("zcode create returned no sessionId");

server.sessionMap.set(sid, sid);
// Only freshly-created sessions are eligible for auto-title on first
// end_turn; resumed/loaded sessions already have a title and must keep it.
server.titleEligibleSessions.add(sid);
log(`session/new → ${sid}`);

// Sync to the App's tasks-index.sqlite so the App UI shows this session.
Expand Down Expand Up @@ -342,10 +345,16 @@ export async function prompt(
turn,
);

// Session title: set once on the first end_turn of this session. The title
// is the first prompt text (truncated). Subsequent turns never overwrite it
// (set-once gate), and the App's title_overridden flag always wins.
if (result.stopReason === "end_turn" && !server.sessionTitles.has(params.sessionId)) {
// Session title: set once on the first end_turn, but ONLY for freshly
// created sessions. Resumed/loaded sessions already carry a title from
// their history and must not be overwritten by the first post-load message.
// sessionTitles enforces set-once within a session; titleEligibleSessions
// gates which sessions are titled at all.
if (
result.stopReason === "end_turn" &&
server.titleEligibleSessions.has(params.sessionId) &&
!server.sessionTitles.has(params.sessionId)
) {
const title = text.slice(0, 80);
server.sessionTitles.set(params.sessionId, title);
const { updateSessionTitle } = await import("../tasks-index.js");
Expand Down
11 changes: 11 additions & 0 deletions src/handlers/slash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
* feedback `agent_message_chunk`, and return `end_turn` — never reaching the
* normal turn loop. Unknown `/x` falls through to the model (extensibility).
*
* `/quota` is the exception: it does not call ZCode at all — it queries the
* GLM Coding Plan usage API directly and renders the result.
*
* Returns the PromptResponse when intercepted, or null to let the caller run a
* normal turn.
*/
Expand All @@ -16,6 +19,7 @@ import type * as acp from "@agentclientprotocol/sdk";
import { RequestError } from "@agentclientprotocol/sdk";
import { applyModelSwitch } from "../config/runtime-model.js";
import { emitConfigOptionUpdate } from "../config/options.js";
import { formatQuota, queryQuota } from "../quota/index.js";
import { CONFIG_DISPATCH, warn } from "../utils.js";
import type { ZcodeAcpServer } from "../server.js";
import { sendTextChunk } from "./io.js";
Expand Down Expand Up @@ -48,6 +52,13 @@ export async function handleSlashCommand(

try {
switch (cmd) {
case "quota": {
// Does not touch ZCode — queries the GLM usage API directly. Always
// returns a status line (success card or an error fallback), so this
// never throws into the catch below under normal conditions.
const result = await queryQuota();
return ok(formatQuota(result));
}
case "compact": {
const result = (await compact(server, { sessionId: acpSid }, cx)) as {
__lockTimeout?: boolean;
Expand Down
39 changes: 39 additions & 0 deletions src/quota/cache.ts
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());
}
73 changes: 73 additions & 0 deletions src/quota/client.ts
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 };
}
126 changes: 126 additions & 0 deletions src/quota/format.ts
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}`);
});
}
Comment on lines +92 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Defensively handle potential missing or malformed properties in item.detail elements (such as d.modelCode or d.usage) to prevent runtime TypeError crashes if the API response structure changes or contains unexpected values.

  if (item.detail && item.detail.length > 0) {
    const last = item.detail.length - 1;
    item.detail.forEach((d, i) => {
      if (!d) return;
      const branch = i === last ? "└" : "├";
      const modelCode = typeof d.modelCode === "string" ? d.modelCode : "unknown";
      const usage = typeof d.usage === "number" ? d.usage : 0;
      const name = modelCode.padEnd(DETAIL_LABEL_WIDTH);
      lines.push(`  ${branch} ${name}${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");
}
47 changes: 47 additions & 0 deletions src/quota/index.ts
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";
Loading
Loading