mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-18 21:02:50 +03:00
feat(usage): redeem GLM Coding Plan Reset Cards from Provider Limits (#12754)
* feat(usage): redeem GLM Coding Plan Reset Cards from Provider Limits z.ai sells Reset Cards that clear an exhausted GLM coding-plan window (5-hour or weekly) ahead of its natural rollover, but OmniRoute only ever read the passive nextResetTime, so redeeming one meant leaving the dashboard. Add the wire layer for z.ai's two reset endpoints (/api/biz/customer-package-reset/list and /use), which authenticate with the same Bearer API key as /api/monitor/usage/quota/limit and report failures inside an HTTP-200 envelope, so callers must inspect success/code rather than the status line. The banked count rides along with the quota poll - only for keys that actually report a resettable window, and strictly best-effort so a card-less account or a transient failure still renders its quotas. The existing reset-credit card, picker and confirmation flow, until now gated to Codex, now also drive glm/glm-cn/glmt/zai through the new /api/usage/glm-reset-card route, reusing z.ai's requestId as the idempotency key so a retry cannot burn two cards. * test(usage): cover GLM reset-card edge cases * test(dashboard): require GLM reset-card copy * fix(usage): harden GLM reset-card redemption * fix(usage): treat missing GLM key as empty * fix(usage): fence GLM reset-card operations and coalesce lease-window duplicates - Acquire a synthetic 60s exclusive-connection lease around each list/use wire operation; release in finally so a competing lease can acquire immediately after success or failure. - Coalesce same-key duplicates that arrive after lease acquisition by checking the in-flight attempt before loading the connection. - Run the post-commit quota refresh outside the lease (redemption is already committed; the refresh is auxiliary and failure-tolerant). - Do not discard a retained ambiguous attempt on a lease-conflict 409. - Harden transport error mapping: static messages for proxy transport failures, keep explicit direct routing for unproxied connections through list, use, and refresh. * fix(i18n): sync GLM reset-card keys to pt-BR and vi locales --------- Co-authored-by: insoln <is@careerum.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
4d9c4d3d8f
commit
241e63bfea
1
changelog.d/features/glm-coding-plan-reset-card.md
Normal file
1
changelog.d/features/glm-coding-plan-reset-card.md
Normal file
@@ -0,0 +1 @@
|
||||
- **feat(usage):** redeem **GLM Coding Plan Reset Cards** (`glm` / `glm-cn` / `glmt` / `zai`) from the Provider Limits UI — clear an exhausted 5-hour or weekly coding-plan window before it rolls over, via the new `/api/usage/glm-reset-card` route (`GET` lists, `POST` redeems). List and redeem requests egress through the connection's proxy and honor exclusive-lease isolation; z.ai's `requestId` is reused for retries of an ambiguous (transport-failed) redemption so a lost response cannot double-consume a card (in-memory, best-effort — restart the server and a fresh key is required). Responses are validated fail-closed (HTTP 200 alone is never treated as success), unavailable/expired cards are filtered and the list is sorted by earliest expiry, and the post-redemption quota refresh is best-effort: a refresh failure still reports the successful reset.
|
||||
@@ -388,6 +388,41 @@ export function buildGlmQuotaFetch(
|
||||
return { url, headers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Coding Plan Reset Card endpoints, mirroring GLM_QUOTA_URLS. `/list` reports the cards
|
||||
* banked on the key, `/use` redeems one. Same Bearer credential as the quota route.
|
||||
*/
|
||||
export const GLM_RESET_CARD_URLS = Object.freeze({
|
||||
international: "https://api.z.ai/api/biz/customer-package-reset",
|
||||
china: "https://open.bigmodel.cn/api/biz/customer-package-reset",
|
||||
});
|
||||
|
||||
export type GlmResetCardAction = "list" | "use";
|
||||
|
||||
export function buildGlmResetCardFetch(
|
||||
apiKey: string,
|
||||
providerSpecificData: unknown,
|
||||
action: GlmResetCardAction
|
||||
): { url: string; headers: Record<string, string> } {
|
||||
const base = GLM_RESET_CARD_URLS[getGlmApiRegion(providerSpecificData)];
|
||||
const url = action === "list" ? `${base}/list?targetType=PERSONAL` : `${base}/use`;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
...(action === "use" ? { "Content-Type": "application/json" } : {}),
|
||||
};
|
||||
|
||||
// Team-plan keys carry the same org/project routing headers as the quota fetch.
|
||||
const teamConfig = getGlmTeamQuotaConfig(providerSpecificData);
|
||||
if (teamConfig.state === "configured") {
|
||||
headers["bigmodel-organization"] = teamConfig.organizationId;
|
||||
headers["bigmodel-project"] = teamConfig.projectId;
|
||||
}
|
||||
|
||||
return { url, headers };
|
||||
}
|
||||
|
||||
function stripKnownGlmEndpointSuffix(baseUrl: string): { base: string; suffix: string } {
|
||||
const parts = splitUrlQueryAndHash(baseUrl);
|
||||
let base = parts.base;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { toNumber, toRecord, toTitleCase, toPercentage } from "./scalars.ts";
|
||||
import { type UsageQuota } from "./quota.ts";
|
||||
import { buildGlmQuotaFetch, getGlmTeamQuotaConfig } from "../../config/glmProvider.ts";
|
||||
import { fetchGlmResetCardCount } from "./glmResetCards.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -110,6 +111,14 @@ function shouldSuggestGlmTeamQuota(
|
||||
return /coding\s*plan|不存在.*plan|没有.*coding|团队|编码套餐/i.test(upstreamMsg);
|
||||
}
|
||||
|
||||
/**
|
||||
* A reset card can only clear the 5-hour or the weekly coding-plan window, so a key that
|
||||
* reports neither can never have one banked — used to skip the extra reset-card request.
|
||||
*/
|
||||
function hasResettableGlmWindow(quotas: Record<string, UsageQuota>): boolean {
|
||||
return Boolean(quotas.session || quotas.weekly);
|
||||
}
|
||||
|
||||
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." };
|
||||
@@ -231,5 +240,21 @@ export async function getGlmUsage(apiKey: string, providerSpecificData?: Record<
|
||||
: "";
|
||||
const plan = levelRaw ? toTitleCase(levelRaw.replace(/\s*plan$/i, "")) : null;
|
||||
|
||||
return { plan, quotas: orderGlmQuotas(quotas) };
|
||||
const orderedQuotas = orderGlmQuotas(quotas);
|
||||
|
||||
// Coding Plan Reset Cards live on a separate endpoint, so surfacing the banked count costs
|
||||
// one extra request. Only pay it for keys that actually report a resettable window — a
|
||||
// pay-as-you-go key can never hold a card — and keep it best-effort (the helper never
|
||||
// throws). The count is tri-state: a successful list reports a number (0 is an
|
||||
// authoritative "no cards"), while a transport/envelope failure reports null so the
|
||||
// cache layer can preserve the previously known count instead of erasing it.
|
||||
const bankedResetCredits = hasResettableGlmWindow(quotas)
|
||||
? await fetchGlmResetCardCount(apiKey, providerSpecificData)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
plan,
|
||||
quotas: orderedQuotas,
|
||||
...(bankedResetCredits !== null ? { bankedResetCredits } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
244
open-sse/services/usage/glmResetCards.ts
Normal file
244
open-sse/services/usage/glmResetCards.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { buildGlmResetCardFetch, type GlmResetCardAction } from "../../config/glmProvider.ts";
|
||||
import { toNumber, toRecord } from "./scalars.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export const GLM_RESET_CARD_TARGET_TYPE = "PERSONAL";
|
||||
|
||||
const GLM_RESET_CARD_TIMEOUT_MS = 15_000;
|
||||
const ZAI_TIMESTAMP_PATTERN =
|
||||
/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,3}))?$/;
|
||||
|
||||
export type GlmResetWindow = "FIVE_HOUR" | "WEEK";
|
||||
|
||||
const GLM_RESET_CARD_BUCKETS: ReadonlyArray<{ key: string; resetType: GlmResetWindow }> = [
|
||||
{ key: "fiveHourResets", resetType: "FIVE_HOUR" },
|
||||
{ key: "weekResets", resetType: "WEEK" },
|
||||
];
|
||||
|
||||
export interface GlmResetCard {
|
||||
id: string;
|
||||
resetType: GlmResetWindow;
|
||||
expiresAt?: string | null;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface GlmResetCardList {
|
||||
cards: GlmResetCard[];
|
||||
availableCount: number;
|
||||
lastFiveHourResetAt: string | null;
|
||||
lastWeekResetAt: string | null;
|
||||
}
|
||||
|
||||
function firstString(record: JsonRecord, keys: readonly string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeStatus(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "");
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function isUnavailableCard(record: JsonRecord): boolean {
|
||||
const status = normalizeStatus(
|
||||
record.status ?? record.state ?? record.outcome ?? record.result ?? record.code
|
||||
);
|
||||
if (
|
||||
status &&
|
||||
["consumed", "redeeming", "redeemed", "used", "expired", "unavailable"].includes(status)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return record.available === false || record.consumed === true || record.redeemed === true;
|
||||
}
|
||||
|
||||
/** Parse z.ai's timezone-less dashboard timestamp as UTC, while retaining normal ISO support. */
|
||||
export function parseGlmResetCardTimestamp(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
const zaiMatch = ZAI_TIMESTAMP_PATTERN.exec(trimmed);
|
||||
if (zaiMatch) {
|
||||
const [, year, month, day, hour, minute, second, fraction = "0"] = zaiMatch;
|
||||
const parts = [year, month, day, hour, minute, second].map(Number);
|
||||
const milliseconds = Number(fraction.padEnd(3, "0"));
|
||||
const timestamp = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], parts[5]);
|
||||
const date = new Date(timestamp);
|
||||
if (
|
||||
date.getUTCFullYear() !== parts[0] ||
|
||||
date.getUTCMonth() + 1 !== parts[1] ||
|
||||
date.getUTCDate() !== parts[2] ||
|
||||
date.getUTCHours() !== parts[3] ||
|
||||
date.getUTCMinutes() !== parts[4] ||
|
||||
date.getUTCSeconds() !== parts[5]
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return timestamp + milliseconds;
|
||||
}
|
||||
|
||||
const timestamp = Date.parse(trimmed);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
function parseResetWindow(value: unknown, fallback: GlmResetWindow): GlmResetWindow {
|
||||
const normalized = typeof value === "string" ? value.trim().toUpperCase() : "";
|
||||
if (normalized === "WEEK" || normalized === "FIVE_HOUR") return normalized;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function parseResetCard(value: unknown, fallbackType: GlmResetWindow): GlmResetCard | null {
|
||||
const record = toRecord(value);
|
||||
if (Object.keys(record).length === 0 || isUnavailableCard(record)) return null;
|
||||
|
||||
const id = firstString(record, ["recordId", "id", "packageResetId", "resetId"]);
|
||||
if (!id) return null;
|
||||
|
||||
const expiresAt = firstString(record, ["expireTime", "expiredTime", "expiresAt", "endTime"]);
|
||||
if (expiresAt) {
|
||||
const expiresAtMs = parseGlmResetCardTimestamp(expiresAt);
|
||||
if (expiresAtMs !== null && expiresAtMs <= Date.now()) return null;
|
||||
}
|
||||
const title = firstString(record, ["packageName", "name", "title"]);
|
||||
|
||||
return {
|
||||
id,
|
||||
resetType: parseResetWindow(record.resetType ?? record.type, fallbackType),
|
||||
...(expiresAt ? { expiresAt } : {}),
|
||||
...(title ? { title } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getExpirySortValue(card: GlmResetCard): number {
|
||||
if (!card.expiresAt) return Number.POSITIVE_INFINITY;
|
||||
return parseGlmResetCardTimestamp(card.expiresAt) ?? Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
export function parseGlmResetCards(payload: unknown): GlmResetCardList {
|
||||
const data = toRecord(toRecord(payload).data);
|
||||
const parsed: Array<{ card: GlmResetCard; index: number }> = [];
|
||||
|
||||
for (const bucket of GLM_RESET_CARD_BUCKETS) {
|
||||
const entries = data[bucket.key];
|
||||
if (!Array.isArray(entries)) continue;
|
||||
for (const entry of entries) {
|
||||
const card = parseResetCard(entry, bucket.resetType);
|
||||
if (card) parsed.push({ card, index: parsed.length });
|
||||
}
|
||||
}
|
||||
|
||||
const cards = parsed
|
||||
.sort((a, b) => getExpirySortValue(a.card) - getExpirySortValue(b.card) || a.index - b.index)
|
||||
.map(({ card }) => card);
|
||||
|
||||
return {
|
||||
cards,
|
||||
availableCount: cards.length,
|
||||
lastFiveHourResetAt: firstString(data, ["lastFiveHourResetTime"]),
|
||||
lastWeekResetAt: firstString(data, ["lastWeekResetTime"]),
|
||||
};
|
||||
}
|
||||
|
||||
/** HTTP success alone is insufficient: require z.ai's complete application envelope. */
|
||||
export function isGlmResetCardEnvelopeOk(payload: unknown): boolean {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
||||
const record = payload as JsonRecord;
|
||||
if (record.success !== true || typeof record.code !== "number" || !Number.isFinite(record.code)) {
|
||||
return false;
|
||||
}
|
||||
return record.code === 0 || record.code === 200;
|
||||
}
|
||||
|
||||
/** A truncated successful list envelope must not become an authoritative empty card list. */
|
||||
export function isGlmResetCardListEnvelopeOk(payload: unknown): boolean {
|
||||
if (!isGlmResetCardEnvelopeOk(payload)) return false;
|
||||
const data = (payload as JsonRecord).data;
|
||||
if (data === null || typeof data !== "object" || Array.isArray(data)) return false;
|
||||
// Both reset buckets are always present as arrays in a complete list response.
|
||||
// A `data: {}` truncation must fail closed instead of parsing as zero cards.
|
||||
return GLM_RESET_CARD_BUCKETS.every((bucket) => Array.isArray((data as JsonRecord)[bucket.key]));
|
||||
}
|
||||
|
||||
export function getGlmResetCardEnvelopeStatus(payload: unknown, httpStatus: number): number {
|
||||
const code = toNumber(toRecord(payload).code, 0);
|
||||
if (code === 401 || code === 403 || code === 404 || code === 429) return code;
|
||||
if (code === 1001) return 401;
|
||||
return httpStatus;
|
||||
}
|
||||
|
||||
export function getGlmResetCardEnvelopeMessage(payload: unknown): string | null {
|
||||
const record = toRecord(payload);
|
||||
const message = record.msg ?? record.message;
|
||||
return typeof message === "string" && message.trim() ? message.trim() : null;
|
||||
}
|
||||
|
||||
async function requestGlmResetCards(
|
||||
apiKey: string,
|
||||
providerSpecificData: unknown,
|
||||
action: GlmResetCardAction,
|
||||
body?: JsonRecord
|
||||
): Promise<{ response: Response; payload: unknown }> {
|
||||
const { url, headers } = buildGlmResetCardFetch(apiKey, providerSpecificData, action);
|
||||
const response = await fetch(url, {
|
||||
method: action === "use" ? "POST" : "GET",
|
||||
headers,
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(GLM_RESET_CARD_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let payload: unknown = {};
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
|
||||
return { response, payload };
|
||||
}
|
||||
|
||||
export function fetchGlmResetCardList(
|
||||
apiKey: string,
|
||||
providerSpecificData?: unknown
|
||||
): Promise<{ response: Response; payload: unknown }> {
|
||||
return requestGlmResetCards(apiKey, providerSpecificData, "list");
|
||||
}
|
||||
|
||||
export function redeemGlmResetCard(
|
||||
apiKey: string,
|
||||
providerSpecificData: unknown,
|
||||
card: { id: string; resetType: GlmResetWindow },
|
||||
requestId: string
|
||||
): Promise<{ response: Response; payload: unknown }> {
|
||||
const numericId = Number(card.id);
|
||||
return requestGlmResetCards(apiKey, providerSpecificData, "use", {
|
||||
targetType: GLM_RESET_CARD_TARGET_TYPE,
|
||||
resetType: card.resetType,
|
||||
recordId: Number.isFinite(numericId) ? numericId : card.id,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Null means the auxiliary request failed; zero is an authoritative empty list. */
|
||||
export async function fetchGlmResetCardCount(
|
||||
apiKey: string,
|
||||
providerSpecificData?: unknown
|
||||
): Promise<number | null> {
|
||||
if (!apiKey) return 0;
|
||||
try {
|
||||
const { response, payload } = await fetchGlmResetCardList(apiKey, providerSpecificData);
|
||||
if (!response.ok || !isGlmResetCardListEnvelopeOk(payload)) return null;
|
||||
return parseGlmResetCards(payload).availableCount;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,60 @@ interface Props {
|
||||
availableCount: number;
|
||||
isOpen: boolean;
|
||||
loading: boolean;
|
||||
provider: string;
|
||||
onClose: () => void;
|
||||
onRedeem: (selectionToken: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function isGlmResetProvider(provider: string): boolean {
|
||||
return provider !== "codex";
|
||||
}
|
||||
|
||||
export function getResetCreditWindowTitle(
|
||||
provider: string,
|
||||
credit: CodexResetCreditView,
|
||||
tr: (key: string, fallback: string, values?: UsageTranslationValues) => string
|
||||
): string {
|
||||
if (!isGlmResetProvider(provider)) {
|
||||
return credit.title || tr("resetCreditDefaultTitle", "Full reset");
|
||||
}
|
||||
|
||||
const windowTitle =
|
||||
credit.resetType === "WEEK"
|
||||
? tr("glmResetCreditWeekTitle", "Weekly window reset")
|
||||
: tr("glmResetCreditFiveHourTitle", "5-hour window reset");
|
||||
return credit.title ? `${windowTitle} · ${credit.title}` : windowTitle;
|
||||
}
|
||||
|
||||
export function getResetCreditConfirmation(
|
||||
provider: string,
|
||||
resetType: string | undefined,
|
||||
tr: (key: string, fallback: string, values?: UsageTranslationValues) => string
|
||||
): string {
|
||||
if (!isGlmResetProvider(provider)) {
|
||||
return tr(
|
||||
"confirmRedeemResetCredit",
|
||||
"Redeeming immediately resets the eligible Codex usage windows and permanently consumes this credit."
|
||||
);
|
||||
}
|
||||
if (resetType === "WEEK") {
|
||||
return tr(
|
||||
"glmConfirmRedeemWeekResetCredit",
|
||||
"Redeeming immediately resets the weekly usage window and permanently consumes this card."
|
||||
);
|
||||
}
|
||||
if (resetType === "FIVE_HOUR") {
|
||||
return tr(
|
||||
"glmConfirmRedeemFiveHourResetCredit",
|
||||
"Redeeming immediately resets the 5-hour usage window and permanently consumes this card."
|
||||
);
|
||||
}
|
||||
return tr(
|
||||
"glmConfirmRedeemResetCredit",
|
||||
"Redeeming immediately resets the selected usage window and permanently consumes this card."
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeExpiry(expiresAt: string | null | undefined): string | null {
|
||||
if (!expiresAt) return null;
|
||||
const diffMs = new Date(expiresAt).getTime() - Date.now();
|
||||
@@ -80,9 +130,11 @@ function ResetCreditModalFooter({
|
||||
|
||||
function ResetCreditConfirmation({
|
||||
credit,
|
||||
provider,
|
||||
tr,
|
||||
}: {
|
||||
credit: CodexResetCreditView;
|
||||
provider: string;
|
||||
tr: (key: string, fallback: string, values?: UsageTranslationValues) => string;
|
||||
}) {
|
||||
return (
|
||||
@@ -95,15 +147,12 @@ function ResetCreditConfirmation({
|
||||
{tr("confirmRedeemResetCreditTitle", "Redeem this reset credit?")}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{tr(
|
||||
"confirmRedeemResetCredit",
|
||||
"Redeeming immediately resets the eligible Codex usage windows and permanently consumes this credit."
|
||||
)}
|
||||
{getResetCreditConfirmation(provider, credit.resetType, tr)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CreditSummary credit={credit} tr={tr} />
|
||||
<CreditSummary credit={credit} provider={provider} tr={tr} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -113,12 +162,14 @@ function ResetCreditList({
|
||||
credits,
|
||||
loading,
|
||||
onSelect,
|
||||
provider,
|
||||
tr,
|
||||
}: {
|
||||
availableCount: number;
|
||||
credits: CodexResetCreditView[];
|
||||
loading: boolean;
|
||||
onSelect: (selectionToken: string) => void;
|
||||
provider: string;
|
||||
tr: (key: string, fallback: string, values?: UsageTranslationValues) => string;
|
||||
}) {
|
||||
if (credits.length === 0) {
|
||||
@@ -141,7 +192,7 @@ function ResetCreditList({
|
||||
key={credit.selectionToken}
|
||||
className="flex flex-col gap-3 rounded-lg border border-border bg-bg-subtle/40 p-3 sm:flex-row sm:items-center"
|
||||
>
|
||||
<CreditSummary credit={credit} tr={tr} recommended={index === 0} />
|
||||
<CreditSummary credit={credit} provider={provider} tr={tr} recommended={index === 0} />
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
@@ -161,6 +212,7 @@ export default function CodexResetCreditsModal({
|
||||
availableCount,
|
||||
isOpen,
|
||||
loading,
|
||||
provider,
|
||||
onClose,
|
||||
onRedeem,
|
||||
}: Props) {
|
||||
@@ -196,7 +248,11 @@ export default function CodexResetCreditsModal({
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
title={tr("resetCreditsModalTitle", "Codex reset credits")}
|
||||
title={
|
||||
isGlmResetProvider(provider)
|
||||
? tr("glmResetCreditsModalTitle", "GLM Coding Plan reset cards")
|
||||
: tr("resetCreditsModalTitle", "Codex reset credits")
|
||||
}
|
||||
size="lg"
|
||||
closeOnOverlay={!loading}
|
||||
footer={
|
||||
@@ -211,20 +267,26 @@ export default function CodexResetCreditsModal({
|
||||
}
|
||||
>
|
||||
{confirming && selectedCredit ? (
|
||||
<ResetCreditConfirmation credit={selectedCredit} tr={tr} />
|
||||
<ResetCreditConfirmation credit={selectedCredit} provider={provider} tr={tr} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
{tr(
|
||||
"resetCreditsModalExplainer",
|
||||
"Credits are ordered by expiration. Automatic redemption always uses the credit that expires first."
|
||||
)}
|
||||
{isGlmResetProvider(provider)
|
||||
? tr(
|
||||
"glmResetCreditsModalExplainer",
|
||||
"Reset cards are ordered by expiration. Choose the 5-hour or weekly window you want to reset."
|
||||
)
|
||||
: tr(
|
||||
"resetCreditsModalExplainer",
|
||||
"Credits are ordered by expiration. Automatic redemption always uses the credit that expires first."
|
||||
)}
|
||||
</p>
|
||||
<ResetCreditList
|
||||
availableCount={availableCount}
|
||||
credits={credits}
|
||||
loading={loading}
|
||||
onSelect={beginRedeem}
|
||||
provider={provider}
|
||||
tr={tr}
|
||||
/>
|
||||
</div>
|
||||
@@ -235,10 +297,12 @@ export default function CodexResetCreditsModal({
|
||||
|
||||
function CreditSummary({
|
||||
credit,
|
||||
provider,
|
||||
recommended = false,
|
||||
tr,
|
||||
}: {
|
||||
credit: CodexResetCreditView;
|
||||
provider: string;
|
||||
recommended?: boolean;
|
||||
tr: (key: string, fallback: string, values?: UsageTranslationValues) => string;
|
||||
}) {
|
||||
@@ -250,7 +314,7 @@ function CreditSummary({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-text-main">
|
||||
{credit.title || tr("resetCreditDefaultTitle", "Full reset")}
|
||||
{getResetCreditWindowTitle(provider, credit, tr)}
|
||||
</span>
|
||||
{recommended && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
import Card from "@/shared/components/Card";
|
||||
import { CardSkeleton } from "@/shared/components/Loading";
|
||||
import { pickDisplayValue } from "@/shared/utils/maskEmail";
|
||||
import { supportsProviderQuota, isProviderQuotaVisible } from "@/shared/utils/providerQuotaVisibility";
|
||||
import {
|
||||
supportsProviderQuota,
|
||||
isProviderQuotaVisible,
|
||||
} from "@/shared/utils/providerQuotaVisibility";
|
||||
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
|
||||
@@ -1110,6 +1113,7 @@ export default function ProviderLimits({
|
||||
credits={resetCreditRedemption.resetCreditPicker.credits}
|
||||
availableCount={resetCreditRedemption.resetCreditPicker.availableCount}
|
||||
loading={resetCreditRedemption.redeemingResetCreditId !== null}
|
||||
provider={resetCreditRedemption.resetCreditPicker.provider}
|
||||
onClose={resetCreditRedemption.closeResetCreditPicker}
|
||||
onRedeem={resetCreditRedemption.redeemCodexResetCredit}
|
||||
/>
|
||||
|
||||
@@ -21,10 +21,10 @@ const PROVIDER_COLUMNS: Record<string, string[]> = {
|
||||
"banked_reset_credits",
|
||||
],
|
||||
claude: ["session", "weekly"],
|
||||
glm: ["session", "weekly", "mcp_monthly"],
|
||||
"glm-cn": ["session", "weekly", "mcp_monthly"],
|
||||
glmt: ["session", "weekly", "mcp_monthly"],
|
||||
zai: ["session", "weekly", "mcp_monthly"],
|
||||
glm: ["session", "weekly", "mcp_monthly", "banked_reset_credits"],
|
||||
"glm-cn": ["session", "weekly", "mcp_monthly", "banked_reset_credits"],
|
||||
glmt: ["session", "weekly", "mcp_monthly", "banked_reset_credits"],
|
||||
zai: ["session", "weekly", "mcp_monthly", "banked_reset_credits"],
|
||||
github: ["chat", "completions", "premium_interactions"],
|
||||
minimax: ["session"],
|
||||
"minimax-cn": ["session"],
|
||||
|
||||
@@ -10,7 +10,7 @@ const CODEX_QUOTA_ORDER: Record<string, number> = {
|
||||
gpt_5_3_codex_spark_weekly: 3,
|
||||
banked_reset_credits: 4,
|
||||
};
|
||||
const GLM_FAMILY_PROVIDERS = ["glm", "glm-cn", "glmt", "opencode-go"];
|
||||
const GLM_FAMILY_PROVIDERS = ["glm", "glm-cn", "glmt", "zai", "opencode-go"];
|
||||
const KIMI_CODING_PROVIDERS: readonly string[] = getProviderConnectionFamilyIds("kimi-coding");
|
||||
|
||||
/**
|
||||
@@ -163,7 +163,7 @@ function parseGithub(data: any) {
|
||||
}
|
||||
|
||||
function parseGlmFamily(data: any) {
|
||||
return quotaEntries(data).map(([name, quota]) =>
|
||||
const quotas = quotaEntries(data).map(([name, quota]) =>
|
||||
normalizeQuotaEntry(name, quota, {
|
||||
displayName: quota?.displayName,
|
||||
details: Array.isArray(quota?.details) ? quota.details : undefined,
|
||||
@@ -171,6 +171,14 @@ function parseGlmFamily(data: any) {
|
||||
Number(quota?.total || 0) === 100 && quota?.remainingPercentage !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
// GLM Coding Plan Reset Cards, surfaced by getGlmUsage alongside the windows.
|
||||
const bankedResetCredits = Number(data?.bankedResetCredits);
|
||||
if (Number.isFinite(bankedResetCredits) && bankedResetCredits > 0) {
|
||||
quotas.push(buildBankedResetCreditsQuota(bankedResetCredits));
|
||||
}
|
||||
|
||||
return quotas;
|
||||
}
|
||||
|
||||
function buildCreditsQuota(
|
||||
@@ -467,7 +475,7 @@ function looksLikeMoonshotBalance(data: any): boolean {
|
||||
function parseProviderQuotas(providerId: string, data: any) {
|
||||
if (looksLikeMoonshotBalance(data)) return parseMoonshotBalance(data);
|
||||
if (providerId === "github") return parseGithub(data);
|
||||
if (["glm", "glm-cn", "glmt", "opencode-go"].includes(providerId)) return parseGlmFamily(data);
|
||||
if (GLM_FAMILY_PROVIDERS.includes(providerId)) return parseGlmFamily(data);
|
||||
if (providerId === "antigravity" || providerId === "agy") return parseAntigravity(data);
|
||||
if (providerId === "codex") return parseCodex(data);
|
||||
if (providerId === "claude") return parseClaude(data);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import { parseQuotaData } from "./utils";
|
||||
import { canProviderRedeemResetCredit, getResetCreditEndpoint, parseQuotaData } from "./utils";
|
||||
import type { UsageTranslationValues } from "./i18nFallback";
|
||||
|
||||
type TranslateUsage = (key: string, fallback: string, values?: UsageTranslationValues) => string;
|
||||
@@ -43,18 +43,28 @@ interface ResetCreditRequestState {
|
||||
|
||||
// Module-level so the ref-store mutation stays outside any hook body — the
|
||||
// immutability rule bars in-callback writes to `state.idempotencyKeysRef.current`.
|
||||
function resetIdempotencyKeys(keys: React.MutableRefObject<Record<string, string>>): void {
|
||||
keys.current = {};
|
||||
function resetIdempotencyKey(
|
||||
keys: React.MutableRefObject<Record<string, string>>,
|
||||
connectionId: string,
|
||||
selectionToken: string
|
||||
): void {
|
||||
delete keys.current[`${connectionId}:${selectionToken}`];
|
||||
}
|
||||
|
||||
function ensureIdempotencyKey(
|
||||
keys: React.MutableRefObject<Record<string, string>>,
|
||||
connectionId: string,
|
||||
selectionToken: string
|
||||
): string {
|
||||
const existing = keys.current[selectionToken];
|
||||
const key = `${connectionId}:${selectionToken}`;
|
||||
const existing = keys.current[key];
|
||||
if (existing) return existing;
|
||||
|
||||
for (const storedKey of Object.keys(keys.current)) {
|
||||
if (storedKey.startsWith(`${connectionId}:`)) delete keys.current[storedKey];
|
||||
}
|
||||
const created = createIdempotencyKey();
|
||||
keys.current[selectionToken] = created;
|
||||
keys.current[key] = created;
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -68,20 +78,38 @@ function getRequestErrorMessage(error: unknown, fallback: string): string {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function applyCommittedResetCreditFallback(entry: any): any {
|
||||
if (!entry) return entry;
|
||||
const quotas = Array.isArray(entry.quotas)
|
||||
? entry.quotas.flatMap((quota: any) => {
|
||||
if (!quota?.isResetCredits) return [quota];
|
||||
const count = Math.max(0, Number(quota.creditCount ?? quota.remaining ?? 0) - 1);
|
||||
return count > 0 ? [{ ...quota, creditCount: count, remaining: count }] : [];
|
||||
})
|
||||
: entry.quotas;
|
||||
const raw =
|
||||
entry.raw && typeof entry.raw === "object"
|
||||
? {
|
||||
...entry.raw,
|
||||
bankedResetCredits: Math.max(0, Number(entry.raw.bankedResetCredits ?? 0) - 1),
|
||||
}
|
||||
: entry.raw;
|
||||
return { ...entry, quotas, raw };
|
||||
}
|
||||
|
||||
function useOpenCodexResetCredits(
|
||||
loadingResetCreditsId: string | null,
|
||||
redeemingResetCreditId: string | null,
|
||||
setErrors: SetErrors,
|
||||
setLoadingResetCreditsId: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
setResetCreditPicker: React.Dispatch<React.SetStateAction<ResetCreditPickerState | null>>,
|
||||
idempotencyKeysRef: React.MutableRefObject<Record<string, string>>,
|
||||
tr: TranslateUsage
|
||||
) {
|
||||
const notify = useNotificationStore();
|
||||
return useCallback(
|
||||
async (connectionId: string, provider: string) => {
|
||||
if (
|
||||
(provider !== "codex" && provider !== "grok-cli") ||
|
||||
!canProviderRedeemResetCredit(provider) ||
|
||||
loadingResetCreditsId ||
|
||||
redeemingResetCreditId
|
||||
)
|
||||
@@ -89,13 +117,14 @@ function useOpenCodexResetCredits(
|
||||
setLoadingResetCreditsId(connectionId);
|
||||
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
||||
try {
|
||||
const endpoint = getResetCreditEndpoint(provider);
|
||||
if (!endpoint) return;
|
||||
const response = await fetch(
|
||||
`/api/usage/codex-reset-credit?connectionId=${encodeURIComponent(connectionId)}`,
|
||||
`${endpoint}?connectionId=${encodeURIComponent(connectionId)}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || response.statusText);
|
||||
idempotencyKeysRef.current = {};
|
||||
setResetCreditPicker({
|
||||
connectionId,
|
||||
provider,
|
||||
@@ -116,7 +145,6 @@ function useOpenCodexResetCredits(
|
||||
}
|
||||
},
|
||||
[
|
||||
idempotencyKeysRef,
|
||||
loadingResetCreditsId,
|
||||
notify,
|
||||
redeemingResetCreditId,
|
||||
@@ -134,11 +162,17 @@ function useRedeemCodexResetCredit(state: ResetCreditRequestState) {
|
||||
async (selectionToken: string) => {
|
||||
const picker = state.resetCreditPicker;
|
||||
if (!picker || state.redeemingResetCreditId || !selectionToken) return;
|
||||
const idempotencyKey = ensureIdempotencyKey(state.idempotencyKeysRef, selectionToken);
|
||||
const idempotencyKey = ensureIdempotencyKey(
|
||||
state.idempotencyKeysRef,
|
||||
picker.connectionId,
|
||||
selectionToken
|
||||
);
|
||||
state.setRedeemingResetCreditId(picker.connectionId);
|
||||
state.setErrors((prev) => ({ ...prev, [picker.connectionId]: null }));
|
||||
try {
|
||||
const response = await fetch("/api/usage/codex-reset-credit", {
|
||||
const endpoint = getResetCreditEndpoint(picker.provider);
|
||||
if (!endpoint) return;
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -149,23 +183,36 @@ function useRedeemCodexResetCredit(state: ResetCreditRequestState) {
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || response.statusText);
|
||||
const usage = data.usage || {};
|
||||
state.setQuotaData((prev) => ({
|
||||
...prev,
|
||||
[picker.connectionId]: {
|
||||
quotas: parseQuotaData(picker.provider, usage),
|
||||
plan: usage.plan || null,
|
||||
message: usage.message || null,
|
||||
raw: usage,
|
||||
stale: usage._stale ? { since: usage._staleSince, reason: usage._staleReason } : null,
|
||||
},
|
||||
}));
|
||||
const refreshPending = data.refreshPending === true && !data.usage;
|
||||
if (refreshPending) {
|
||||
// The upstream redemption already committed but the post-commit quota
|
||||
// refresh failed: keep the existing windows and only decrement the
|
||||
// consumed reset-credit count so success is not reported as failure.
|
||||
state.setQuotaData((prev) => {
|
||||
const entry = prev[picker.connectionId];
|
||||
return entry
|
||||
? { ...prev, [picker.connectionId]: applyCommittedResetCreditFallback(entry) }
|
||||
: prev;
|
||||
});
|
||||
} else {
|
||||
const usage = data.usage || {};
|
||||
state.setQuotaData((prev) => ({
|
||||
...prev,
|
||||
[picker.connectionId]: {
|
||||
quotas: parseQuotaData(picker.provider, usage),
|
||||
plan: usage.plan || null,
|
||||
message: usage.message || null,
|
||||
raw: usage,
|
||||
stale: usage._stale ? { since: usage._staleSince, reason: usage._staleReason } : null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
state.setLastRefreshedAt((prev) => ({
|
||||
...prev,
|
||||
[picker.connectionId]: new Date().toISOString(),
|
||||
}));
|
||||
state.setResetCreditPicker(null);
|
||||
resetIdempotencyKeys(state.idempotencyKeysRef);
|
||||
resetIdempotencyKey(state.idempotencyKeysRef, picker.connectionId, selectionToken);
|
||||
notify.success(state.tr("resetCreditRedeemed", "Reset redeemed"));
|
||||
} catch (error) {
|
||||
const message = getRequestErrorMessage(
|
||||
@@ -199,7 +246,6 @@ export function useCodexResetCreditRedemption(
|
||||
setErrors,
|
||||
setLoadingResetCreditsId,
|
||||
setResetCreditPicker,
|
||||
idempotencyKeysRef,
|
||||
tr
|
||||
);
|
||||
const redeemCodexResetCredit = useRedeemCodexResetCredit({
|
||||
|
||||
@@ -337,9 +337,37 @@ export function computeCanEditCutoff(quotas: any[]): boolean {
|
||||
return quotas.some((q: any) => q && typeof q.name === "string" && !q.isCredits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Providers that can redeem a banked reset credit: Codex reset credits and the GLM
|
||||
* Coding Plan Reset Cards, which share the list/redeem contract behind different routes.
|
||||
*/
|
||||
export const RESET_CREDIT_PROVIDERS = [
|
||||
"codex",
|
||||
// grok-cli redeems through the same codex-reset-credit endpoint (tip of
|
||||
// release/v3.8.51); kept here so the generalized gate below covers it.
|
||||
"grok-cli",
|
||||
"glm",
|
||||
"glm-cn",
|
||||
"glmt",
|
||||
"zai",
|
||||
] as const;
|
||||
|
||||
/** The redemption API backing a provider's reset credits, if supported. */
|
||||
export function getResetCreditEndpoint(provider: string): string | null {
|
||||
if (provider === "codex" || provider === "grok-cli") return "/api/usage/codex-reset-credit";
|
||||
if (["glm", "glm-cn", "glmt", "zai"].includes(provider)) {
|
||||
return "/api/usage/glm-reset-card";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function canProviderRedeemResetCredit(provider: string): boolean {
|
||||
return (RESET_CREDIT_PROVIDERS as readonly string[]).includes(provider);
|
||||
}
|
||||
|
||||
export function computeCanRedeemResetCredit(provider: string, quotas: any[]): boolean {
|
||||
return (
|
||||
(provider === "codex" || provider === "grok-cli") &&
|
||||
canProviderRedeemResetCredit(provider) &&
|
||||
quotas.some((q: any) => q?.isResetCredits && Number(q.creditCount ?? q.remaining ?? 0) > 0)
|
||||
);
|
||||
}
|
||||
|
||||
78
src/app/api/usage/glm-reset-card/route.ts
Normal file
78
src/app/api/usage/glm-reset-card/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import {
|
||||
GlmResetCardError,
|
||||
consumeGlmResetCard,
|
||||
listGlmResetCards,
|
||||
} from "@/lib/usage/glmResetCards";
|
||||
|
||||
const ConnectionIdSchema = z.string().trim().min(1).max(256);
|
||||
|
||||
const GlmResetCardBodySchema = z.object({
|
||||
connectionId: ConnectionIdSchema,
|
||||
idempotencyKey: z.string().trim().min(1).max(256),
|
||||
creditId: z.string().trim().min(1).max(512).optional(),
|
||||
});
|
||||
|
||||
function buildErrorResponse(error: unknown) {
|
||||
const status = error instanceof GlmResetCardError ? error.status : 500;
|
||||
const code = error instanceof GlmResetCardError ? error.code : "glm_reset_card_failed";
|
||||
const message =
|
||||
error instanceof GlmResetCardError
|
||||
? sanitizeErrorMessage(error.message) || "GLM reset-card request failed."
|
||||
: "GLM reset-card request failed.";
|
||||
console.error("[API] GLM reset-card request failed", {
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
});
|
||||
return NextResponse.json({ ok: false, code, error: message }, { status });
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const parsed = ConnectionIdSchema.safeParse(
|
||||
new URL(request.url).searchParams.get("connectionId")
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, code: "invalid_connection_id", error: "Invalid connectionId." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const result = await listGlmResetCards(parsed.data);
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
return buildErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const raw = await request.json().catch(() => ({}));
|
||||
const parsed = GlmResetCardBodySchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, code: "invalid_request_body", error: "Invalid request body." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await consumeGlmResetCard(
|
||||
parsed.data.connectionId,
|
||||
parsed.data.idempotencyKey,
|
||||
parsed.data.creditId
|
||||
);
|
||||
return NextResponse.json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
return buildErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -9503,7 +9503,7 @@
|
||||
"redeemResetCredit": "Redeem reset",
|
||||
"manageResetCredits": "View credits",
|
||||
"viewResetCredits": "View reset credits",
|
||||
"resetCreditsModalTitle": "Codex reset credits",
|
||||
"resetCreditsModalTitle": "Reset credits",
|
||||
"resetCreditsModalExplainer": "Credits are ordered by expiration. Automatic redemption always uses the credit that expires first.",
|
||||
"resetCreditsLoadFailed": "Failed to load reset credits",
|
||||
"resetCreditsDetailsUnavailable": "Credit details are currently unavailable. Refresh and try again.",
|
||||
@@ -9514,10 +9514,17 @@
|
||||
"resetCreditNoExpiry": "No expiration date",
|
||||
"redeemThisResetCredit": "Redeem",
|
||||
"confirmRedeemResetCreditTitle": "Redeem this reset credit?",
|
||||
"confirmRedeemResetCredit": "Redeeming immediately resets the eligible Codex usage windows and permanently consumes this credit.",
|
||||
"confirmRedeemResetCredit": "Redeeming immediately resets the eligible usage windows and permanently consumes this credit.",
|
||||
"confirmRedeemResetCreditButton": "Redeem credit",
|
||||
"resetCreditRedeemed": "Reset redeemed",
|
||||
"resetCreditRedeemFailed": "Failed to redeem reset credit",
|
||||
"glmResetCreditsModalTitle": "GLM Coding Plan reset cards",
|
||||
"glmResetCreditsModalExplainer": "Reset cards are ordered by expiration. Choose the 5-hour or weekly window you want to reset.",
|
||||
"glmResetCreditFiveHourTitle": "5-hour window reset",
|
||||
"glmResetCreditWeekTitle": "Weekly window reset",
|
||||
"glmConfirmRedeemResetCredit": "Redeeming immediately resets the selected usage window and permanently consumes this card.",
|
||||
"glmConfirmRedeemFiveHourResetCredit": "Redeeming immediately resets the 5-hour usage window and permanently consumes this card.",
|
||||
"glmConfirmRedeemWeekResetCredit": "Redeeming immediately resets the weekly usage window and permanently consumes this card.",
|
||||
"suiteBuilderSaveFailed": "Failed to save suite",
|
||||
"clone": "Clone",
|
||||
"exportSuite": "Export",
|
||||
|
||||
@@ -9516,6 +9516,13 @@
|
||||
"confirmRedeemResetCreditTitle": "Resgatar este crédito de redefinição?",
|
||||
"confirmRedeemResetCredit": "O resgate redefine imediatamente as janelas de uso elegíveis do Codex e consome este crédito de forma permanente.",
|
||||
"confirmRedeemResetCreditButton": "Resgatar crédito",
|
||||
"glmResetCreditsModalTitle": "Cartões de redefinição do GLM Coding Plan",
|
||||
"glmResetCreditsModalExplainer": "Os cartões de redefinição estão ordenados por expiração. Escolha a janela de 5 horas ou semanal que deseja redefinir.",
|
||||
"glmResetCreditFiveHourTitle": "Redefinição da janela de 5 horas",
|
||||
"glmResetCreditWeekTitle": "Redefinição da janela semanal",
|
||||
"glmConfirmRedeemResetCredit": "O resgate redefine imediatamente a janela de uso selecionada e consome este cartão de forma permanente.",
|
||||
"glmConfirmRedeemFiveHourResetCredit": "O resgate redefine imediatamente a janela de uso de 5 horas e consome este cartão de forma permanente.",
|
||||
"glmConfirmRedeemWeekResetCredit": "O resgate redefine imediatamente a janela de uso semanal e consome este cartão de forma permanente.",
|
||||
"resetCreditRedeemed": "Redefinição resgatada",
|
||||
"resetCreditRedeemFailed": "Falha ao resgatar crédito de redefinição",
|
||||
"suiteBuilderSaveFailed": "Falha ao salvar a suíte customizada",
|
||||
|
||||
@@ -9516,6 +9516,13 @@
|
||||
"confirmRedeemResetCreditTitle": "Đổi tín dụng đặt lại này?",
|
||||
"confirmRedeemResetCredit": "Đổi một tín dụng đặt lại Codex cho tài khoản này? Thao tác này sẽ tiêu tốn một tín dụng đặt lại.",
|
||||
"confirmRedeemResetCreditButton": "Đổi tín dụng",
|
||||
"glmResetCreditsModalTitle": "Thẻ đặt lại của GLM Coding Plan",
|
||||
"glmResetCreditsModalExplainer": "Thẻ đặt lại được sắp xếp theo thời gian hết hạn. Chọn cửa sổ 5 giờ hoặc hàng tuần bạn muốn đặt lại.",
|
||||
"glmResetCreditFiveHourTitle": "Đặt lại cửa sổ 5 giờ",
|
||||
"glmResetCreditWeekTitle": "Đặt lại cửa sổ hàng tuần",
|
||||
"glmConfirmRedeemResetCredit": "Việc đổi sẽ đặt lại ngay cửa sổ sử dụng đã chọn và tiêu tốn vĩnh viễn thẻ này.",
|
||||
"glmConfirmRedeemFiveHourResetCredit": "Việc đổi sẽ đặt lại ngay cửa sổ sử dụng 5 giờ và tiêu tốn vĩnh viễn thẻ này.",
|
||||
"glmConfirmRedeemWeekResetCredit": "Việc đổi sẽ đặt lại ngay cửa sổ sử dụng hàng tuần và tiêu tốn vĩnh viễn thẻ này.",
|
||||
"resetCreditRedeemed": "Đã đổi lượt đặt lại",
|
||||
"resetCreditRedeemFailed": "Không thể đổi tín dụng đặt lại",
|
||||
"suiteBuilderSaveFailed": "Không thể lưu bộ đánh giá",
|
||||
|
||||
442
src/lib/usage/glmResetCards.ts
Normal file
442
src/lib/usage/glmResetCards.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
acquireExclusiveConnectionLease,
|
||||
releaseExclusiveConnectionLease,
|
||||
} from "@/lib/db/exclusiveConnectionLeases";
|
||||
import { getProviderConnectionById } from "@/lib/db/providers";
|
||||
import { resolveProxyForConnection } from "@/lib/db/settings";
|
||||
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
|
||||
import { fetchAndPersistProviderLimits } from "@/lib/usage/providerLimits";
|
||||
import {
|
||||
fetchGlmResetCardList,
|
||||
getGlmResetCardEnvelopeMessage,
|
||||
getGlmResetCardEnvelopeStatus,
|
||||
isGlmResetCardEnvelopeOk,
|
||||
isGlmResetCardListEnvelopeOk,
|
||||
parseGlmResetCards,
|
||||
redeemGlmResetCard,
|
||||
type GlmResetCard,
|
||||
} from "@omniroute/open-sse/services/usage/glmResetCards.ts";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
import {
|
||||
runWithDirectFetchContext,
|
||||
runWithProxyContext,
|
||||
} from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export const GLM_RESET_CARD_PROVIDERS = ["glm", "glm-cn", "glmt", "zai"] as const;
|
||||
|
||||
const ATTEMPT_TTL_MS = 10 * 60_000;
|
||||
const MAX_ATTEMPTS = 500;
|
||||
|
||||
// Short-lived synthetic operation lease: `exclusive_connection_leases` has no FK on
|
||||
// api_key_id, so a namespaced synthetic id is schema-legal. The partial unique index
|
||||
// on (connection_id) WHERE state = 'ACTIVE' gives an atomic fence around the wire
|
||||
// operations that no check-then-act availability probe can provide.
|
||||
const OPERATION_LEASE_TTL_MS = 60_000;
|
||||
const OPERATION_LEASE_API_KEY_ID = "omniroute:glm-reset-card-operation";
|
||||
const OPERATION_LEASE_OWNER_PREFIX = "vlo_";
|
||||
|
||||
type ProxyLike = Parameters<typeof runWithProxyContext>[0];
|
||||
|
||||
/** A connection with no proxy must go explicitly direct — passing null to
|
||||
* runWithProxyContext would inherit an ambient context instead. */
|
||||
function runWithConnectionFetch<T>(proxy: ProxyLike, fn: () => T): T {
|
||||
return proxy ? runWithProxyContext(proxy, fn) : runWithDirectFetchContext(fn);
|
||||
}
|
||||
|
||||
/** Map transport-layer proxy failures to safe typed errors. Public messages are
|
||||
* static: the underlying error text can embed proxy host:port topology. */
|
||||
function toSafeTransportError(error: unknown, fallback: GlmResetCardError): GlmResetCardError {
|
||||
const err = error as { code?: unknown; errorCode?: unknown };
|
||||
const code = typeof err?.code === "string" ? err.code : null;
|
||||
const errorCode = typeof err?.errorCode === "string" ? err.errorCode : null;
|
||||
if (code === "PROXY_UNREACHABLE" || errorCode === "proxy_unreachable") {
|
||||
return new GlmResetCardError(503, "proxy_unreachable", "The connection proxy is unreachable.");
|
||||
}
|
||||
if (code === "PROXY_FAMILY_UNAVAILABLE" || errorCode === "proxy_family_unavailable") {
|
||||
return new GlmResetCardError(
|
||||
502,
|
||||
"proxy_family_unavailable",
|
||||
"No proxy is available for this request."
|
||||
);
|
||||
}
|
||||
if (code === "PROXY_REQUEST_FAILED" || errorCode === "proxy_request_failed") {
|
||||
return new GlmResetCardError(502, "proxy_request_failed", "The proxy request failed.");
|
||||
}
|
||||
if (code === "RELAY_TIMEOUT" || errorCode === "relay_timeout") {
|
||||
return new GlmResetCardError(504, "relay_timeout", "The proxied request timed out.");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Atomically claim the connection for one list/use operation. */
|
||||
async function withOperationLease<T>(
|
||||
connection: GlmConnectionLike,
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const leaseOwnerId = OPERATION_LEASE_OWNER_PREFIX + randomBytes(32).toString("base64url");
|
||||
const acquired = acquireExclusiveConnectionLease({
|
||||
leaseOwnerId,
|
||||
apiKeyId: OPERATION_LEASE_API_KEY_ID,
|
||||
provider: connection.provider,
|
||||
connectionId: connection.id,
|
||||
ttlMs: OPERATION_LEASE_TTL_MS,
|
||||
});
|
||||
if (acquired.kind === "CONNECTION_BUSY" || acquired.kind === "OWNER_ALREADY_ACTIVE") {
|
||||
throw new GlmResetCardError(
|
||||
409,
|
||||
"exclusive_lease_active",
|
||||
"Reset-card operations are deferred while an exclusive lease is active."
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
releaseExclusiveConnectionLease({
|
||||
leaseOwnerId,
|
||||
generation: acquired.lease.generation,
|
||||
apiKeyId: OPERATION_LEASE_API_KEY_ID,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type GlmConnectionLike = JsonRecord & {
|
||||
id: string;
|
||||
provider: string;
|
||||
apiKey?: string;
|
||||
providerSpecificData?: JsonRecord;
|
||||
};
|
||||
|
||||
export type PublicGlmResetCard = Omit<GlmResetCard, "id"> & {
|
||||
selectionToken: string;
|
||||
};
|
||||
|
||||
export interface GlmResetCardListResult {
|
||||
credits: PublicGlmResetCard[];
|
||||
availableCount: number;
|
||||
lastFiveHourResetAt: string | null;
|
||||
lastWeekResetAt: string | null;
|
||||
}
|
||||
|
||||
export interface GlmResetCardConsumeResult {
|
||||
outcome: "reset";
|
||||
usage?: JsonRecord;
|
||||
refreshPending?: true;
|
||||
}
|
||||
|
||||
interface RedemptionAttempt {
|
||||
requestedSelection: string | null;
|
||||
card?: GlmResetCard;
|
||||
inFlight?: Promise<GlmResetCardConsumeResult>;
|
||||
committed?: GlmResetCardConsumeResult;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const redemptionAttempts = new Map<string, RedemptionAttempt>();
|
||||
|
||||
export class GlmResetCardError extends Error {
|
||||
status: number;
|
||||
code: string;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "GlmResetCardError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function isGlmResetCardProvider(provider: unknown): boolean {
|
||||
return (
|
||||
typeof provider === "string" &&
|
||||
(GLM_RESET_CARD_PROVIDERS as readonly string[]).includes(provider)
|
||||
);
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toPublicCard(card: GlmResetCard): PublicGlmResetCard {
|
||||
const { id, ...metadata } = card;
|
||||
return { ...metadata, selectionToken: id };
|
||||
}
|
||||
|
||||
function getProviderSpecificData(connection: GlmConnectionLike): JsonRecord {
|
||||
return {
|
||||
...toRecord(connection.providerSpecificData),
|
||||
...(connection.provider === "glm-cn" ? { apiRegion: "china" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadGlmConnection(connectionId: string): Promise<GlmConnectionLike> {
|
||||
if (await isConnectionUnavailableToAuxiliaryActivity(connectionId)) {
|
||||
throw new GlmResetCardError(
|
||||
409,
|
||||
"exclusive_lease_active",
|
||||
"Reset-card operations are deferred while an exclusive lease is active."
|
||||
);
|
||||
}
|
||||
|
||||
const connection = (await getProviderConnectionById(
|
||||
connectionId
|
||||
)) as unknown as GlmConnectionLike | null;
|
||||
|
||||
if (!connection) {
|
||||
throw new GlmResetCardError(404, "connection_not_found", "Connection not found.");
|
||||
}
|
||||
if (!isGlmResetCardProvider(connection.provider)) {
|
||||
throw new GlmResetCardError(
|
||||
400,
|
||||
"glm_provider_required",
|
||||
"Reset cards can only be redeemed for GLM coding-plan accounts."
|
||||
);
|
||||
}
|
||||
if (!connection.apiKey) {
|
||||
throw new GlmResetCardError(
|
||||
401,
|
||||
"glm_api_key_missing",
|
||||
"GLM coding-plan API key is missing on this connection."
|
||||
);
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
|
||||
function assertEnvelopeOk(
|
||||
payload: unknown,
|
||||
httpStatus: number,
|
||||
fallbackMessage: string,
|
||||
requireListData = false
|
||||
): void {
|
||||
const envelopeOk = requireListData
|
||||
? isGlmResetCardListEnvelopeOk(payload)
|
||||
: isGlmResetCardEnvelopeOk(payload);
|
||||
if (envelopeOk && httpStatus < 400) return;
|
||||
|
||||
const status = getGlmResetCardEnvelopeStatus(payload, httpStatus);
|
||||
if (status === 401 || status === 403) {
|
||||
throw new GlmResetCardError(
|
||||
401,
|
||||
"glm_reset_card_unauthorized",
|
||||
"The GLM API key was rejected by the reset-card API."
|
||||
);
|
||||
}
|
||||
|
||||
const upstreamMessage = getGlmResetCardEnvelopeMessage(payload);
|
||||
throw new GlmResetCardError(
|
||||
status >= 400 ? status : 502,
|
||||
"glm_reset_card_upstream_error",
|
||||
sanitizeErrorMessage(upstreamMessage) || fallbackMessage
|
||||
);
|
||||
}
|
||||
|
||||
function pruneAttempts(now = Date.now()): void {
|
||||
for (const [key, attempt] of redemptionAttempts) {
|
||||
if (!attempt.inFlight && attempt.expiresAt <= now) redemptionAttempts.delete(key);
|
||||
}
|
||||
|
||||
if (redemptionAttempts.size <= MAX_ATTEMPTS) return;
|
||||
for (const [key, attempt] of redemptionAttempts) {
|
||||
if (!attempt.inFlight) redemptionAttempts.delete(key);
|
||||
if (redemptionAttempts.size <= MAX_ATTEMPTS) break;
|
||||
}
|
||||
}
|
||||
|
||||
function attemptKey(connectionId: string, idempotencyKey: string): string {
|
||||
return `${connectionId}:${idempotencyKey}`;
|
||||
}
|
||||
|
||||
function normalizeSelection(selectionToken?: string): string | null {
|
||||
return typeof selectionToken === "string" && selectionToken.trim() ? selectionToken.trim() : null;
|
||||
}
|
||||
|
||||
function assertCompatibleAttempt(
|
||||
attempt: RedemptionAttempt,
|
||||
requestedSelection: string | null
|
||||
): void {
|
||||
if (attempt.requestedSelection !== requestedSelection) {
|
||||
throw new GlmResetCardError(
|
||||
409,
|
||||
"idempotency_key_conflict",
|
||||
"This idempotency key is already bound to a different reset-card selection."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAfterCommit(connectionId: string): Promise<GlmResetCardConsumeResult> {
|
||||
try {
|
||||
const refreshed = await fetchAndPersistProviderLimits(connectionId, "manual", {
|
||||
allowRotatingRefresh: true,
|
||||
});
|
||||
return { outcome: "reset", usage: refreshed.usage };
|
||||
} catch {
|
||||
// Redemption is already irreversible. The caller can preserve its current
|
||||
// quotas and refresh later instead of turning committed success into a 500.
|
||||
return { outcome: "reset", refreshPending: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listGlmResetCards(connectionId: string): Promise<GlmResetCardListResult> {
|
||||
if (!connectionId || typeof connectionId !== "string") {
|
||||
throw new GlmResetCardError(400, "connection_id_required", "connectionId is required.");
|
||||
}
|
||||
|
||||
try {
|
||||
const connection = await loadGlmConnection(connectionId);
|
||||
const proxyInfo = await resolveProxyForConnection(connection.id);
|
||||
const { response, payload } = await withOperationLease(connection, () =>
|
||||
runWithConnectionFetch(proxyInfo?.proxy ?? null, () =>
|
||||
fetchGlmResetCardList(connection.apiKey as string, getProviderSpecificData(connection))
|
||||
)
|
||||
);
|
||||
assertEnvelopeOk(payload, response.status, "The GLM reset-card API returned an error.", true);
|
||||
|
||||
const parsed = parseGlmResetCards(payload);
|
||||
return {
|
||||
credits: parsed.cards.map(toPublicCard),
|
||||
availableCount: parsed.availableCount,
|
||||
lastFiveHourResetAt: parsed.lastFiveHourResetAt,
|
||||
lastWeekResetAt: parsed.lastWeekResetAt,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof GlmResetCardError) throw error;
|
||||
throw toSafeTransportError(
|
||||
error,
|
||||
new GlmResetCardError(500, "glm_reset_card_list_failed", "Failed to load GLM reset cards.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeRedemption(
|
||||
attempt: RedemptionAttempt,
|
||||
connection: GlmConnectionLike,
|
||||
requestId: string,
|
||||
proxyConfig: ProxyLike
|
||||
): Promise<void> {
|
||||
const providerSpecificData = getProviderSpecificData(connection);
|
||||
const apiKey = connection.apiKey as string;
|
||||
|
||||
if (!attempt.card) {
|
||||
const listed = await runWithConnectionFetch(proxyConfig, () =>
|
||||
fetchGlmResetCardList(apiKey, providerSpecificData)
|
||||
);
|
||||
assertEnvelopeOk(
|
||||
listed.payload,
|
||||
listed.response.status,
|
||||
"The GLM reset-card API returned an error.",
|
||||
true
|
||||
);
|
||||
|
||||
const { cards } = parseGlmResetCards(listed.payload);
|
||||
const card = attempt.requestedSelection
|
||||
? cards.find((entry) => entry.id === attempt.requestedSelection)
|
||||
: cards[0];
|
||||
if (!card) {
|
||||
throw new GlmResetCardError(
|
||||
409,
|
||||
attempt.requestedSelection ? "selected_card_unavailable" : "no_reset_card",
|
||||
attempt.requestedSelection
|
||||
? "The selected GLM reset card is no longer available."
|
||||
: "No GLM reset cards are available."
|
||||
);
|
||||
}
|
||||
attempt.card = card;
|
||||
}
|
||||
|
||||
// A transport rejection is ambiguous: z.ai may have committed the card before
|
||||
// the response was lost. Keep `attempt.card`, and the next call will retry the
|
||||
// exact same body/requestId without relisting.
|
||||
const redeemed = await runWithConnectionFetch(proxyConfig, () =>
|
||||
redeemGlmResetCard(apiKey, providerSpecificData, attempt.card as GlmResetCard, requestId)
|
||||
);
|
||||
assertEnvelopeOk(
|
||||
redeemed.payload,
|
||||
redeemed.response.status,
|
||||
"The GLM reset-card API rejected the redemption."
|
||||
);
|
||||
}
|
||||
|
||||
export async function consumeGlmResetCard(
|
||||
connectionId: string,
|
||||
idempotencyKey: string,
|
||||
selectionToken?: string
|
||||
): Promise<GlmResetCardConsumeResult> {
|
||||
if (!connectionId || typeof connectionId !== "string") {
|
||||
throw new GlmResetCardError(400, "connection_id_required", "connectionId is required.");
|
||||
}
|
||||
if (!idempotencyKey || typeof idempotencyKey !== "string" || !idempotencyKey.trim()) {
|
||||
throw new GlmResetCardError(400, "idempotency_key_required", "idempotencyKey is required.");
|
||||
}
|
||||
|
||||
const requestId = idempotencyKey.trim();
|
||||
const requestedSelection = normalizeSelection(selectionToken);
|
||||
const key = attemptKey(connectionId, requestId);
|
||||
|
||||
pruneAttempts();
|
||||
const activeAttempt = redemptionAttempts.get(key);
|
||||
if (activeAttempt?.inFlight) {
|
||||
assertCompatibleAttempt(activeAttempt, requestedSelection);
|
||||
activeAttempt.expiresAt = Date.now() + ATTEMPT_TTL_MS;
|
||||
return activeAttempt.inFlight;
|
||||
}
|
||||
|
||||
try {
|
||||
const connection = await loadGlmConnection(connectionId);
|
||||
const proxyInfo = await resolveProxyForConnection(connection.id);
|
||||
|
||||
const existing = redemptionAttempts.get(key);
|
||||
if (existing) {
|
||||
assertCompatibleAttempt(existing, requestedSelection);
|
||||
existing.expiresAt = Date.now() + ATTEMPT_TTL_MS;
|
||||
if (existing.committed) return existing.committed;
|
||||
if (existing.inFlight) return existing.inFlight;
|
||||
}
|
||||
|
||||
const attempt: RedemptionAttempt = existing ?? {
|
||||
requestedSelection,
|
||||
expiresAt: Date.now() + ATTEMPT_TTL_MS,
|
||||
};
|
||||
redemptionAttempts.set(key, attempt);
|
||||
|
||||
const operation = (async () => {
|
||||
await withOperationLease(connection, () =>
|
||||
executeRedemption(attempt, connection, requestId, proxyInfo?.proxy ?? null)
|
||||
);
|
||||
return runWithConnectionFetch(proxyInfo?.proxy ?? null, () =>
|
||||
refreshAfterCommit(connection.id)
|
||||
);
|
||||
})();
|
||||
attempt.inFlight = operation;
|
||||
|
||||
try {
|
||||
const result = await operation;
|
||||
attempt.committed = result;
|
||||
attempt.expiresAt = Date.now() + ATTEMPT_TTL_MS;
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Keep ambiguous transport failures after a card has been selected. A lease
|
||||
// conflict is also a retryable local deferral, so it must not discard the
|
||||
// selected card/requestId binding from a previous ambiguous attempt.
|
||||
const retryableLeaseConflict =
|
||||
error instanceof GlmResetCardError && error.code === "exclusive_lease_active";
|
||||
if ((error instanceof GlmResetCardError && !retryableLeaseConflict) || !attempt.card) {
|
||||
redemptionAttempts.delete(key);
|
||||
} else {
|
||||
attempt.expiresAt = Date.now() + ATTEMPT_TTL_MS;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
attempt.inFlight = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof GlmResetCardError) throw error;
|
||||
throw toSafeTransportError(
|
||||
error,
|
||||
new GlmResetCardError(500, "glm_reset_card_failed", "Failed to redeem GLM reset card.")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { sanitizeProviderBillingStatus } from "@/shared/utils/providerBilling";
|
||||
import { GROK_BUILD_ADDITIONAL_CREDITS_URL } from "@/shared/utils/grokBilling";
|
||||
|
||||
const GROK_CLI_PROVIDER = "grok-cli";
|
||||
const GLM_RESET_CARD_PROVIDERS = new Set(["glm", "glm-cn", "glmt", "zai"]);
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -43,6 +44,18 @@ export function mergeProviderLimitsCacheEntry(
|
||||
return previous;
|
||||
}
|
||||
|
||||
// A GLM quota refresh omits `bankedResetCredits` when the auxiliary reset-card
|
||||
// list request failed (fetchGlmResetCardCount → null). That "unknown" must not
|
||||
// erase a previously known count; an explicit number (including an
|
||||
// authoritative 0) always wins.
|
||||
if (
|
||||
GLM_RESET_CARD_PROVIDERS.has(provider) &&
|
||||
next.bankedResetCredits === undefined &&
|
||||
previous.bankedResetCredits !== undefined
|
||||
) {
|
||||
return { ...next, bankedResetCredits: previous.bankedResetCredits };
|
||||
}
|
||||
|
||||
if (provider !== GROK_CLI_PROVIDER) return next;
|
||||
|
||||
const nextBilling = next.billing;
|
||||
|
||||
52
tests/unit/glm-reset-card-cache.test.ts
Normal file
52
tests/unit/glm-reset-card-cache.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { mergeProviderLimitsCacheEntry, toProviderLimitsCacheEntry } =
|
||||
await import("../../src/lib/usage/providerLimitsCache.ts");
|
||||
|
||||
const fetchedAt = "2026-09-05T00:00:00.000Z";
|
||||
const previous = {
|
||||
quotas: { session: { remainingPercentage: 20 } },
|
||||
plan: "pro",
|
||||
message: null,
|
||||
fetchedAt,
|
||||
source: "sync",
|
||||
bankedResetCredits: 3,
|
||||
};
|
||||
|
||||
test("GLM cache preserves a known reset-card count only when the auxiliary count is unknown", () => {
|
||||
for (const provider of ["glm", "glm-cn", "glmt", "zai"]) {
|
||||
const unknownCount = toProviderLimitsCacheEntry(
|
||||
{ quotas: { session: { remainingPercentage: 80 } }, plan: "pro" },
|
||||
"manual",
|
||||
fetchedAt
|
||||
);
|
||||
const merged = mergeProviderLimitsCacheEntry(provider, unknownCount, previous);
|
||||
assert.equal(merged.bankedResetCredits, 3, `${provider} should preserve a known count`);
|
||||
assert.deepEqual(merged.quotas, { session: { remainingPercentage: 80 } });
|
||||
|
||||
const authoritativeZero = toProviderLimitsCacheEntry(
|
||||
{
|
||||
quotas: { session: { remainingPercentage: 90 } },
|
||||
plan: "pro",
|
||||
bankedResetCredits: 0,
|
||||
},
|
||||
"manual",
|
||||
fetchedAt
|
||||
);
|
||||
const cleared = mergeProviderLimitsCacheEntry(provider, authoritativeZero, previous);
|
||||
assert.equal(cleared.bankedResetCredits, 0, `${provider} should accept authoritative zero`);
|
||||
}
|
||||
});
|
||||
|
||||
test("non-GLM providers do not inherit GLM reset-card cache semantics", () => {
|
||||
const next = toProviderLimitsCacheEntry(
|
||||
{ quotas: { session: { remainingPercentage: 80 } }, plan: "pro" },
|
||||
"manual",
|
||||
fetchedAt
|
||||
);
|
||||
assert.equal(
|
||||
mergeProviderLimitsCacheEntry("codex", next, previous).bankedResetCredits,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
242
tests/unit/glm-reset-card-route.test.ts
Normal file
242
tests/unit/glm-reset-card-route.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-glm-reset-card-route-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-glm-reset-card-route-secret";
|
||||
process.env.INITIAL_PASSWORD = "route-test-password";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const auth = await import("../../src/lib/api/requireManagementAuth.ts");
|
||||
const authHeaders = await import("../../src/server/authz/headers.ts");
|
||||
const route = await import("../../src/app/api/usage/glm-reset-card/route.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalConsoleError = console.error;
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function request(pathname: string, init: RequestInit = {}) {
|
||||
return new Request(`http://localhost:20128${pathname}`, init);
|
||||
}
|
||||
|
||||
function managementRequest(pathname: string, init: RequestInit = {}) {
|
||||
return request(pathname, {
|
||||
...init,
|
||||
headers: {
|
||||
...(init.headers ?? {}),
|
||||
[authHeaders.AUTHZ_HEADER_AUTH_KIND]: "management_key",
|
||||
[authHeaders.AUTHZ_HEADER_AUTH_LABEL]: "local-cli-token",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
console.error = originalConsoleError;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("GET and POST authorize before parsing any payload", () => {
|
||||
const source = fs.readFileSync("src/app/api/usage/glm-reset-card/route.ts", "utf8");
|
||||
const getIndex = source.indexOf("export async function GET");
|
||||
const postIndex = source.indexOf("export async function POST");
|
||||
assert.ok(getIndex >= 0 && postIndex > getIndex);
|
||||
|
||||
const getBody = source.slice(getIndex, postIndex);
|
||||
const postBody = source.slice(postIndex);
|
||||
for (const [label, body, parseMarker] of [
|
||||
["GET", getBody, "new URL(request.url)"],
|
||||
["POST", postBody, "request.json()"],
|
||||
] as const) {
|
||||
assert.match(body, /const authError = await requireManagementAuth\(request\);/);
|
||||
assert.ok(
|
||||
body.indexOf("requireManagementAuth(request)") < body.indexOf(parseMarker),
|
||||
`${label} must authorize before parsing`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("unauthenticated requests never reach the upstream connection", async () => {
|
||||
let called = false;
|
||||
globalThis.fetch = async () => {
|
||||
called = true;
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
const getResponse = await route.GET(request("/api/usage/glm-reset-card"));
|
||||
assert.equal(getResponse.status, 401);
|
||||
|
||||
const postResponse = await route.POST(
|
||||
request("/api/usage/glm-reset-card", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ connectionId: "c1", idempotencyKey: "k1" }),
|
||||
})
|
||||
);
|
||||
assert.equal(postResponse.status, 401);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("GET rejects an invalid connectionId with 400", async () => {
|
||||
const response = await route.GET(managementRequest("/api/usage/glm-reset-card"));
|
||||
assert.equal(response.status, 400);
|
||||
const body = (await response.json()) as { ok: boolean; code: string };
|
||||
assert.equal(body.ok, false);
|
||||
assert.equal(body.code, "invalid_connection_id");
|
||||
});
|
||||
|
||||
test("POST rejects a malformed body with 400 and no upstream call", async () => {
|
||||
let called = false;
|
||||
globalThis.fetch = async () => {
|
||||
called = true;
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
for (const payload of [
|
||||
{},
|
||||
{ connectionId: "" },
|
||||
{ idempotencyKey: "k1" },
|
||||
{ connectionId: "c1" },
|
||||
{ connectionId: "c1", idempotencyKey: 5 },
|
||||
]) {
|
||||
const response = await route.POST(
|
||||
managementRequest("/api/usage/glm-reset-card", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
const body = (await response.json()) as { ok: boolean; code: string };
|
||||
assert.equal(body.code, "invalid_request_body");
|
||||
}
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test("GET maps a z.ai auth failure to a typed 401 without leaking upstream detail", async () => {
|
||||
const connection = (await providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: `GLM Route 401 ${Date.now()}`,
|
||||
apiKey: "glm-route-key",
|
||||
})) as { id: string };
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
json({ code: 1001, msg: "Authentication parameter not received in Header" });
|
||||
|
||||
const errors: unknown[] = [];
|
||||
console.error = (...args: unknown[]) => errors.push(args);
|
||||
|
||||
const response = await route.GET(
|
||||
managementRequest(`/api/usage/glm-reset-card?connectionId=${connection.id}`)
|
||||
);
|
||||
|
||||
console.error = originalConsoleError;
|
||||
assert.equal(response.status, 401);
|
||||
const body = (await response.json()) as { ok: boolean; code: string; error: string };
|
||||
assert.equal(body.ok, false);
|
||||
assert.ok(!body.error.includes("at /"), "no stack trace may leak into responses");
|
||||
assert.ok(!body.error.toLowerCase().includes("bearer"), "no credential detail may leak");
|
||||
});
|
||||
|
||||
test("GET maps a malformed upstream envelope to a sanitized 502", async () => {
|
||||
const connection = (await providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: `GLM Route 502 ${Date.now()}`,
|
||||
apiKey: "glm-route-key",
|
||||
})) as { id: string };
|
||||
|
||||
globalThis.fetch = async () => new Response("<html>gateway</html>", { status: 200 });
|
||||
|
||||
console.error = () => {};
|
||||
const response = await route.GET(
|
||||
managementRequest(`/api/usage/glm-reset-card?connectionId=${connection.id}`)
|
||||
);
|
||||
console.error = originalConsoleError;
|
||||
|
||||
assert.equal(response.status, 502);
|
||||
const body = (await response.json()) as { ok: boolean; error: string };
|
||||
assert.ok(!body.error.includes("<html>"), "raw upstream payload may not leak");
|
||||
});
|
||||
|
||||
test("POST returns the committed redemption shape", async () => {
|
||||
const connection = (await providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: `GLM Route OK ${Date.now()}`,
|
||||
apiKey: "glm-route-key",
|
||||
})) as { id: string };
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
return json({
|
||||
code: 200,
|
||||
success: true,
|
||||
data: {
|
||||
fiveHourResets: [],
|
||||
weekResets: [{ recordId: 124140, expireTime: "2099-01-01 00:00:00" }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
return json({ code: 200, msg: "Operation successful", data: 124140, success: true });
|
||||
}
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
return json({ code: 200, success: true, data: { limits: [] } });
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
const response = await route.POST(
|
||||
managementRequest("/api/usage/glm-reset-card", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ connectionId: connection.id, idempotencyKey: "route-key-1" }),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const body = (await response.json()) as { ok: boolean; outcome: string };
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.outcome, "reset");
|
||||
});
|
||||
|
||||
test("error logging stays bounded and sanitized", async () => {
|
||||
const source = fs.readFileSync("src/app/api/usage/glm-reset-card/route.ts", "utf8");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/console\.error\([^\n]*error\)/,
|
||||
"raw error objects (with stacks) must not be logged"
|
||||
);
|
||||
});
|
||||
|
||||
test("auth helper refuses a wrong management password", async () => {
|
||||
const response = await route.GET(
|
||||
request("/api/usage/glm-reset-card?connectionId=c1", {
|
||||
headers: { "x-omniroute-admin": "wrong-password" },
|
||||
})
|
||||
);
|
||||
assert.equal(response.status, 401);
|
||||
assert.ok(auth.requireManagementAuth);
|
||||
});
|
||||
712
tests/unit/glm-reset-cards.test.ts
Normal file
712
tests/unit/glm-reset-cards.test.ts
Normal file
@@ -0,0 +1,712 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-glm-reset-cards-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-glm-reset-cards-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const leasesDb = await import("../../src/lib/db/exclusiveConnectionLeases.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const glmResetCards = await import("../../src/lib/usage/glmResetCards.ts");
|
||||
const wire = await import("../../open-sse/services/usage/glmResetCards.ts");
|
||||
const glmProvider = await import("../../open-sse/config/glmProvider.ts");
|
||||
const proxyFetch = await import("../../open-sse/utils/proxyFetch.ts");
|
||||
const uiUtils =
|
||||
await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
/** The exact envelope z.ai returns for an account with no cards banked. */
|
||||
const EMPTY_LIST_ENVELOPE = {
|
||||
code: 200,
|
||||
msg: "Operation successful",
|
||||
data: {
|
||||
customerId: 75751781508272646,
|
||||
targetType: "PERSONAL",
|
||||
organizationId: null,
|
||||
projectId: null,
|
||||
lastFiveHourResetTime: null,
|
||||
lastWeekResetTime: "2026-09-04 18:39:23",
|
||||
fiveHourResets: [],
|
||||
weekResets: [],
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
|
||||
function listEnvelopeWith(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
...EMPTY_LIST_ENVELOPE,
|
||||
data: { ...EMPTY_LIST_ENVELOPE.data, ...overrides },
|
||||
};
|
||||
}
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function createGlmConnection(overrides: Record<string, unknown> = {}) {
|
||||
return providersDb.createProviderConnection({
|
||||
provider: "glm",
|
||||
authType: "apikey",
|
||||
name: `GLM Reset ${Date.now()} ${Math.random()}`,
|
||||
apiKey: "glm-test-key",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
});
|
||||
|
||||
test("parseGlmResetCards reports no cards for an account with both buckets empty", () => {
|
||||
const parsed = wire.parseGlmResetCards(EMPTY_LIST_ENVELOPE);
|
||||
assert.equal(parsed.availableCount, 0);
|
||||
assert.deepEqual(parsed.cards, []);
|
||||
assert.equal(parsed.lastWeekResetAt, "2026-09-04 18:39:23");
|
||||
assert.equal(parsed.lastFiveHourResetAt, null);
|
||||
});
|
||||
|
||||
test("parseGlmResetCards derives the reset window from the bucket it came from", () => {
|
||||
const parsed = wire.parseGlmResetCards(
|
||||
listEnvelopeWith({
|
||||
fiveHourResets: [{ recordId: 111 }],
|
||||
weekResets: [{ recordId: 124128 }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(parsed.availableCount, 2);
|
||||
assert.deepEqual(
|
||||
parsed.cards.map((card) => [card.id, card.resetType]),
|
||||
[
|
||||
["111", "FIVE_HOUR"],
|
||||
["124128", "WEEK"],
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("parseGlmResetCards prefers an explicit resetType and skips entries without an id", () => {
|
||||
const parsed = wire.parseGlmResetCards(
|
||||
listEnvelopeWith({
|
||||
fiveHourResets: [{ recordId: 222, resetType: "WEEK" }, { packageName: "no id here" }],
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(parsed.availableCount, 1);
|
||||
assert.equal(parsed.cards[0].resetType, "WEEK");
|
||||
});
|
||||
|
||||
test("z.ai envelopes fail closed despite an HTTP 200 status line", () => {
|
||||
// Observed live: no auth header → code 1001, bad Bearer token → code 401.
|
||||
const missingAuth = { code: 1001, msg: "Authentication parameter not received in Header" };
|
||||
const badToken = { code: 401, msg: "token expired or incorrect", success: false };
|
||||
|
||||
for (const malformed of [
|
||||
null,
|
||||
"<html>upstream error</html>",
|
||||
{},
|
||||
{ code: 200 },
|
||||
{ code: 200, success: "true" },
|
||||
{ code: "200", success: true },
|
||||
{ code: 201, success: true },
|
||||
]) {
|
||||
assert.equal(wire.isGlmResetCardEnvelopeOk(malformed), false);
|
||||
}
|
||||
assert.equal(wire.isGlmResetCardEnvelopeOk(missingAuth), false);
|
||||
assert.equal(wire.isGlmResetCardEnvelopeOk(badToken), false);
|
||||
assert.equal(wire.isGlmResetCardEnvelopeOk(EMPTY_LIST_ENVELOPE), true);
|
||||
assert.equal(wire.isGlmResetCardEnvelopeOk({ code: 0, success: true }), true);
|
||||
assert.equal(wire.isGlmResetCardListEnvelopeOk(EMPTY_LIST_ENVELOPE), true);
|
||||
assert.equal(wire.isGlmResetCardListEnvelopeOk({ code: 200, success: true }), false);
|
||||
assert.equal(wire.isGlmResetCardListEnvelopeOk({ code: 200, success: true, data: null }), false);
|
||||
assert.equal(wire.isGlmResetCardListEnvelopeOk({ code: 200, success: true, data: {} }), false);
|
||||
assert.equal(
|
||||
wire.isGlmResetCardListEnvelopeOk(listEnvelopeWith({ fiveHourResets: undefined })),
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(wire.getGlmResetCardEnvelopeStatus(missingAuth, 200), 401);
|
||||
assert.equal(wire.getGlmResetCardEnvelopeStatus(badToken, 200), 401);
|
||||
assert.equal(wire.getGlmResetCardEnvelopeMessage(badToken), "token expired or incorrect");
|
||||
});
|
||||
|
||||
test("parseGlmResetCards filters unavailable cards and orders usable cards by expiry", () => {
|
||||
const parsed = wire.parseGlmResetCards(
|
||||
listEnvelopeWith({
|
||||
fiveHourResets: [
|
||||
{ recordId: 1, status: "consumed", expireTime: "2099-01-01 00:00:00" },
|
||||
{ recordId: 2, available: false, expireTime: "2099-01-01 00:00:00" },
|
||||
{ recordId: 3, consumed: true, expireTime: "2099-01-01 00:00:00" },
|
||||
{ recordId: 4, expireTime: "2000-01-01 00:00:00" },
|
||||
{ recordId: 5, expireTime: "2099-03-01 00:00:00" },
|
||||
{ recordId: 6, expireTime: "not-a-date" },
|
||||
],
|
||||
weekResets: [
|
||||
{ recordId: 7, status: "redeeming", expireTime: "2099-01-01 00:00:00" },
|
||||
{ recordId: 8, redeemed: true, expireTime: "2099-01-01 00:00:00" },
|
||||
{ recordId: 9, expireTime: "2099-02-01T00:00:00Z" },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(parsed.availableCount, 3);
|
||||
assert.deepEqual(
|
||||
parsed.cards.map((card) => card.id),
|
||||
["9", "5", "6"]
|
||||
);
|
||||
});
|
||||
|
||||
test("buildGlmResetCardFetch targets the right host, path and headers per region", () => {
|
||||
const list = glmProvider.buildGlmResetCardFetch("key-1", undefined, "list");
|
||||
assert.equal(
|
||||
list.url,
|
||||
"https://api.z.ai/api/biz/customer-package-reset/list?targetType=PERSONAL"
|
||||
);
|
||||
assert.equal(list.headers.Authorization, "Bearer key-1");
|
||||
assert.equal(list.headers["Content-Type"], undefined);
|
||||
|
||||
const use = glmProvider.buildGlmResetCardFetch("key-1", { apiRegion: "china" }, "use");
|
||||
assert.equal(use.url, "https://open.bigmodel.cn/api/biz/customer-package-reset/use");
|
||||
assert.equal(use.headers["Content-Type"], "application/json");
|
||||
|
||||
const team = glmProvider.buildGlmResetCardFetch(
|
||||
"key-1",
|
||||
{ glmOrganizationId: "org-1", glmProjectId: "proj-1" },
|
||||
"list"
|
||||
);
|
||||
assert.equal(team.headers["bigmodel-organization"], "org-1");
|
||||
assert.equal(team.headers["bigmodel-project"], "proj-1");
|
||||
});
|
||||
|
||||
test("consumeGlmResetCard redeems the listed card with z.ai's wire body, then refreshes usage", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const href = String(url);
|
||||
calls.push({ url: href, init });
|
||||
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
assert.equal((init.headers as Record<string, string>).Authorization, "Bearer glm-test-key");
|
||||
return json(listEnvelopeWith({ weekResets: [{ recordId: 124128 }] }));
|
||||
}
|
||||
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
assert.deepEqual(JSON.parse(String(init.body)), {
|
||||
targetType: "PERSONAL",
|
||||
resetType: "WEEK",
|
||||
recordId: 124128,
|
||||
requestId: "redeem-1",
|
||||
});
|
||||
return json({ code: 200, msg: "Operation successful", data: 124128, success: true });
|
||||
}
|
||||
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
return json({
|
||||
code: 200,
|
||||
success: true,
|
||||
data: { limits: [{ type: "CREDIT_LIMIT", unit: 3, number: 5, percentage: 0 }] },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
const result = await glmResetCards.consumeGlmResetCard(connection.id, "redeem-1");
|
||||
|
||||
assert.equal(result.outcome, "reset");
|
||||
assert.ok(
|
||||
calls.some((call) => call.url.includes("/customer-package-reset/use")),
|
||||
"expected the redemption call to be issued"
|
||||
);
|
||||
});
|
||||
|
||||
test("GLM reset-card list and redemption run inside the assigned proxy context", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
const server = net.createServer((socket) => socket.destroy());
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
|
||||
await settingsDb.setProxyForLevel("key", connection.id, {
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: address.port,
|
||||
});
|
||||
|
||||
const sources: string[] = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
sources.push(proxyFetch.resolveProxyForRequest(String(url)).source);
|
||||
const href = String(url);
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
return json(listEnvelopeWith({ weekResets: [{ recordId: 124141 }] }));
|
||||
}
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
return json({ code: 200, success: true, data: 124141 });
|
||||
}
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
return json({ code: 200, success: true, data: { limits: [] } });
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
try {
|
||||
const listed = await glmResetCards.listGlmResetCards(connection.id);
|
||||
assert.equal(listed.availableCount, 1);
|
||||
await glmResetCards.consumeGlmResetCard(connection.id, "redeem-proxied", "124141");
|
||||
assert.ok(sources.length >= 3);
|
||||
assert.ok(sources.every((source) => source === "context"));
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("an unreachable assigned proxy blocks GLM reset-card requests before direct fallback", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address === "object");
|
||||
const deadPort = address.port;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
|
||||
await settingsDb.setProxyForLevel("key", connection.id, {
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: deadPort,
|
||||
});
|
||||
|
||||
const sources: string[] = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
sources.push(proxyFetch.resolveProxyForRequest(String(url)).source);
|
||||
return new Promise<Response>(() => {});
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => glmResetCards.listGlmResetCards(connection.id),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof glmResetCards.GlmResetCardError);
|
||||
assert.equal(error.status, 503);
|
||||
assert.equal(error.code, "proxy_unreachable");
|
||||
assert.equal(error.message, "The connection proxy is unreachable.");
|
||||
assert.doesNotMatch(error.message, /127\.0\.0\.1|:\d{2,5}/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.deepEqual(sources, ["context"], "an unreachable proxy must never retry directly");
|
||||
});
|
||||
|
||||
test("GLM reset-card requests remain direct when no proxy is configured", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
let source: string | null = null;
|
||||
globalThis.fetch = async (url) => {
|
||||
source = proxyFetch.resolveProxyForRequest(String(url)).source;
|
||||
return json(EMPTY_LIST_ENVELOPE);
|
||||
};
|
||||
|
||||
await glmResetCards.listGlmResetCards(connection.id);
|
||||
assert.equal(source, "direct");
|
||||
});
|
||||
|
||||
test("an unproxied GLM connection does not inherit an ambient proxy context", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
let source: string | null = null;
|
||||
globalThis.fetch = async (url) => {
|
||||
source = proxyFetch.resolveProxyForRequest(String(url)).source;
|
||||
return json(EMPTY_LIST_ENVELOPE);
|
||||
};
|
||||
|
||||
await proxyFetch.runWithProxyContext({ type: "vercel", host: "ambient-proxy.invalid" }, () =>
|
||||
glmResetCards.listGlmResetCards(connection.id)
|
||||
);
|
||||
|
||||
assert.equal(source, "direct");
|
||||
});
|
||||
|
||||
test("consumeGlmResetCard atomically fences the connection while the wire operation is active", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
let releaseList!: () => void;
|
||||
let listStarted!: () => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
listStarted = resolve;
|
||||
});
|
||||
const blockedList = new Promise<void>((resolve) => {
|
||||
releaseList = resolve;
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
listStarted();
|
||||
await blockedList;
|
||||
return json(listEnvelopeWith({ weekResets: [{ recordId: 124142 }] }));
|
||||
}
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
return json({ code: 200, success: true, data: 124142 });
|
||||
}
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
return json({ code: 200, success: true, data: { limits: [] } });
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
const redemption = glmResetCards.consumeGlmResetCard(connection.id, "redeem-fenced");
|
||||
await started;
|
||||
|
||||
const competingLease = leasesDb.acquireExclusiveConnectionLease({
|
||||
leaseOwnerId: `vlo_${"a".repeat(43)}`,
|
||||
apiKeyId: "test-competing-key",
|
||||
provider: "glm",
|
||||
connectionId: connection.id,
|
||||
});
|
||||
assert.equal(competingLease.kind, "CONNECTION_BUSY");
|
||||
|
||||
releaseList();
|
||||
assert.equal((await redemption).outcome, "reset");
|
||||
});
|
||||
|
||||
test("consumeGlmResetCard releases its operation lease after success and failure", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
let failList = false;
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
if (failList) throw new Error("list transport failed");
|
||||
return json(listEnvelopeWith({ weekResets: [{ recordId: 124143 }] }));
|
||||
}
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
return json({ code: 200, success: true, data: 124143 });
|
||||
}
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
return json({ code: 200, success: true, data: { limits: [] } });
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
await glmResetCards.consumeGlmResetCard(connection.id, "redeem-release-success");
|
||||
failList = true;
|
||||
await assert.rejects(() => glmResetCards.listGlmResetCards(connection.id));
|
||||
|
||||
const competingLease = leasesDb.acquireExclusiveConnectionLease({
|
||||
leaseOwnerId: `vlo_${"b".repeat(43)}`,
|
||||
apiKeyId: "test-after-operation",
|
||||
provider: "glm",
|
||||
connectionId: connection.id,
|
||||
});
|
||||
assert.equal(competingLease.kind, "ACQUIRED");
|
||||
if (competingLease.kind === "ACQUIRED") {
|
||||
leasesDb.releaseExclusiveConnectionLease({
|
||||
leaseOwnerId: `vlo_${"b".repeat(43)}`,
|
||||
generation: competingLease.lease.generation,
|
||||
apiKeyId: "test-after-operation",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("consumeGlmResetCard reports committed success when usage refresh fails", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
let uses = 0;
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
return json(listEnvelopeWith({ weekResets: [{ recordId: 124129 }] }));
|
||||
}
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
uses += 1;
|
||||
return json({ code: 200, msg: "Operation successful", data: 124129, success: true });
|
||||
}
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
throw new Error("quota refresh unavailable");
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
const result = await glmResetCards.consumeGlmResetCard(connection.id, "redeem-refresh-fails");
|
||||
assert.equal(result.outcome, "reset");
|
||||
assert.equal(result.refreshPending, true);
|
||||
assert.equal(uses, 1);
|
||||
});
|
||||
|
||||
test("consumeGlmResetCard coalesces concurrent requests with one idempotency key", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
let lists = 0;
|
||||
let uses = 0;
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
lists += 1;
|
||||
return json(listEnvelopeWith({ weekResets: [{ recordId: 124130 }] }));
|
||||
}
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
uses += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return json({ code: 200, msg: "Operation successful", data: 124130, success: true });
|
||||
}
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
return json({ code: 200, success: true, data: { limits: [] } });
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
glmResetCards.consumeGlmResetCard(connection.id, "redeem-concurrent"),
|
||||
glmResetCards.consumeGlmResetCard(connection.id, "redeem-concurrent"),
|
||||
]);
|
||||
assert.deepEqual(second, first);
|
||||
assert.equal(lists, 1);
|
||||
assert.equal(uses, 1);
|
||||
});
|
||||
|
||||
test("consumeGlmResetCard coalesces a duplicate that arrives after lease acquisition", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
let releaseList!: () => void;
|
||||
let listStarted!: () => void;
|
||||
let lists = 0;
|
||||
let uses = 0;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
listStarted = resolve;
|
||||
});
|
||||
const blockedList = new Promise<void>((resolve) => {
|
||||
releaseList = resolve;
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
lists += 1;
|
||||
listStarted();
|
||||
await blockedList;
|
||||
return json(listEnvelopeWith({ weekResets: [{ recordId: 124144 }] }));
|
||||
}
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
uses += 1;
|
||||
return json({ code: 200, success: true, data: 124144 });
|
||||
}
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
return json({ code: 200, success: true, data: { limits: [] } });
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
const first = glmResetCards.consumeGlmResetCard(connection.id, "redeem-late-duplicate");
|
||||
await started;
|
||||
const second = glmResetCards.consumeGlmResetCard(connection.id, "redeem-late-duplicate");
|
||||
releaseList();
|
||||
|
||||
assert.deepEqual(await second, await first);
|
||||
assert.equal(lists, 1);
|
||||
assert.equal(uses, 1);
|
||||
});
|
||||
|
||||
test("an unproxied redemption ignores an ambient proxy context", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
const sources: string[] = [];
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
sources.push(proxyFetch.resolveProxyForRequest(String(url)).source);
|
||||
const href = String(url);
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
return json(listEnvelopeWith({ weekResets: [{ recordId: 124145 }] }));
|
||||
}
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
return json({ code: 200, success: true, data: 124145 });
|
||||
}
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
return json({ code: 200, success: true, data: { limits: [] } });
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
await proxyFetch.runWithProxyContext({ type: "vercel", host: "ambient-proxy.invalid" }, () =>
|
||||
glmResetCards.consumeGlmResetCard(connection.id, "redeem-direct-context")
|
||||
);
|
||||
|
||||
assert.ok(sources.length >= 3);
|
||||
assert.ok(sources.every((source) => source === "direct"));
|
||||
});
|
||||
|
||||
test("consumeGlmResetCard retries an ambiguous use with the same card and request id", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
const bodies: unknown[] = [];
|
||||
let lists = 0;
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
lists += 1;
|
||||
return json(listEnvelopeWith({ weekResets: [{ recordId: 124131 }] }));
|
||||
}
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
bodies.push(JSON.parse(String(init.body)));
|
||||
if (bodies.length === 1) throw new Error("response lost after send");
|
||||
return json({ code: 200, msg: "Operation successful", data: 124131, success: true });
|
||||
}
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
return json({ code: 200, success: true, data: { limits: [] } });
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
await assert.rejects(() =>
|
||||
glmResetCards.consumeGlmResetCard(connection.id, "redeem-ambiguous", "124131")
|
||||
);
|
||||
const result = await glmResetCards.consumeGlmResetCard(
|
||||
connection.id,
|
||||
"redeem-ambiguous",
|
||||
"124131"
|
||||
);
|
||||
|
||||
assert.equal(result.outcome, "reset");
|
||||
assert.equal(lists, 1, "retry must not relist a card that may already be consumed");
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.deepEqual(bodies[1], bodies[0]);
|
||||
});
|
||||
|
||||
test("consumeGlmResetCard rejects reuse of an idempotency key for another card", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const href = String(url);
|
||||
if (href.includes("/customer-package-reset/list")) {
|
||||
return json(
|
||||
listEnvelopeWith({
|
||||
weekResets: [{ recordId: 124132 }, { recordId: 124133 }],
|
||||
})
|
||||
);
|
||||
}
|
||||
if (href.includes("/customer-package-reset/use")) {
|
||||
return json({
|
||||
code: 200,
|
||||
msg: "Operation successful",
|
||||
data: JSON.parse(String(init.body)).recordId,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
if (href.includes("/monitor/usage/quota/limit")) {
|
||||
return json({ code: 200, success: true, data: { limits: [] } });
|
||||
}
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
await glmResetCards.consumeGlmResetCard(connection.id, "redeem-conflict", "124132");
|
||||
await assert.rejects(
|
||||
() => glmResetCards.consumeGlmResetCard(connection.id, "redeem-conflict", "124133"),
|
||||
(error: InstanceType<typeof glmResetCards.GlmResetCardError>) => {
|
||||
assert.equal(error.status, 409);
|
||||
assert.equal(error.code, "idempotency_key_conflict");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("consumeGlmResetCard reports a 409 when nothing is banked", async () => {
|
||||
const connection = (await createGlmConnection()) as { id: string };
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).includes("/customer-package-reset/list")) return json(EMPTY_LIST_ENVELOPE);
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => glmResetCards.consumeGlmResetCard(connection.id, "redeem-2"),
|
||||
(error: InstanceType<typeof glmResetCards.GlmResetCardError>) => {
|
||||
assert.equal(error.status, 409);
|
||||
assert.equal(error.code, "no_reset_card");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("non-GLM connections are rejected before any upstream call", async () => {
|
||||
const connection = (await createGlmConnection({
|
||||
provider: "openai",
|
||||
apiKey: "sk-openai",
|
||||
})) as { id: string };
|
||||
|
||||
let called = false;
|
||||
globalThis.fetch = async () => {
|
||||
called = true;
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => glmResetCards.listGlmResetCards(connection.id),
|
||||
(error: InstanceType<typeof glmResetCards.GlmResetCardError>) => {
|
||||
assert.equal(error.status, 400);
|
||||
assert.equal(error.code, "glm_provider_required");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.equal(called, false, "no upstream request should be made for a non-GLM provider");
|
||||
});
|
||||
|
||||
test("fetchGlmResetCardCount distinguishes an authoritative zero from unknown", async () => {
|
||||
globalThis.fetch = async () => json(EMPTY_LIST_ENVELOPE);
|
||||
assert.equal(await wire.fetchGlmResetCardCount("glm-test-key"), 0);
|
||||
assert.equal(await wire.fetchGlmResetCardCount(""), 0);
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("network down");
|
||||
};
|
||||
assert.equal(await wire.fetchGlmResetCardCount("glm-test-key"), null);
|
||||
|
||||
// A truncated-but-JSON body must stay "unknown", never an authoritative zero.
|
||||
globalThis.fetch = async () => json({ code: 200, success: true });
|
||||
assert.equal(await wire.fetchGlmResetCardCount("glm-test-key"), null);
|
||||
globalThis.fetch = async () => json({ code: 200, success: true, data: {} });
|
||||
assert.equal(await wire.fetchGlmResetCardCount("glm-test-key"), null);
|
||||
|
||||
globalThis.fetch = async () => json({ code: 401, msg: "token expired or incorrect" });
|
||||
assert.equal(await wire.fetchGlmResetCardCount("glm-test-key"), null);
|
||||
});
|
||||
|
||||
test("the redeem button unlocks for the GLM family, not only for Codex", () => {
|
||||
const quotas = [{ isResetCredits: true, creditCount: 1 }];
|
||||
|
||||
for (const provider of ["codex", "glm", "glm-cn", "glmt", "zai"]) {
|
||||
assert.equal(
|
||||
uiUtils.computeCanRedeemResetCredit(provider, quotas),
|
||||
true,
|
||||
`${provider} should be able to redeem`
|
||||
);
|
||||
}
|
||||
|
||||
assert.equal(uiUtils.computeCanRedeemResetCredit("openai", quotas), false);
|
||||
assert.equal(uiUtils.computeCanRedeemResetCredit("glm", [{ isResetCredits: true }]), false);
|
||||
assert.equal(uiUtils.getResetCreditEndpoint("codex"), "/api/usage/codex-reset-credit");
|
||||
for (const provider of ["glm", "glm-cn", "glmt", "zai"]) {
|
||||
assert.equal(uiUtils.getResetCreditEndpoint(provider), "/api/usage/glm-reset-card");
|
||||
}
|
||||
assert.equal(uiUtils.getResetCreditEndpoint("openai"), null);
|
||||
assert.equal(uiUtils.getResetCreditEndpoint("opencode-go"), null);
|
||||
});
|
||||
@@ -318,6 +318,112 @@ describe("getGlmUsage team quota parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGlmUsage reset-card integration", () => {
|
||||
const RESETTABLE_QUOTA_RESPONSE = {
|
||||
code: 200,
|
||||
success: true,
|
||||
data: {
|
||||
limits: [
|
||||
{ type: "TOKENS_LIMIT", unit: 3, number: 5, percentage: 10 },
|
||||
{ type: "TOKENS_LIMIT", unit: 6, number: 1, percentage: 20 },
|
||||
],
|
||||
level: "pro",
|
||||
},
|
||||
};
|
||||
|
||||
it("adds the available reset-card count for resettable coding-plan windows", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requestedUrls: string[] = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
const requestedUrl = String(url);
|
||||
requestedUrls.push(requestedUrl);
|
||||
if (requestedUrl.includes("customer-package-reset/list")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
success: true,
|
||||
data: {
|
||||
fiveHourResets: [{ recordId: 101 }],
|
||||
weekResets: [{ recordId: 202 }],
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return new Response(JSON.stringify(RESETTABLE_QUOTA_RESPONSE), { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const usage = await getGlmUsage("glm-key", { apiRegion: "international" });
|
||||
|
||||
assert.equal(usage.bankedResetCredits, 2);
|
||||
assert.equal(requestedUrls.length, 2);
|
||||
assert.match(requestedUrls[0], /api\/monitor\/usage\/quota\/limit/);
|
||||
assert.match(requestedUrls[1], /customer-package-reset\/list/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps normal quota data and omits the card count when the auxiliary list fails", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (String(url).includes("customer-package-reset/list")) {
|
||||
throw new Error("auxiliary endpoint unavailable");
|
||||
}
|
||||
return new Response(JSON.stringify(RESETTABLE_QUOTA_RESPONSE), { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const usage = await getGlmUsage("glm-key", { apiRegion: "international" });
|
||||
|
||||
assert.equal(usage.quotas.session.remainingPercentage, 90);
|
||||
assert.equal(usage.quotas.weekly.remainingPercentage, 80);
|
||||
assert.equal("bankedResetCredits" in usage, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("skips the auxiliary request and reports zero when no resettable window exists", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requestedUrls: string[] = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
requestedUrls.push(String(url));
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
success: true,
|
||||
data: {
|
||||
limits: [
|
||||
{
|
||||
type: "TIME_LIMIT",
|
||||
unit: 5,
|
||||
usage: 100,
|
||||
currentValue: 25,
|
||||
remaining: 75,
|
||||
percentage: 25,
|
||||
},
|
||||
],
|
||||
level: "pro",
|
||||
},
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const usage = await getGlmUsage("glm-key", { apiRegion: "international" });
|
||||
|
||||
assert.equal(usage.bankedResetCredits, 0);
|
||||
assert.equal(requestedUrls.length, 1);
|
||||
assert.match(requestedUrls[0], /api\/monitor\/usage\/quota\/limit/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGlmUsage CREDIT_LIMIT (coding-plan subscription keys)", () => {
|
||||
// Real-world response from https://api.z.ai/api/monitor/usage/quota/limit
|
||||
// for a GLM Coding Max subscription key (2026-08): limits use CREDIT_LIMIT
|
||||
|
||||
@@ -190,6 +190,7 @@ const EXPECTED: Record<InventoryKind, Record<string, number>> = {
|
||||
"src/lib/usage/callLogs.ts": 1,
|
||||
"src/lib/usage/codexResetCredits.ts": 1,
|
||||
"src/lib/usage/comboScoringInspector.ts": 1,
|
||||
"src/lib/usage/glmResetCards.ts": 1,
|
||||
// v3.8.51 #12805 (c042a5188): grok-cli sibling of codexResetCredits.ts, same
|
||||
// shape — isConnectionUnavailableToAuxiliaryActivity() gates the lookup, so an
|
||||
// ACTIVE exclusive lease defers redemption (409 exclusive_lease_active).
|
||||
@@ -243,6 +244,7 @@ const CLASSIFICATION: Record<InventoryKind, Record<string, BypassClass>> = {
|
||||
"src/lib/providers/volcenginePlanBinding.ts",
|
||||
"src/lib/services/quotaAutoPing.ts",
|
||||
"src/lib/usage/codexResetCredits.ts",
|
||||
"src/lib/usage/glmResetCards.ts",
|
||||
"src/lib/usage/grokResetCredits.ts",
|
||||
"src/lib/usage/providerLimits.ts",
|
||||
"src/lib/vncSession/service.ts",
|
||||
@@ -352,6 +354,7 @@ test("managed request surfaces are fenced centrally or rejected before independe
|
||||
"src/lib/api/modelTestRunner.ts",
|
||||
"src/lib/services/quotaAutoPing.ts",
|
||||
"src/lib/usage/codexResetCredits.ts",
|
||||
"src/lib/usage/glmResetCards.ts",
|
||||
"src/lib/usage/grokResetCredits.ts",
|
||||
"src/lib/vncSession/service.ts",
|
||||
"src/lib/warmupScheduler.ts",
|
||||
|
||||
@@ -7,6 +7,10 @@ const providerLimitUtils =
|
||||
await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx");
|
||||
const providerConstants = await import("../../src/shared/constants/providers.ts");
|
||||
const settingsSchemas = await import("../../src/shared/validation/settingsSchemas.ts");
|
||||
const resetCreditModal =
|
||||
await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/CodexResetCreditsModal.tsx");
|
||||
const resetCreditRedemption =
|
||||
await import("../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useCodexResetCreditRedemption.ts");
|
||||
|
||||
type ParsedQuota = {
|
||||
name?: string;
|
||||
@@ -212,6 +216,69 @@ test("Codex banked reset credits parse as an integer reset-credit counter", () =
|
||||
assert.equal(resetCredits.creditCount, 2);
|
||||
});
|
||||
|
||||
test("reset-credit modal uses provider-specific titles and confirmations", () => {
|
||||
const tr = (_key: string, fallback: string) => fallback;
|
||||
const upstreamTitle = { selectionToken: "card-1", title: "Bonus card" };
|
||||
|
||||
assert.equal(
|
||||
resetCreditModal.getResetCreditWindowTitle("codex", upstreamTitle, tr),
|
||||
"Bonus card"
|
||||
);
|
||||
assert.equal(
|
||||
resetCreditModal.getResetCreditWindowTitle("glm", upstreamTitle, tr),
|
||||
"5-hour window reset · Bonus card"
|
||||
);
|
||||
assert.equal(
|
||||
resetCreditModal.getResetCreditWindowTitle("zai", { ...upstreamTitle, resetType: "WEEK" }, tr),
|
||||
"Weekly window reset · Bonus card"
|
||||
);
|
||||
assert.match(
|
||||
resetCreditModal.getResetCreditConfirmation("codex", undefined, tr),
|
||||
/Codex usage windows/
|
||||
);
|
||||
assert.match(resetCreditModal.getResetCreditConfirmation("glm", "FIVE_HOUR", tr), /5-hour/);
|
||||
assert.match(resetCreditModal.getResetCreditConfirmation("glm-cn", "WEEK", tr), /weekly/);
|
||||
});
|
||||
|
||||
test("committed refresh fallback preserves usage windows and decrements only reset cards", () => {
|
||||
const session = { name: "session", used: 50, total: 100 };
|
||||
const entry = {
|
||||
quotas: [
|
||||
session,
|
||||
{
|
||||
name: "banked_reset_credits",
|
||||
isResetCredits: true,
|
||||
remaining: 2,
|
||||
creditCount: 2,
|
||||
},
|
||||
],
|
||||
raw: { bankedResetCredits: 2, quotas: { session } },
|
||||
plan: "pro",
|
||||
};
|
||||
|
||||
const decremented = resetCreditRedemption.applyCommittedResetCreditFallback(entry);
|
||||
assert.deepEqual(decremented.quotas, [
|
||||
session,
|
||||
{
|
||||
name: "banked_reset_credits",
|
||||
isResetCredits: true,
|
||||
remaining: 1,
|
||||
creditCount: 1,
|
||||
},
|
||||
]);
|
||||
assert.equal(decremented.raw.bankedResetCredits, 1);
|
||||
assert.deepEqual(decremented.raw.quotas, entry.raw.quotas);
|
||||
assert.equal(decremented.plan, "pro");
|
||||
|
||||
const exhausted = resetCreditRedemption.applyCommittedResetCreditFallback({
|
||||
...entry,
|
||||
quotas: [{ isResetCredits: true, remaining: 1, creditCount: 1 }],
|
||||
raw: { ...entry.raw, bankedResetCredits: 1 },
|
||||
});
|
||||
assert.deepEqual(exhausted.quotas, []);
|
||||
assert.equal(exhausted.raw.bankedResetCredits, 0);
|
||||
});
|
||||
|
||||
test("quota labels normalize session and weekly windows while preserving readable titles", () => {
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("session"), "Session");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("session (5h)"), "Session");
|
||||
@@ -268,19 +335,25 @@ test("MiniMax quota payloads use generic provider parsing and stale resets still
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel(parsed[1].name), "Weekly");
|
||||
});
|
||||
|
||||
test("GLM quota rows are ordered by session, weekly, then monthly", () => {
|
||||
const parsed = providerLimitUtils.parseQuotaData("glm", {
|
||||
quotas: {
|
||||
mcp_monthly: { used: 10, total: 100, remainingPercentage: 90 },
|
||||
weekly: { used: 20, total: 100, remainingPercentage: 80 },
|
||||
session: { used: 30, total: 100, remainingPercentage: 70 },
|
||||
},
|
||||
});
|
||||
test("GLM quota rows are ordered by session, weekly, monthly, then reset cards", () => {
|
||||
for (const provider of ["glm", "glm-cn", "glmt", "zai"]) {
|
||||
const parsed = providerLimitUtils.parseQuotaData(provider, {
|
||||
bankedResetCredits: 2,
|
||||
quotas: {
|
||||
mcp_monthly: { used: 10, total: 100, remainingPercentage: 90 },
|
||||
weekly: { used: 20, total: 100, remainingPercentage: 80 },
|
||||
session: { used: 30, total: 100, remainingPercentage: 70 },
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
parsed.map((quota) => quota.name),
|
||||
["session", "weekly", "mcp_monthly"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
parsed.map((quota) => quota.name),
|
||||
["session", "weekly", "mcp_monthly", "banked_reset_credits"],
|
||||
`${provider} should use GLM family parsing`
|
||||
);
|
||||
assert.equal(parsed[3].creditCount, 2);
|
||||
assert.equal(parsed[3].isResetCredits, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenRouter credits render as a USD credit count, not a percentage row", () => {
|
||||
@@ -411,6 +484,13 @@ test("usage namespace includes Provider Limits UI translation keys", () => {
|
||||
"confirmRedeemResetCreditButton",
|
||||
"resetCreditRedeemed",
|
||||
"resetCreditRedeemFailed",
|
||||
"glmResetCreditsModalTitle",
|
||||
"glmResetCreditsModalExplainer",
|
||||
"glmResetCreditFiveHourTitle",
|
||||
"glmResetCreditWeekTitle",
|
||||
"glmConfirmRedeemResetCredit",
|
||||
"glmConfirmRedeemFiveHourResetCredit",
|
||||
"glmConfirmRedeemWeekResetCredit",
|
||||
]) {
|
||||
assert.equal(typeof usage[key], "string", `usage.${key} should be defined in en.json`);
|
||||
assert.ok(!usage[key].startsWith("__MISSING__:"), `usage.${key} should not be a placeholder`);
|
||||
|
||||
Reference in New Issue
Block a user