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
168 changes: 3 additions & 165 deletions open-sse/services/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ import {
getMiniMaxAuthErrorMessage,
getMiniMaxErrorSummary,
} from "./usage/minimax.ts";
import { getGlmUsage } from "./usage/glm.ts";
// Re-exported para o teste glm-coding-plan-monthly (importa de services/usage).
export { glmMonthlyRemainingPercentage } from "./usage/glm.ts";

// Quota / usage upstream URLs (overridable for testing or relays).
const CROF_USAGE_URL = process.env.OMNIROUTE_CROF_USAGE_URL ?? "https://crof.ai/usage_api/";
Expand Down Expand Up @@ -147,24 +150,6 @@ type SubscriptionCacheEntry = {
fetchedAt: number;
};

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 === 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;
}
function shouldDisplayGitHubQuota(quota: UsageQuota | null): quota is UsageQuota {
if (!quota) return false;
if (quota.unlimited && quota.total <= 0) return false;
Expand Down Expand Up @@ -327,153 +312,6 @@ async function getCrofUsage(apiKey: string) {
return { quotas };
}

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)));
}

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 quotaUrl = getGlmQuotaUrl(providerSpecificData);

const res = await fetch(quotaUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});

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 (toNumber(json.code, 200) === 401 || json.success === false) {
throw new Error("Invalid API key");
}

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) };
}

/**
* Bailian (Alibaba Coding Plan) Usage
* Fetches triple-window quota (5h, weekly, monthly) and returns worst-case.
Expand Down
182 changes: 182 additions & 0 deletions open-sse/services/usage/glm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* 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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Importe parseResetTime de ./quota.ts para utilizar o utilitário robusto e compartilhado de parsing de timestamps de reset, em vez de fazer isso manualmente.

Suggested change
import { type UsageQuota } from "./quota.ts";
import { type UsageQuota, parseResetTime } from "./quota.ts";

import { getGlmQuotaUrl } 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 === 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;
}
}
Comment on lines +39 to +56

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

A função getGlmQuotaLabel está definida aqui, mas nunca é chamada dentro deste arquivo, nem é exportada para uso em outros módulos. Para manter o código limpo e livre de dead code, ela deve ser removida.


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)));
}

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 quotaUrl = getGlmQuotaUrl(providerSpecificData);

const res = await fetch(quotaUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});

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 (toNumber(json.code, 200) === 401 || json.success === false) {
throw new Error("Invalid API key");
Comment on lines +105 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Para evitar possíveis erros de execução (TypeError) caso a API de upstream retorne null ou um primitivo que não seja um objeto, envolva a resposta JSON parseada em toRecord antes de acessar suas propriedades. Isso segue as melhores práticas de programação defensiva.

Suggested change
const json = await res.json();
if (toNumber(json.code, 200) === 401 || json.success === false) {
throw new Error("Invalid API key");
const json = toRecord(await res.json());
if (toNumber(json.code, 200) === 401 || json.success === false) {
throw new Error("Invalid API key");
}

}

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;
Comment on lines +117 to +118

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Em vez de fazer o parsing manual do timestamp de reset com toNumber e new Date, utilize o utilitário compartilhado parseResetTime importado de ./quota.ts. Isso garante um tratamento consistente e robusto para diversos formatos de timestamp (segundos, milissegundos, strings ISO) e captura erros de parsing com segurança.

Suggested change
const resetMs = toNumber(src.nextResetTime, 0);
const resetAt = resetMs > 0 ? new Date(resetMs).toISOString() : null;
const resetAt = parseResetTime(src.nextResetTime);


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) };
}
Loading
Loading