/** * usage/glm.ts — GLM (Zhipu) usage fetcher + quota helpers. * * Extracted from services/usage.ts (god-file decomposition): the GLM family — token/window * quota naming, quota ordering, monthly-remaining math, and the getGlmUsage fetcher that * probes the Zhipu quota endpoint. Depends only on the sibling scalar/quota leaves plus the * GLM quota-URL config — no host coupling — so it lives as a co-located provider leaf. * usage.ts imports getGlmUsage (dispatcher) and re-exports glmMonthlyRemainingPercentage * (used by the glm-coding-plan-monthly test). Behavior-preserving move. */ import { toNumber, toRecord, toTitleCase, toPercentage } from "./scalars.ts"; import { type UsageQuota } from "./quota.ts"; import { buildGlmQuotaFetch, getGlmTeamQuotaConfig } from "../../config/glmProvider.ts"; type JsonRecord = Record; function getGlmTokenQuotaName( limit: JsonRecord, existingQuotas: Record ): string { const unit = toNumber(limit.unit, 0); const number = toNumber(limit.number, 0); if (unit === 3 && number === 5) return "session"; if (unit === 6 && number === 1) return "weekly"; if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly"; return existingQuotas.session ? "weekly" : "session"; } function getGlmQuotaDisplayName(quotaName: string): string { if (quotaName === "session") return "5 Hours Quota"; if (quotaName === "weekly") return "Weekly Quota"; return quotaName; } const GLM_QUOTA_ORDER = ["5 Hours Quota", "Weekly Quota", "Monthly Tools", "Tokens", "Time Limit"]; function getGlmQuotaLabel(type: unknown, unit: unknown): string | null { const normalized = typeof type === "string" ? type.trim().toUpperCase() : ""; const unitValue = toNumber(unit, -1); switch (normalized) { case "TOKENS_LIMIT": case "TOKEN_LIMIT": if (unitValue === 3) return "5 Hours Quota"; if (unitValue === 6) return "Weekly Quota"; return "Tokens"; case "TIME_LIMIT": case "TIME_USAGE_LIMIT": if (unitValue === 5) return "Monthly Tools"; return "Time Limit"; default: return null; } } function orderGlmQuotas(quotas: Record): Record { const ordered: Record = {}; for (const key of GLM_QUOTA_ORDER) { if (quotas[key]) ordered[key] = quotas[key]; } for (const [key, quota] of Object.entries(quotas)) { if (!ordered[key]) ordered[key] = quota; } return ordered; } /** * Remaining-percentage for a GLM/z.ai TIME_LIMIT ("Monthly") quota. With an absolute * monthly cap (`total > 0`) it is `remaining / total`. Coding plans that have no * monthly cap (only 5-hour windows) report `total = 0`; in that case fall back to the * percentage-derived remaining so "no monthly cap" renders as full/100% instead of a * misleading 0% (#3580). */ export function glmMonthlyRemainingPercentage(total: number, remaining: number): number { if (total > 0) { return Math.max(0, Math.min(100, Math.round((remaining / total) * 100))); } return Math.max(0, Math.min(100, Math.round(remaining))); } function glmTeamQuotaIncompleteMessage(missing: "glmOrganizationId" | "glmProjectId"): string { const fieldLabel = missing === "glmOrganizationId" ? "Organization ID" : "Project ID"; return `GLM team plan quota requires both Organization ID and Project ID. Add the missing ${fieldLabel} on this connection.`; } function glmTeamQuotaHintMessage(): string { return "This API key appears to be a GLM Coding team plan. Add Organization ID and Project ID on this connection to view usage."; } function sanitizeGlmQuotaErrorMessage(msg: unknown): string { if (typeof msg !== "string" || !msg.trim()) { return "Unable to fetch GLM quota."; } return msg.trim(); } function shouldSuggestGlmTeamQuota( teamConfig: ReturnType, _providerSpecificData: unknown, _json: JsonRecord, upstreamMsg: string ): boolean { if (teamConfig.state !== "none") return false; return /coding\s*plan|不存在.*plan|没有.*coding|团队|编码套餐/i.test(upstreamMsg); } export async function getGlmUsage(apiKey: string, providerSpecificData?: Record) { if (!apiKey) { return { message: "API key not available. Add a coding plan API key to view usage." }; } const teamConfig = getGlmTeamQuotaConfig(providerSpecificData); if (teamConfig.state === "incomplete") { return { message: glmTeamQuotaIncompleteMessage(teamConfig.missing) }; } const { url: quotaUrl, headers } = buildGlmQuotaFetch(apiKey, providerSpecificData); const res = await fetch(quotaUrl, { headers }); if (!res.ok) { if (res.status === 401) throw new Error("Invalid API key"); throw new Error(`GLM quota API error (${res.status})`); } const json = await res.json(); if (!json || typeof json !== "object") { throw new Error("Invalid JSON response from GLM quota API"); } if (toNumber(json.code, 200) === 401) { throw new Error("Invalid API key"); } if (json.success === false) { const upstreamMsg = sanitizeGlmQuotaErrorMessage(json.msg ?? json.message); if (shouldSuggestGlmTeamQuota(teamConfig, providerSpecificData, toRecord(json), upstreamMsg)) { return { message: glmTeamQuotaHintMessage() }; } return { message: upstreamMsg }; } const data = toRecord(json.data); const limits: unknown[] = Array.isArray(data.limits) ? data.limits : []; const quotas: Record = {}; for (const limit of limits) { const src = toRecord(limit); const type = String(src.type || "").toUpperCase(); const resetMs = toNumber(src.nextResetTime, 0); const resetAt = resetMs > 0 ? new Date(resetMs).toISOString() : null; if (type === "TOKENS_LIMIT") { const quotaName = getGlmTokenQuotaName(src, quotas); const usedPercent = toPercentage(src.percentage); const remaining = Math.max(0, 100 - usedPercent); quotas[quotaName] = { used: usedPercent, total: 100, remaining, remainingPercentage: remaining, resetAt, displayName: getGlmQuotaDisplayName(quotaName), details: Array.isArray(src.models) ? (src.models as unknown[]).map((m) => { const modelInfo = toRecord(m); return { name: String(modelInfo.model || ""), used: toNumber(modelInfo.percentage, 0), }; }) : [], unlimited: false, }; continue; } if (type === "TIME_LIMIT") { const total = toNumber(src.usage, toNumber(src.total, 0)); const remaining = toNumber(src.remaining, Math.max(0, 100 - toPercentage(src.percentage))); const used = toNumber(src.currentValue, Math.max(0, total - remaining)); const remainingPercentage = glmMonthlyRemainingPercentage(total, remaining); quotas["mcp_monthly"] = { used, total, remaining, remainingPercentage, resetAt, unlimited: false, displayName: "Monthly", details: Array.isArray(src.usageDetails) ? src.usageDetails.map((item) => { const detail = toRecord(item); return { name: String(detail.modelCode || detail.name || "usage"), used: toNumber(detail.usage, 0), }; }) : undefined, }; } } const levelRaw = typeof data.planName === "string" ? data.planName : typeof data.level === "string" ? data.level : ""; const plan = levelRaw ? toTitleCase(levelRaw.replace(/\s*plan$/i, "")) : null; return { plan, quotas: orderGlmQuotas(quotas) }; }