From 241e63bfea379bf13fb319c2fa7e95d726ae8d57 Mon Sep 17 00:00:00 2001 From: Innokentiy Solntsev Date: Thu, 17 Sep 2026 23:38:09 +0200 Subject: [PATCH] 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 Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --- .../features/glm-coding-plan-reset-card.md | 1 + open-sse/config/glmProvider.ts | 35 + open-sse/services/usage/glm.ts | 27 +- open-sse/services/usage/glmResetCards.ts | 244 ++++++ .../ProviderLimits/CodexResetCreditsModal.tsx | 90 ++- .../usage/components/ProviderLimits/index.tsx | 6 +- .../ProviderLimits/providerColumns.ts | 8 +- .../components/ProviderLimits/quotaParsing.ts | 14 +- .../useCodexResetCreditRedemption.ts | 96 ++- .../usage/components/ProviderLimits/utils.tsx | 30 +- src/app/api/usage/glm-reset-card/route.ts | 78 ++ src/i18n/messages/en.json | 11 +- src/i18n/messages/pt-BR.json | 7 + src/i18n/messages/vi.json | 7 + src/lib/usage/glmResetCards.ts | 442 +++++++++++ src/lib/usage/providerLimitsCache.ts | 13 + tests/unit/glm-reset-card-cache.test.ts | 52 ++ tests/unit/glm-reset-card-route.test.ts | 242 ++++++ tests/unit/glm-reset-cards.test.ts | 712 ++++++++++++++++++ tests/unit/glm-team-quota.test.ts | 106 +++ ...ard-session-lease-bypass-inventory.test.ts | 3 + tests/unit/provider-limits-ui.test.ts | 104 ++- 22 files changed, 2266 insertions(+), 62 deletions(-) create mode 100644 changelog.d/features/glm-coding-plan-reset-card.md create mode 100644 open-sse/services/usage/glmResetCards.ts create mode 100644 src/app/api/usage/glm-reset-card/route.ts create mode 100644 src/lib/usage/glmResetCards.ts create mode 100644 tests/unit/glm-reset-card-cache.test.ts create mode 100644 tests/unit/glm-reset-card-route.test.ts create mode 100644 tests/unit/glm-reset-cards.test.ts diff --git a/changelog.d/features/glm-coding-plan-reset-card.md b/changelog.d/features/glm-coding-plan-reset-card.md new file mode 100644 index 0000000000..4e042b1d5f --- /dev/null +++ b/changelog.d/features/glm-coding-plan-reset-card.md @@ -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. diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index 22d05864e7..f77d08760c 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -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 } { + const base = GLM_RESET_CARD_URLS[getGlmApiRegion(providerSpecificData)]; + const url = action === "list" ? `${base}/list?targetType=PERSONAL` : `${base}/use`; + + const headers: Record = { + 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; diff --git a/open-sse/services/usage/glm.ts b/open-sse/services/usage/glm.ts index 489e16efde..836bf612dd 100644 --- a/open-sse/services/usage/glm.ts +++ b/open-sse/services/usage/glm.ts @@ -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; @@ -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): boolean { + return Boolean(quotas.session || quotas.weekly); +} + export async function getGlmUsage(apiKey: string, providerSpecificData?: Record) { if (!apiKey) { return { message: "API key not available. Add a coding plan API key to view usage." }; @@ -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 } : {}), + }; } diff --git a/open-sse/services/usage/glmResetCards.ts b/open-sse/services/usage/glmResetCards.ts new file mode 100644 index 0000000000..cbd01de45c --- /dev/null +++ b/open-sse/services/usage/glmResetCards.ts @@ -0,0 +1,244 @@ +import { buildGlmResetCardFetch, type GlmResetCardAction } from "../../config/glmProvider.ts"; +import { toNumber, toRecord } from "./scalars.ts"; + +type JsonRecord = Record; + +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 { + 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; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/CodexResetCreditsModal.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/CodexResetCreditsModal.tsx index 5817c6fa01..2d7eacab71 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/CodexResetCreditsModal.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/CodexResetCreditsModal.tsx @@ -13,10 +13,60 @@ interface Props { availableCount: number; isOpen: boolean; loading: boolean; + provider: string; onClose: () => void; onRedeem: (selectionToken: string) => Promise; } +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?")}

- {tr( - "confirmRedeemResetCredit", - "Redeeming immediately resets the eligible Codex usage windows and permanently consumes this credit." - )} + {getResetCreditConfirmation(provider, credit.resetType, tr)}

- + ); } @@ -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" > - +