Files
2026-07-13 13:39:12 +08:00

221 lines
7.6 KiB
TypeScript

/**
* 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<string, unknown>;
function getGlmTokenQuotaName(
limit: JsonRecord,
existingQuotas: Record<string, UsageQuota>
): 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<string, UsageQuota>): Record<string, UsageQuota> {
const ordered: Record<string, UsageQuota> = {};
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<typeof getGlmTeamQuotaConfig>,
_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<string, unknown>) {
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<string, UsageQuota> = {};
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) };
}