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" > - +