diff --git a/changelog.d/fixes/grok-cli-reset-credits-readonly.md b/changelog.d/fixes/grok-cli-reset-credits-readonly.md new file mode 100644 index 0000000000..7bde01736e --- /dev/null +++ b/changelog.d/fixes/grok-cli-reset-credits-readonly.md @@ -0,0 +1 @@ +- **feat(grok-cli):** Provider Limits shows grok-cli banked reset credits from `GetRemainingResets` (including a real zero; a failed RPC omits the row) and the existing View credits button now calls `ConsumerUiSvc/RedeemReset` for grok-cli. Live tokens use nested fields 10/20/30 (id + Timestamp), not compact 1/2/3. diff --git a/open-sse/services/grokResetCredits.ts b/open-sse/services/grokResetCredits.ts new file mode 100644 index 0000000000..29c765ca4b --- /dev/null +++ b/open-sse/services/grokResetCredits.ts @@ -0,0 +1,214 @@ +/** + * grokResetCredits.ts — live read / redeem of Grok reset cards. + * + * POST grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets with the + * grok-cli OAuth bearer. Decode failure / HTTP miss returns null so callers + * omit bankedResetCredits rather than faking a zero. A decoded empty DATA + * frame with grpc-status 0 is a real zero and must be returned as count 0. + * + * Redeem: POST .../RedeemReset with ConsumerRedeemResetReq.token_id as + * protobuf field 10 (live X500 2026-09-05: fake field-10 → grpc-status 9 + * "does not exist"; field 1 / empty → grpc-status 3 "Invalid token_id"). + * Token ids stay server-side and are never logged. + */ +import { + decodeGrokGrpcWebRpc, + decodeGrokResetCreditsFrame, + encodeGrpcWebRequest, + encodeRedeemResetRequest, + type GrokResetCreditToken, + type GrokResetCreditsSnapshot, +} from "./grokResetCreditsFrame.ts"; + +const GROK_RESET_CREDITS_URL = + "https://grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets"; +const GROK_REDEEM_RESET_URL = "https://grok.com/prod_mc_billing.ConsumerUiSvc/RedeemReset"; +const GRPC_WEB_EMPTY_REQUEST_FRAME = Buffer.from([0, 0, 0, 0, 0]); +const FETCH_TIMEOUT_MS = 8_000; +const REDEEM_TIMEOUT_MS = 15_000; + +export type GrokResetCreditOutcome = "reset" | "alreadyRedeemed"; +export type GrokRedeemMappedStatus = GrokResetCreditOutcome | "noCredit"; + +export class GrokResetCreditError extends Error { + status: number; + code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = "GrokResetCreditError"; + this.status = status; + this.code = code; + } +} + +export type PublicGrokResetCredit = { + selectionToken: string; + expiresAt: string | null; +}; + +function grokRpcHeaders(accessToken: string): Record { + return { + Authorization: ["Bearer", accessToken].join(" "), + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + }; +} + +function expirySortValue(expiresAt: string | null): number { + if (!expiresAt) return Number.POSITIVE_INFINITY; + const ms = Date.parse(expiresAt); + return Number.isFinite(ms) ? ms : Number.POSITIVE_INFINITY; +} + +function toPublicCredits(tokens: GrokResetCreditToken[]): PublicGrokResetCredit[] { + return tokens + .map((token, index) => ({ token, index })) + .sort( + (left, right) => + expirySortValue(left.token.expiresAt) - expirySortValue(right.token.expiresAt) || + left.index - right.index + ) + .map(({ token }) => ({ + selectionToken: token.tokenId, + expiresAt: token.expiresAt, + })); +} + +async function postGrokRpc( + url: string, + accessToken: string, + payload: Buffer, + fetchImpl: typeof fetch, + timeoutMs: number +): Promise<{ grpcStatus: string; grpcMessage: string | null }> { + const response = await fetchImpl(url, { + method: "POST", + headers: grokRpcHeaders(accessToken), + body: new Uint8Array(encodeGrpcWebRequest(payload)), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + throw new GrokResetCreditError( + response.status, + "grok_reset_credit_upstream_error", + `Grok reset-credit API returned HTTP ${response.status}.` + ); + } + const buffer = Buffer.from(await response.arrayBuffer()); + return decodeGrokGrpcWebRpc( + buffer, + response.headers.get("grpc-status"), + response.headers.get("grpc-message") + ); +} + +export function mapGrokRedeemGrpcStatus( + grpcStatus: string, + grpcMessage: string | null +): GrokRedeemMappedStatus { + if (grpcStatus === "0") return "reset"; + const message = (grpcMessage ?? "").toLowerCase(); + if (grpcStatus === "9") { + return message.includes("already") ? "alreadyRedeemed" : "noCredit"; + } + if (grpcStatus === "3" && message.includes("token_id")) { + return "noCredit"; + } + throw new GrokResetCreditError( + 502, + "unknown_reset_credit_response", + grpcMessage ? `Grok reset failed: ${grpcMessage}` : `Grok reset failed (grpc-status ${grpcStatus})` + ); +} + +function throwMappedRedeemStatus(outcome: GrokRedeemMappedStatus): GrokResetCreditOutcome { + if (outcome === "noCredit") { + throw new GrokResetCreditError(409, "no_credit", "No Grok reset credits are available."); + } + return outcome; +} + +export async function fetchGrokResetCredits( + accessToken: string, + fetchImpl: typeof fetch = fetch +): Promise { + if (!accessToken) return null; + try { + const response = await fetchImpl(GROK_RESET_CREDITS_URL, { + method: "POST", + headers: grokRpcHeaders(accessToken), + body: GRPC_WEB_EMPTY_REQUEST_FRAME, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) return null; + const decoded = decodeGrokResetCreditsFrame(Buffer.from(await response.arrayBuffer())); + return decoded.ok ? decoded.snapshot : null; + } catch { + return null; + } +} + +async function loadInventory( + accessToken: string, + fetchImpl: typeof fetch +): Promise<{ credits: PublicGrokResetCredit[]; availableCount: number }> { + const response = await fetchImpl(GROK_RESET_CREDITS_URL, { + method: "POST", + headers: grokRpcHeaders(accessToken), + body: GRPC_WEB_EMPTY_REQUEST_FRAME, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new GrokResetCreditError( + response.status, + "grok_reset_credit_upstream_error", + `Grok remaining-resets returned HTTP ${response.status}.` + ); + } + const decoded = decodeGrokResetCreditsFrame(Buffer.from(await response.arrayBuffer())); + if (!decoded.ok) { + throw new GrokResetCreditError(502, "grok_reset_credit_decode_failed", "Grok remaining-resets decode failed."); + } + const credits = toPublicCredits(decoded.tokens); + return { credits, availableCount: credits.length }; +} + +export async function listGrokResetCreditTokens( + accessToken: string, + fetchImpl: typeof fetch = fetch +): Promise<{ credits: PublicGrokResetCredit[]; availableCount: number }> { + if (!accessToken) { + throw new GrokResetCreditError(401, "grok_access_token_missing", "Grok OAuth access token is missing."); + } + return loadInventory(accessToken, fetchImpl); +} + +export async function consumeGrokResetCredit( + accessToken: string, + options: { tokenId?: string } = {}, + fetchImpl: typeof fetch = fetch +): Promise { + if (!accessToken) { + throw new GrokResetCreditError(401, "grok_access_token_missing", "Grok OAuth access token is missing."); + } + + const requested = options.tokenId?.trim() ?? ""; + let selectedTokenId = requested; + if (!selectedTokenId) { + const inventory = await loadInventory(accessToken, fetchImpl); + selectedTokenId = inventory.credits[0]?.selectionToken ?? ""; + } + if (!selectedTokenId) { + throw new GrokResetCreditError(409, "no_credit", "No Grok reset credits are available."); + } + + const rpc = await postGrokRpc( + GROK_REDEEM_RESET_URL, + accessToken, + encodeRedeemResetRequest(selectedTokenId), + fetchImpl, + REDEEM_TIMEOUT_MS + ); + return throwMappedRedeemStatus(mapGrokRedeemGrpcStatus(rpc.grpcStatus, rpc.grpcMessage)); +} diff --git a/open-sse/services/grokResetCreditsFrame.ts b/open-sse/services/grokResetCreditsFrame.ts new file mode 100644 index 0000000000..3d71575a9d --- /dev/null +++ b/open-sse/services/grokResetCreditsFrame.ts @@ -0,0 +1,346 @@ +/** + * grokResetCreditsFrame.ts — gRPC-web decoder for + * `prod_mc_billing.ConsumerUiSvc/GetRemainingResets`. + * + * Live shape (X500, 2026-09-06 hotmail SuperGrokPro): empty DATA + + * grpc-status 0 = inventory 0; otherwise repeated top-level field 10, + * each a ConsumerResetToken. Nested numbers are 10 / 20 / 30, all + * length-delimited (id 13B string, granted/expires google.protobuf.Timestamp + * with seconds in field 1). Compact 1 / 2 / 3 (id bytes, granted/expires + * varint unix seconds) is still accepted. Token ids stay server-side. + * + * Do not reuse grokCliQuotaFrame.decodeFields: that Map last-wins and + * would collapse repeated field 10 to a single token. + */ +import { probeFrameHeader } from "./grokCliQuotaFrame.ts"; + +const WIRE_TYPE_VARINT = 0; +const WIRE_TYPE_FIXED64 = 1; +const WIRE_TYPE_LENGTH_DELIMITED = 2; +const WIRE_TYPE_FIXED32 = 5; +const GRPC_WEB_TRAILER_FLAG_BIT = 0x80; +const MAX_VARINT_SHIFT_BITS = 70n; + +const FIELD_RESET_TOKEN = 10; +/** Compact 1/2/3 (varint seconds) and live 10/20/30 (id + Timestamp). */ +const TOKEN_NESTED_FIELDS = { + id: [1, 10], + granted: [2, 20], + expires: [3, 30], +} as const; +const TIMESTAMP_FIELD_SECONDS = 1; +const REDEEM_REQUEST_TOKEN_ID_FIELD = 10; + +type ProtoField = + | { wireType: typeof WIRE_TYPE_VARINT; value: number } + | { + wireType: typeof WIRE_TYPE_FIXED64 | typeof WIRE_TYPE_FIXED32 | typeof WIRE_TYPE_LENGTH_DELIMITED; + bytes: Buffer; + }; + +type TaggedField = { fieldNumber: number; field: ProtoField }; + +export type GrokResetCreditsSnapshot = { + count: number; + nextExpiresAt: string | null; +}; + +export type GrokResetCreditToken = { + tokenId: string; + expiresAt: string | null; +}; + +export type GrokResetCreditsDecode = + | { ok: true; snapshot: GrokResetCreditsSnapshot; tokens: GrokResetCreditToken[] } + | { ok: false; reason: "empty-buffer" | "no-data-frame" | "malformed" | "trailer-nonzero" }; + +function encodeVarint(value: number): Buffer { + const bytes: number[] = []; + let n = Math.floor(value); + while (n > 0x7f) { + bytes.push((n & 0x7f) | 0x80); + n = Math.floor(n / 128); + } + bytes.push(n); + return Buffer.from(bytes); +} + +/** + * ConsumerRedeemResetReq.token_id is field 10 (live X500 2026-09-05: + * fake field-10 id → grpc-status 9 "does not exist"; field 1 / empty → + * grpc-status 3 "Invalid token_id"). + */ +export function encodeRedeemResetRequest(tokenId: string): Buffer { + const body = Buffer.from(tokenId, "utf8"); + return Buffer.concat([ + encodeVarint((REDEEM_REQUEST_TOKEN_ID_FIELD << 3) | WIRE_TYPE_LENGTH_DELIMITED), + encodeVarint(body.length), + body, + ]); +} + +export function encodeGrpcWebRequest(payload: Buffer): Buffer { + const header = Buffer.alloc(5); + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function readVarint(buffer: Buffer, offset: number): { value: number; next: number } | null { + let result = 0n; + let shift = 0n; + let pos = offset; + for (;;) { + if (pos >= buffer.length) return null; + const byte = buffer[pos]; + result |= BigInt(byte & 0x7f) << shift; + pos += 1; + if ((byte & 0x80) === 0) break; + shift += 7n; + if (shift > MAX_VARINT_SHIFT_BITS) return null; + } + return { value: Number(result), next: pos }; +} + +function readField(buffer: Buffer, offset: number): { tagged: TaggedField; next: number } | null { + const tagResult = readVarint(buffer, offset); + if (!tagResult) return null; + const fieldNumber = tagResult.value >>> 3; + const wireType = tagResult.value & 0x7; + if (fieldNumber === 0) return null; + + if (wireType === WIRE_TYPE_VARINT) { + const valueResult = readVarint(buffer, tagResult.next); + if (!valueResult) return null; + return { + tagged: { fieldNumber, field: { wireType: WIRE_TYPE_VARINT, value: valueResult.value } }, + next: valueResult.next, + }; + } + if (wireType === WIRE_TYPE_LENGTH_DELIMITED) { + const lengthResult = readVarint(buffer, tagResult.next); + if (!lengthResult) return null; + const { value: length, next: bodyStart } = lengthResult; + if (length < 0 || bodyStart + length > buffer.length) return null; + return { + tagged: { + fieldNumber, + field: { wireType: WIRE_TYPE_LENGTH_DELIMITED, bytes: buffer.subarray(bodyStart, bodyStart + length) }, + }, + next: bodyStart + length, + }; + } + if (wireType === WIRE_TYPE_FIXED64) { + if (tagResult.next + 8 > buffer.length) return null; + return { + tagged: { + fieldNumber, + field: { wireType: WIRE_TYPE_FIXED64, bytes: buffer.subarray(tagResult.next, tagResult.next + 8) }, + }, + next: tagResult.next + 8, + }; + } + if (wireType === WIRE_TYPE_FIXED32) { + if (tagResult.next + 4 > buffer.length) return null; + return { + tagged: { + fieldNumber, + field: { wireType: WIRE_TYPE_FIXED32, bytes: buffer.subarray(tagResult.next, tagResult.next + 4) }, + }, + next: tagResult.next + 4, + }; + } + return null; +} + +function walkFields(buffer: Buffer): TaggedField[] | null { + const fields: TaggedField[] = []; + let offset = 0; + while (offset < buffer.length) { + const result = readField(buffer, offset); + if (!result) return null; + fields.push(result.tagged); + offset = result.next; + } + return fields; +} + +function decodeTrailerMessage(raw: string | null): string | null { + if (!raw) return null; + try { + return decodeURIComponent(raw.replace(/\+/g, " ")); + } catch { + return raw; + } +} + +function parseTrailer(trailerBody: Buffer): { status: number | null; message: string | null } { + const text = trailerBody.toString("utf8"); + const statusMatch = text.match(/grpc-status:\s*(\d+)/); + const messageMatch = text.match(/grpc-message:\s*([^\r\n]+)/); + return { + status: statusMatch ? Number(statusMatch[1]) : null, + message: decodeTrailerMessage(messageMatch?.[1] ?? null), + }; +} + +export function decodeGrokGrpcWebRpc( + buffer: Buffer, + headerStatus?: string | null, + headerMessage?: string | null +): { grpcStatus: string; grpcMessage: string | null } { + let trailerStatus: number | null = null; + let trailerMessage: string | null = null; + let offset = 0; + while (offset < buffer.length) { + const frame = probeFrameHeader(buffer, offset); + if (!frame) break; + const frameEnd = frame.payloadStart + frame.payloadLength; + const body = buffer.subarray(frame.payloadStart, frameEnd); + if ((frame.flag & GRPC_WEB_TRAILER_FLAG_BIT) !== 0) { + const parsed = parseTrailer(body); + if (parsed.status !== null) trailerStatus = parsed.status; + if (parsed.message) trailerMessage = parsed.message; + } + offset = frameEnd; + } + const grpcStatus = + trailerStatus !== null ? String(trailerStatus) : headerStatus && headerStatus.trim() ? headerStatus.trim() : "13"; + const grpcMessage = trailerMessage ?? decodeTrailerMessage(headerMessage ?? null); + return { grpcStatus, grpcMessage }; +} + +function splitFrames(buffer: Buffer): { + dataPayload: Buffer | null; + sawData: boolean; + trailerStatus: number | null; +} { + let offset = 0; + let dataPayload: Buffer | null = null; + let sawData = false; + let trailerStatus: number | null = null; + + while (offset < buffer.length) { + const frame = probeFrameHeader(buffer, offset); + if (!frame) break; + const frameEnd = frame.payloadStart + frame.payloadLength; + const body = buffer.subarray(frame.payloadStart, frameEnd); + if ((frame.flag & GRPC_WEB_TRAILER_FLAG_BIT) !== 0) { + const status = parseTrailer(body).status; + if (status !== null) trailerStatus = status; + } else if (!sawData) { + sawData = true; + dataPayload = body; + } + offset = frameEnd; + } + + return { dataPayload, sawData, trailerStatus }; +} + +function timestampSeconds(field: ProtoField): number | null { + if (field.wireType === WIRE_TYPE_VARINT) { + return Number.isFinite(field.value) ? field.value : null; + } + if (field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null; + const nested = walkFields(field.bytes); + if (!nested) return null; + const seconds = nested.find( + (item) => item.fieldNumber === TIMESTAMP_FIELD_SECONDS && item.field.wireType === WIRE_TYPE_VARINT + ); + if (!seconds || seconds.field.wireType !== WIRE_TYPE_VARINT) return null; + return Number.isFinite(seconds.field.value) ? seconds.field.value : null; +} + +function hasFieldNumber(field: TaggedField, numbers: readonly number[]): boolean { + return (numbers as readonly number[]).includes(field.fieldNumber); +} + +function tokenExpiresAtMs(tokenFields: TaggedField[]): number | null { + const expires = tokenFields.find((field) => hasFieldNumber(field, TOKEN_NESTED_FIELDS.expires)); + if (!expires) return null; + const seconds = timestampSeconds(expires.field); + return seconds === null ? null : seconds * 1000; +} + +function tokenIdFromFields(tokenFields: TaggedField[]): string | null { + const id = tokenFields.find( + (field) => + hasFieldNumber(field, TOKEN_NESTED_FIELDS.id) && + field.field.wireType === WIRE_TYPE_LENGTH_DELIMITED + ); + if (!id || id.field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null; + const tokenId = id.field.bytes.toString("utf8").trim(); + return tokenId.length > 0 ? tokenId : null; +} + +function inventoryFromPayload( + payload: Buffer, + nowMs: number +): { snapshot: GrokResetCreditsSnapshot; tokens: GrokResetCreditToken[] } | null { + if (payload.length === 0) { + return { snapshot: { count: 0, nextExpiresAt: null }, tokens: [] }; + } + + const top = walkFields(payload); + if (!top) return null; + + const tokens: GrokResetCreditToken[] = []; + const expiresMs: number[] = []; + + for (const tagged of top) { + if (tagged.fieldNumber !== FIELD_RESET_TOKEN) continue; + if (tagged.field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null; + const tokenFields = walkFields(tagged.field.bytes); + if (!tokenFields) return null; + const tokenId = tokenIdFromFields(tokenFields); + if (!tokenId) return null; + const expires = tokenExpiresAtMs(tokenFields); + if (expires !== null && expires < nowMs) continue; + tokens.push({ + tokenId, + expiresAt: expires === null ? null : new Date(expires).toISOString(), + }); + if (expires !== null) expiresMs.push(expires); + } + + const next = expiresMs.length > 0 ? Math.min(...expiresMs) : null; + return { + snapshot: { + count: tokens.length, + nextExpiresAt: next === null ? null : new Date(next).toISOString(), + }, + tokens, + }; +} + +export function decodeGrokResetCreditsFrame( + buffer: Buffer, + nowMs = Date.now() +): GrokResetCreditsDecode { + if (!buffer || buffer.length === 0) { + return { ok: false, reason: "empty-buffer" }; + } + + try { + const framed = probeFrameHeader(buffer, 0) !== null; + if (!framed) { + const inventory = inventoryFromPayload(buffer, nowMs); + if (!inventory) return { ok: false, reason: "malformed" }; + return { ok: true, ...inventory }; + } + + const { dataPayload, sawData, trailerStatus } = splitFrames(buffer); + if (trailerStatus !== null && trailerStatus !== 0) { + return { ok: false, reason: "trailer-nonzero" }; + } + if (!sawData || dataPayload === null) { + return { ok: false, reason: "no-data-frame" }; + } + + const inventory = inventoryFromPayload(dataPayload, nowMs); + if (!inventory) return { ok: false, reason: "malformed" }; + return { ok: true, ...inventory }; + } catch { + return { ok: false, reason: "malformed" }; + } +} diff --git a/open-sse/services/usage/grokCli.ts b/open-sse/services/usage/grokCli.ts index bbd76199b8..5f60b8b0eb 100644 --- a/open-sse/services/usage/grokCli.ts +++ b/open-sse/services/usage/grokCli.ts @@ -5,6 +5,7 @@ import { GROK_BUILD_ADDITIONAL_CREDITS_URL, type GrokAutoTopUpStatus, } from "../../../src/shared/utils/grokBilling.ts"; +import { fetchGrokResetCredits } from "../grokResetCredits.ts"; const GROK_BUILD_FETCH_TIMEOUT_MS = 10_000; const GROK_BUILD_MAX_RESPONSE_BYTES = 256 * 1024; @@ -223,11 +224,13 @@ export async function getGrokCliUsage(accessToken?: string) { const user = await fetchGrokBuildJson("/user?include=subscription", baseHeaders, userSchema); const userId = user?.userId || null; const tier = user?.subscriptionTier || null; - const billing = await fetchGrokBuildJson( - "/billing?format=credits", - userId ? getGrokBuildModelsHeaders({ token: accessToken, userId }) : baseHeaders, - billingSchema - ); + const billingHeaders = userId + ? getGrokBuildModelsHeaders({ token: accessToken, userId }) + : baseHeaders; + const [billing, resetCredits] = await Promise.all([ + fetchGrokBuildJson("/billing?format=credits", billingHeaders, billingSchema), + fetchGrokResetCredits(accessToken), + ]); if (!billing?.config) { return { @@ -258,6 +261,7 @@ export async function getGrokCliUsage(accessToken?: string) { return { quotas, ...(tier ? { plan: tier } : {}), + ...(resetCredits ? { bankedResetCredits: resetCredits.count } : {}), billing: { currency: "USD", ...(config.prepaidBalance ? { extraCreditsMinorUnits: config.prepaidBalance.val } : {}), diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index 5c0616995f..38aa72d685 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -147,7 +147,12 @@ function normalizeQuotaEntry(name: string, quota: any = {}, extras: any = {}) { } function parseGeneric(data: any) { - return quotaEntries(data).map(([name, quota]) => normalizeQuotaEntry(name, quota)); + const quotas = quotaEntries(data).map(([name, quota]) => normalizeQuotaEntry(name, quota)); + const bankedResetCredits = Number(data?.bankedResetCredits); + if (Number.isFinite(bankedResetCredits) && bankedResetCredits >= 0) { + quotas.push(buildBankedResetCreditsQuota(bankedResetCredits)); + } + return quotas; } function parseGithub(data: any) { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useCodexResetCreditRedemption.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useCodexResetCreditRedemption.ts index 7ff97fa913..6f77137205 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useCodexResetCreditRedemption.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useCodexResetCreditRedemption.ts @@ -80,7 +80,12 @@ function useOpenCodexResetCredits( const notify = useNotificationStore(); return useCallback( async (connectionId: string, provider: string) => { - if (provider !== "codex" || loadingResetCreditsId || redeemingResetCreditId) return; + if ( + (provider !== "codex" && provider !== "grok-cli") || + loadingResetCreditsId || + redeemingResetCreditId + ) + return; setLoadingResetCreditsId(connectionId); setErrors((prev) => ({ ...prev, [connectionId]: null })); try { diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 5f052623ef..b34a50de56 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -339,7 +339,7 @@ export function computeCanEditCutoff(quotas: any[]): boolean { export function computeCanRedeemResetCredit(provider: string, quotas: any[]): boolean { return ( - provider === "codex" && + (provider === "codex" || provider === "grok-cli") && quotas.some((q: any) => q?.isResetCredits && Number(q.creditCount ?? q.remaining ?? 0) > 0) ); } diff --git a/src/app/api/usage/codex-reset-credit/route.ts b/src/app/api/usage/codex-reset-credit/route.ts index c9840bb8aa..78bb43bf7b 100644 --- a/src/app/api/usage/codex-reset-credit/route.ts +++ b/src/app/api/usage/codex-reset-credit/route.ts @@ -7,6 +7,12 @@ import { consumeCodexResetCredit, listCodexResetCredits, } from "@/lib/usage/codexResetCredits"; +import { + GrokResetCreditError, + consumeGrokResetCredit, + listGrokResetCredits, +} from "@/lib/usage/grokResetCredits"; +import { getProviderConnectionById } from "@/lib/db/providers"; const ConnectionIdSchema = z.string().trim().min(1).max(256); @@ -16,17 +22,40 @@ const CodexResetCreditBodySchema = z.object({ creditId: z.string().trim().min(1).max(512).optional(), }); +function isResetCreditError( + error: unknown +): error is CodexResetCreditError | GrokResetCreditError { + return error instanceof CodexResetCreditError || error instanceof GrokResetCreditError; +} + function buildErrorResponse(error: unknown) { - const status = error instanceof CodexResetCreditError ? error.status : 500; - const code = error instanceof CodexResetCreditError ? error.code : "codex_reset_credit_failed"; - const message = - error instanceof CodexResetCreditError - ? sanitizeErrorMessage(error.message) || "Codex reset-credit request failed." - : "Codex reset-credit request failed."; + const status = isResetCreditError(error) ? error.status : 500; + const code = isResetCreditError(error) ? error.code : "reset_credit_failed"; + const message = isResetCreditError(error) + ? sanitizeErrorMessage(error.message) || "Reset-credit request failed." + : "Reset-credit request failed."; console.error("[API] /api/usage/codex-reset-credit error:", error); return NextResponse.json({ ok: false, code, error: message }, { status }); } +function unsupportedResetCreditProvider(provider: string | null) { + return NextResponse.json( + { + ok: false, + code: provider ? "unsupported_reset_credit_provider" : "connection_not_found", + error: provider + ? "Reset credits are only available for Codex and Grok Build accounts." + : "Connection not found.", + }, + { status: provider ? 400 : 404 } + ); +} + +async function resolveResetCreditProvider(connectionId: string): Promise { + const connection = await getProviderConnectionById(connectionId); + return connection && typeof connection.provider === "string" ? connection.provider : null; +} + export async function GET(request: Request) { const authError = await requireManagementAuth(request); if (authError) return authError; @@ -41,8 +70,16 @@ export async function GET(request: Request) { { status: 400 } ); } - const result = await listCodexResetCredits(parsed.data); - return NextResponse.json({ ok: true, ...result }); + const provider = await resolveResetCreditProvider(parsed.data); + if (provider === "grok-cli") { + const result = await listGrokResetCredits(parsed.data); + return NextResponse.json({ ok: true, ...result }); + } + if (provider === "codex") { + const result = await listCodexResetCredits(parsed.data); + return NextResponse.json({ ok: true, ...result }); + } + return unsupportedResetCreditProvider(provider); } catch (error) { return buildErrorResponse(error); } @@ -62,12 +99,24 @@ export async function POST(request: Request) { ); } - const result = await consumeCodexResetCredit( - parsed.data.connectionId, - parsed.data.idempotencyKey, - parsed.data.creditId - ); - return NextResponse.json({ ok: true, ...result }); + const provider = await resolveResetCreditProvider(parsed.data.connectionId); + if (provider === "grok-cli") { + const result = await consumeGrokResetCredit( + parsed.data.connectionId, + parsed.data.idempotencyKey, + parsed.data.creditId + ); + return NextResponse.json({ ok: true, ...result }); + } + if (provider === "codex") { + const result = await consumeCodexResetCredit( + parsed.data.connectionId, + parsed.data.idempotencyKey, + parsed.data.creditId + ); + return NextResponse.json({ ok: true, ...result }); + } + return unsupportedResetCreditProvider(provider); } catch (error) { return buildErrorResponse(error); } diff --git a/src/lib/usage/grokResetCredits.ts b/src/lib/usage/grokResetCredits.ts new file mode 100644 index 0000000000..b1321e06cc --- /dev/null +++ b/src/lib/usage/grokResetCredits.ts @@ -0,0 +1,143 @@ +import { getProviderConnectionById } from "@/lib/db/providers"; +import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation"; +import { + fetchAndPersistProviderLimits, + refreshAndUpdateCredentials, +} from "@/lib/usage/providerLimits"; +import { invalidateGrokCliQuotaCache } from "@omniroute/open-sse/services/grokCliQuotaFetcher.ts"; +import { + consumeGrokResetCredit as consumeGrokResetCreditRpc, + GrokResetCreditError, + listGrokResetCreditTokens, + type GrokResetCreditOutcome, + type PublicGrokResetCredit, +} from "@omniroute/open-sse/services/grokResetCredits.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; + +export { GrokResetCreditError }; + +type JsonRecord = Record; + +type GrokConnectionLike = JsonRecord & { + id: string; + provider: string; + authType?: string; + accessToken?: string; +}; + +export interface GrokResetCreditList { + credits: PublicGrokResetCredit[]; + availableCount: number; +} + +async function loadGrokConnection(connectionId: string): Promise { + if (await isConnectionUnavailableToAuxiliaryActivity(connectionId)) { + throw new GrokResetCreditError( + 409, + "exclusive_lease_active", + "Reset-credit operations are deferred while an exclusive lease is active." + ); + } + const connection = (await getProviderConnectionById( + connectionId + )) as unknown as GrokConnectionLike | null; + + if (!connection) { + throw new GrokResetCreditError(404, "connection_not_found", "Connection not found."); + } + + if (connection.provider !== "grok-cli") { + throw new GrokResetCreditError( + 400, + "grok_provider_required", + "Reset credits can only be redeemed for Grok Build accounts." + ); + } + + if (connection.authType !== "oauth") { + throw new GrokResetCreditError( + 400, + "grok_oauth_required", + "Grok reset credits require an OAuth connection." + ); + } + + return connection; +} + +async function refreshGrokConnectionIfNeeded( + connection: GrokConnectionLike, + force = false +): Promise { + const refreshed = await refreshAndUpdateCredentials(connection, { + allowRotatingRefresh: true, + force, + }); + return refreshed.connection as GrokConnectionLike; +} + +function requireAccessToken(connection: GrokConnectionLike): string { + const token = typeof connection.accessToken === "string" ? connection.accessToken.trim() : ""; + if (!token) { + throw new GrokResetCreditError( + 401, + "grok_access_token_missing", + "Grok OAuth access token is missing." + ); + } + return token; +} + +export async function listGrokResetCredits(connectionId: string): Promise { + if (!connectionId || typeof connectionId !== "string") { + throw new GrokResetCreditError(400, "connection_id_required", "connectionId is required."); + } + + try { + let connection = await loadGrokConnection(connectionId); + connection = await refreshGrokConnectionIfNeeded(connection); + return await listGrokResetCreditTokens(requireAccessToken(connection)); + } catch (error) { + if (error instanceof GrokResetCreditError) throw error; + throw new GrokResetCreditError( + 500, + "grok_reset_credit_list_failed", + sanitizeErrorMessage(error) || "Failed to load Grok reset credits." + ); + } +} + +export async function consumeGrokResetCredit( + connectionId: string, + // RedeemReset has no idempotency field (live X500 2026-09-05). Kept so the + // shared /api/usage/codex-reset-credit body schema stays one shape. + _idempotencyKey: string, + creditId?: string +): Promise<{ + outcome: GrokResetCreditOutcome; + usage: JsonRecord; +}> { + if (!connectionId || typeof connectionId !== "string") { + throw new GrokResetCreditError(400, "connection_id_required", "connectionId is required."); + } + + try { + let connection = await loadGrokConnection(connectionId); + connection = await refreshGrokConnectionIfNeeded(connection); + const outcome = await consumeGrokResetCreditRpc(requireAccessToken(connection), { + tokenId: creditId, + }); + invalidateGrokCliQuotaCache(connectionId); + const refreshed = await fetchAndPersistProviderLimits(connectionId, "manual", { + allowRotatingRefresh: true, + }); + return { outcome, usage: refreshed.usage }; + } catch (error) { + if (error instanceof GrokResetCreditError) throw error; + throw new GrokResetCreditError( + 500, + "grok_reset_credit_failed", + sanitizeErrorMessage(error) || "Failed to redeem Grok reset credit." + ); + } +} diff --git a/tests/unit/grok-cli-provider-limits.test.ts b/tests/unit/grok-cli-provider-limits.test.ts index 72e7c2b2da..ae2232b821 100644 --- a/tests/unit/grok-cli-provider-limits.test.ts +++ b/tests/unit/grok-cli-provider-limits.test.ts @@ -32,6 +32,75 @@ function response(value: unknown, init: ResponseInit = {}) { }); } +function grpcFrame(flag: number, payload: Buffer): Buffer { + const header = Buffer.alloc(5); + header[0] = flag; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function emptyResetCreditsResponse(): Response { + const trailer = Buffer.from("grpc-status:0\r\n", "utf8"); + return new Response(Buffer.concat([grpcFrame(0x00, Buffer.alloc(0)), grpcFrame(0x80, trailer)]), { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); +} + +function encodeVarint(value: number): Buffer { + const bytes: number[] = []; + let v = BigInt(value); + do { + let byte = Number(v & 0x7fn); + v >>= 7n; + if (v !== 0n) byte |= 0x80; + bytes.push(byte); + } while (v !== 0n); + return Buffer.from(bytes); +} + +function encodeTag(fieldNumber: number, wireType: number): Buffer { + return encodeVarint((fieldNumber << 3) | wireType); +} + +function encodeLengthDelimited(fieldNumber: number, body: Buffer): Buffer { + return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]); +} + +function encodeVarintField(fieldNumber: number, value: number): Buffer { + return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]); +} + +function oneResetTokenResponse(): Response { + const token = Buffer.concat([ + encodeLengthDelimited(1, Buffer.from("test-token-id", "utf8")), + encodeVarintField(2, 1786560540), + encodeVarintField(3, 1789238940), + ]); + const payload = encodeLengthDelimited(10, token); + const trailer = Buffer.from("grpc-status:0\r\n", "utf8"); + return new Response(Buffer.concat([grpcFrame(0x00, payload), grpcFrame(0x80, trailer)]), { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); +} + +/** Live X500 hotmail shape: nested fields 10/20/30, timestamps length-delimited. */ +function liveResetTokenResponse(): Response { + const timestamp = (unixSeconds: number) => encodeVarintField(1, unixSeconds); + const token = Buffer.concat([ + encodeLengthDelimited(10, Buffer.from("test-token-id", "utf8")), + encodeLengthDelimited(20, timestamp(1786560540)), + encodeLengthDelimited(30, timestamp(1789238940)), + ]); + const payload = encodeLengthDelimited(10, token); + const trailer = Buffer.from("grpc-status:0\r\n", "utf8"); + return new Response(Buffer.concat([grpcFrame(0x00, payload), grpcFrame(0x80, trailer)]), { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); +} + function successFixtures( options: { tier?: unknown; @@ -99,6 +168,9 @@ function successFixtures( }, }); } + if (url.includes("prod_mc_billing.ConsumerUiSvc/GetRemainingResets")) { + return emptyResetCreditsResponse(); + } return new Response(null, { status: 404 }); }; } @@ -106,6 +178,7 @@ function successFixtures( interface UsageResult { plan?: string; message?: string; + bankedResetCredits?: number; quotas?: Record< string, { @@ -190,15 +263,35 @@ test("grok-cli fetches the fixed read-only surfaces with the full Grok client pr additionalCreditsUrl: "https://grok.com/build?_s=usage", }); + assert.equal(usage.bankedResetCredits, 0); + + const jsonCalls = calls.filter( + (call) => !call.url.includes("prod_mc_billing.ConsumerUiSvc/GetRemainingResets") + ); + const resetCall = calls.find((call) => + call.url.includes("prod_mc_billing.ConsumerUiSvc/GetRemainingResets") + ); + assert.ok(resetCall); + assert.equal(resetCall.init.method, "POST"); + assert.equal( + new Headers(resetCall.init.headers).get("content-type"), + "application/grpc-web+proto" + ); + assert.equal(new Headers(resetCall.init.headers).get("x-grpc-web"), "1"); + assert.equal( + new Headers(resetCall.init.headers).get("authorization"), + "Bearer fixture-access-token" + ); + assert.deepEqual( - calls.map((call) => call.url), + jsonCalls.map((call) => call.url), [ "https://cli-chat-proxy.grok.com/v1/user?include=subscription", "https://cli-chat-proxy.grok.com/v1/billing?format=credits", "https://cli-chat-proxy.grok.com/v1/auto-topup-rule", ] ); - for (const { init } of calls) { + for (const { init } of jsonCalls) { assert.equal(init.method, "GET"); assert.equal(init.redirect, "error"); assert.equal(init.body, undefined); @@ -212,8 +305,8 @@ test("grok-cli fetches the fixed read-only surfaces with the full Grok client pr assert.ok(headers.get("x-grok-client-identifier")); assert.equal(headers.get("x-grok-client-mode"), "headless"); } - assert.equal(new Headers(calls[0].init.headers).has("x-userid"), false); - assert.equal(new Headers(calls[2].init.headers).get("x-userid"), "canonical-user-id"); + assert.equal(new Headers(jsonCalls[0].init.headers).has("x-userid"), false); + assert.equal(new Headers(jsonCalls[2].init.headers).get("x-userid"), "canonical-user-id"); assert.deepEqual(grokTesting.networkPolicy, { method: "GET", redirect: "error", @@ -549,6 +642,39 @@ test("SuperGrokPro explicit null creditUsagePercent still yields a weekly quota }); }); +test("grok-cli surfaces bankedResetCredits when GetRemainingResets returns one token", async () => { + const fixtureFetch = successFixtures(); + const usage = await getUsage((async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("GetRemainingResets")) return oneResetTokenResponse(); + return fixtureFetch(input); + }) as typeof fetch); + assert.equal(usage.bankedResetCredits, 1); + assert.ok(usage.quotas?.weekly); +}); + +test("grok-cli surfaces bankedResetCredits for live nested 10/20/30 tokens", async () => { + const fixtureFetch = successFixtures(); + const usage = await getUsage((async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("GetRemainingResets")) return liveResetTokenResponse(); + return fixtureFetch(input); + }) as typeof fetch); + assert.equal(usage.bankedResetCredits, 1); + assert.ok(usage.quotas?.weekly); +}); + +test("grok-cli omits bankedResetCredits when GetRemainingResets fails (fail-open)", async () => { + const fixtureFetch = successFixtures(); + const usage = await getUsage((async (input: string | URL | Request) => { + const url = String(input); + if (url.includes("GetRemainingResets")) return new Response("nope", { status: 404 }); + return fixtureFetch(input); + }) as typeof fetch); + assert.equal("bankedResetCredits" in usage, false); + assert.ok(usage.quotas?.weekly); +}); + test("SuperGrokPro omitted currentPeriod still yields a weekly bar with null resetAt", async () => { const usage = await getUsage( successFixtures({ diff --git a/tests/unit/grok-reset-credits-connection.test.ts b/tests/unit/grok-reset-credits-connection.test.ts new file mode 100644 index 0000000000..a82df43bc0 --- /dev/null +++ b/tests/unit/grok-reset-credits-connection.test.ts @@ -0,0 +1,195 @@ +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-grok-reset-credits-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-grok-reset-credits-secret"; +process.env.STORAGE_ENCRYPTION_KEY = "grok-reset-credits-test-key-32-bytes-min"; +process.env.DISABLE_SQLITE_AUTO_BACKUP = "true"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const grokReset = await import("../../src/lib/usage/grokResetCredits.ts"); + +const originalFetch = globalThis.fetch; +const LIST_URL = "https://grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets"; +const REDEEM_URL = "https://grok.com/prod_mc_billing.ConsumerUiSvc/RedeemReset"; +const GRANTED = 1786560540; +const EXPIRES = 1789238940; +const TOKEN_ID = "test-token-id"; + +function encodeVarint(value: number): Buffer { + const bytes: number[] = []; + let v = BigInt(value); + do { + let byte = Number(v & 0x7fn); + v >>= 7n; + if (v !== 0n) byte |= 0x80; + bytes.push(byte); + } while (v !== 0n); + return Buffer.from(bytes); +} + +function encodeTag(fieldNumber: number, wireType: number): Buffer { + return encodeVarint((fieldNumber << 3) | wireType); +} + +function encodeLengthDelimited(fieldNumber: number, body: Buffer): Buffer { + return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]); +} + +function encodeVarintField(fieldNumber: number, value: number): Buffer { + return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]); +} + +function grpcFrame(flag: number, payload: Buffer): Buffer { + const header = Buffer.alloc(5); + header[0] = flag; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function listResponse(tokens: Array<{ id: string; expires: number }>): Response { + const payload = Buffer.concat( + tokens.map((token) => + encodeLengthDelimited( + 10, + Buffer.concat([ + encodeLengthDelimited(1, Buffer.from(token.id, "utf8")), + encodeVarintField(2, GRANTED), + encodeVarintField(3, token.expires), + ]) + ) + ) + ); + const trailer = Buffer.from("grpc-status:0\r\n", "utf8"); + return new Response(Buffer.concat([grpcFrame(0x00, payload), grpcFrame(0x80, trailer)]), { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); +} + +function trailerResponse(status: number, message?: string): Response { + const lines = [`grpc-status:${status}\r\n`]; + if (message) lines.push(`grpc-message:${encodeURIComponent(message)}\r\n`); + const trailer = Buffer.from(lines.join(""), "utf8"); + return new Response(Buffer.concat([grpcFrame(0x00, Buffer.alloc(0)), grpcFrame(0x80, trailer)]), { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); +} + +function usageJsonResponse(): Response { + return new Response( + JSON.stringify({ + plan: "SuperGrok Heavy", + quotas: { weekly: { used: 0, total: 100, remainingPercentage: 100 } }, + bankedResetCredits: 0, + }), + { status: 200, 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 createGrokConnection(overrides: Record = {}) { + return providersDb.createProviderConnection({ + provider: "grok-cli", + authType: "oauth", + name: `Grok Reset ${Date.now()} ${Math.random()}`, + email: `grok-${Date.now()}-${Math.random()}@example.test`, + accessToken: "grok-access-token", + refreshToken: "grok-refresh-token", + ...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("listGrokResetCredits returns public rows without logging token ids in usage", async () => { + const connection = (await createGrokConnection()) as { id: string }; + globalThis.fetch = async (url) => { + if (String(url) === LIST_URL) return listResponse([{ id: TOKEN_ID, expires: EXPIRES }]); + return new Response("unexpected", { status: 500 }); + }; + + const result = await grokReset.listGrokResetCredits(connection.id); + assert.equal(result.availableCount, 1); + assert.equal(result.credits[0]?.selectionToken, TOKEN_ID); + assert.equal(result.credits[0]?.expiresAt, new Date(EXPIRES * 1000).toISOString()); +}); + +test("consumeGrokResetCredit posts RedeemReset then refreshes usage", async () => { + const connection = (await createGrokConnection()) as { id: string }; + const calls: string[] = []; + + globalThis.fetch = async (url, init = {}) => { + calls.push(String(url)); + if (String(url) === LIST_URL) return listResponse([{ id: TOKEN_ID, expires: EXPIRES }]); + if (String(url) === REDEEM_URL) { + const headers = init.headers as Record; + assert.equal(headers.Authorization, ["Bearer", "grok-access-token"].join(" ")); + return trailerResponse(0); + } + if (String(url).includes("/user?include=subscription")) { + return new Response(JSON.stringify({ userId: "u1", subscriptionTier: "SuperGrok Heavy" }), { + status: 200, + }); + } + if (String(url).includes("/billing?format=credits")) { + return new Response( + JSON.stringify({ + config: { + creditUsagePercent: 0, + currentPeriod: { type: "WEEKLY", end: "2026-09-12T00:00:00.000Z" }, + }, + }), + { status: 200 } + ); + } + if (String(url).includes("/auto-topup-rule")) { + return new Response(JSON.stringify({}), { status: 200 }); + } + return usageJsonResponse(); + }; + + const result = await grokReset.consumeGrokResetCredit(connection.id, "redeem-1", TOKEN_ID); + assert.equal(result.outcome, "reset"); + assert.equal(calls.includes(REDEEM_URL), true); + assert.equal(typeof result.usage, "object"); +}); + +test("consumeGrokResetCredit rejects non-grok connections", async () => { + const connection = (await providersDb.createProviderConnection({ + provider: "codex", + authType: "oauth", + name: "Codex decoy", + email: `codex-${Date.now()}@example.test`, + accessToken: "codex-access-token", + refreshToken: "codex-refresh-token", + })) as { id: string }; + + await assert.rejects( + () => grokReset.consumeGrokResetCredit(connection.id, "redeem-wrong"), + (error: unknown) => + error instanceof grokReset.GrokResetCreditError && + error.status === 400 && + error.code === "grok_provider_required" + ); +}); diff --git a/tests/unit/grok-reset-credits-frame.test.ts b/tests/unit/grok-reset-credits-frame.test.ts new file mode 100644 index 0000000000..780f5281b8 --- /dev/null +++ b/tests/unit/grok-reset-credits-frame.test.ts @@ -0,0 +1,191 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + decodeGrokResetCreditsFrame, + encodeRedeemResetRequest, +} from "../../open-sse/services/grokResetCreditsFrame.ts"; + +const GRANTED = 1786560540; +const EXPIRES = 1789238940; +const TOKEN_ID = "test-token-id"; // 13 bytes + +function encodeVarint(value: number): Buffer { + const bytes: number[] = []; + let v = BigInt(value); + do { + let byte = Number(v & 0x7fn); + v >>= 7n; + if (v !== 0n) byte |= 0x80; + bytes.push(byte); + } while (v !== 0n); + return Buffer.from(bytes); +} + +function encodeTag(fieldNumber: number, wireType: number): Buffer { + return encodeVarint((fieldNumber << 3) | wireType); +} + +function encodeLengthDelimited(fieldNumber: number, body: Buffer): Buffer { + return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]); +} + +function encodeVarintField(fieldNumber: number, value: number): Buffer { + return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]); +} + +function encodeToken(id: string, granted: number, expires: number): Buffer { + return Buffer.concat([ + encodeLengthDelimited(1, Buffer.from(id, "utf8")), + encodeVarintField(2, granted), + encodeVarintField(3, expires), + ]); +} + +/** google.protobuf.Timestamp with seconds only (nanos omitted). Live inner length is 6. */ +function encodeTimestampSeconds(unixSeconds: number): Buffer { + return encodeVarintField(1, unixSeconds); +} + +/** + * X500 2026-09-06 hotmail SuperGrokPro GetRemainingResets DATA: + * top field 10, nested fields 10/20/30 all length-delimited + * (id 13B, granted Timestamp 6B, expires Timestamp 6B). + */ +function encodeLiveToken(id: string, granted: number, expires: number): Buffer { + return Buffer.concat([ + encodeLengthDelimited(10, Buffer.from(id, "utf8")), + encodeLengthDelimited(20, encodeTimestampSeconds(granted)), + encodeLengthDelimited(30, encodeTimestampSeconds(expires)), + ]); +} + +function frameData(payload: Buffer): Buffer { + const header = Buffer.alloc(5); + header[0] = 0x00; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function frameTrailer(statusText = "grpc-status:0\r\n"): Buffer { + const body = Buffer.from(statusText, "utf8"); + const header = Buffer.alloc(5); + header[0] = 0x80; + header.writeUInt32BE(body.length, 1); + return Buffer.concat([header, body]); +} + +test("empty DATA frame + grpc-status 0 is a real zero inventory", () => { + const buffer = Buffer.concat([frameData(Buffer.alloc(0)), frameTrailer()]); + const decoded = decodeGrokResetCreditsFrame(buffer); + assert.equal(decoded.ok, true); + if (!decoded.ok) return; + assert.equal(decoded.snapshot.count, 0); + assert.equal(decoded.snapshot.nextExpiresAt, null); +}); + +test("one unexpired field-10 token counts as 1", () => { + const payload = encodeLengthDelimited(10, encodeToken(TOKEN_ID, GRANTED, EXPIRES)); + const decoded = decodeGrokResetCreditsFrame(Buffer.concat([frameData(payload), frameTrailer()])); + assert.equal(decoded.ok, true); + if (!decoded.ok) return; + assert.equal(decoded.snapshot.count, 1); + assert.equal(decoded.snapshot.nextExpiresAt, new Date(EXPIRES * 1000).toISOString()); + assert.equal(decoded.tokens.length, 1); + assert.equal(decoded.tokens[0]?.tokenId, TOKEN_ID); + assert.equal(decoded.tokens[0]?.expiresAt, new Date(EXPIRES * 1000).toISOString()); +}); + +test("repeated field-10 is not collapsed by a Map walker", () => { + const a = encodeLengthDelimited(10, encodeToken("test-token-aa", GRANTED, EXPIRES)); + const b = encodeLengthDelimited(10, encodeToken("test-token-bb", GRANTED, EXPIRES + 86400)); + const decoded = decodeGrokResetCreditsFrame( + Buffer.concat([frameData(Buffer.concat([a, b])), frameTrailer()]) + ); + assert.equal(decoded.ok, true); + if (!decoded.ok) return; + assert.equal(decoded.snapshot.count, 2); + assert.equal(decoded.snapshot.nextExpiresAt, new Date(EXPIRES * 1000).toISOString()); + assert.deepEqual( + decoded.tokens.map((token) => token.tokenId), + ["test-token-aa", "test-token-bb"] + ); +}); + +test("expired tokens are dropped from the count", () => { + const expired = encodeLengthDelimited(10, encodeToken("test-token-ex", GRANTED, 1_700_000_000)); + const live = encodeLengthDelimited(10, encodeToken(TOKEN_ID, GRANTED, EXPIRES)); + const decoded = decodeGrokResetCreditsFrame( + Buffer.concat([frameData(Buffer.concat([expired, live])), frameTrailer()]) + ); + assert.equal(decoded.ok, true); + if (!decoded.ok) return; + assert.equal(decoded.snapshot.count, 1); + assert.equal(decoded.snapshot.nextExpiresAt, new Date(EXPIRES * 1000).toISOString()); +}); + +test("nonzero grpc-status is not a zero inventory", () => { + const decoded = decodeGrokResetCreditsFrame( + Buffer.concat([frameData(Buffer.alloc(0)), frameTrailer("grpc-status:13\r\n")]) + ); + assert.equal(decoded.ok, false); + if (decoded.ok) return; + assert.equal(decoded.reason, "trailer-nonzero"); +}); + +test("trailer-only buffer is not a zero inventory", () => { + const decoded = decodeGrokResetCreditsFrame(frameTrailer()); + assert.equal(decoded.ok, false); + if (decoded.ok) return; + assert.equal(decoded.reason, "no-data-frame"); +}); + +test("empty buffer is not a zero inventory", () => { + const decoded = decodeGrokResetCreditsFrame(Buffer.alloc(0)); + assert.equal(decoded.ok, false); + if (decoded.ok) return; + assert.equal(decoded.reason, "empty-buffer"); +}); + +test("13-byte token id is a string, not a nested protobuf message", () => { + const payload = encodeLengthDelimited(10, encodeToken(TOKEN_ID, GRANTED, EXPIRES)); + const decoded = decodeGrokResetCreditsFrame(Buffer.concat([frameData(payload), frameTrailer()])); + assert.equal(decoded.ok, true); + if (!decoded.ok) return; + assert.equal(decoded.snapshot.count, 1); + assert.equal(decoded.tokens[0]?.tokenId, TOKEN_ID); + assert.equal(decoded.tokens[0]?.tokenId.length, 13); +}); + +test("live nested fields 10/20/30 are not malformed", () => { + const liveInner = encodeLiveToken(TOKEN_ID, GRANTED, EXPIRES); + assert.equal(encodeTimestampSeconds(GRANTED).length, 6); + assert.equal(encodeTimestampSeconds(EXPIRES).length, 6); + const payload = encodeLengthDelimited(10, liveInner); + const decoded = decodeGrokResetCreditsFrame(Buffer.concat([frameData(payload), frameTrailer()])); + assert.equal(decoded.ok, true); + if (!decoded.ok) return; + assert.equal(decoded.snapshot.count, 1); + assert.equal(decoded.tokens[0]?.tokenId, TOKEN_ID); + assert.equal(decoded.tokens[0]?.expiresAt, new Date(EXPIRES * 1000).toISOString()); +}); + +test("live nested field-30 expiry still drops expired cards", () => { + const expired = encodeLengthDelimited(10, encodeLiveToken("test-token-ex", GRANTED, 1_700_000_000)); + const live = encodeLengthDelimited(10, encodeLiveToken(TOKEN_ID, GRANTED, EXPIRES)); + const decoded = decodeGrokResetCreditsFrame( + Buffer.concat([frameData(Buffer.concat([expired, live])), frameTrailer()]) + ); + assert.equal(decoded.ok, true); + if (!decoded.ok) return; + assert.equal(decoded.snapshot.count, 1); + assert.equal(decoded.tokens[0]?.tokenId, TOKEN_ID); +}); + +test("RedeemReset request encodes token_id as protobuf field 10", () => { + const encoded = encodeRedeemResetRequest(TOKEN_ID); + const tag = encoded[0]; + const length = encoded[1]; + assert.equal(tag, (10 << 3) | 2); + assert.equal(length, TOKEN_ID.length); + assert.equal(encoded.subarray(2).toString("utf8"), TOKEN_ID); +}); diff --git a/tests/unit/grok-reset-credits-redeem.test.ts b/tests/unit/grok-reset-credits-redeem.test.ts new file mode 100644 index 0000000000..5f77a17705 --- /dev/null +++ b/tests/unit/grok-reset-credits-redeem.test.ts @@ -0,0 +1,176 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + consumeGrokResetCredit, + listGrokResetCreditTokens, + mapGrokRedeemGrpcStatus, +} from "../../open-sse/services/grokResetCredits.ts"; +import { encodeRedeemResetRequest } from "../../open-sse/services/grokResetCreditsFrame.ts"; + +const GRANTED = 1786560540; +const EXPIRES = 1789238940; +const TOKEN_ID = "test-token-id"; +const REDEEM_URL = "https://grok.com/prod_mc_billing.ConsumerUiSvc/RedeemReset"; +const LIST_URL = "https://grok.com/prod_mc_billing.ConsumerUiSvc/GetRemainingResets"; + +function encodeVarint(value: number): Buffer { + const bytes: number[] = []; + let v = BigInt(value); + do { + let byte = Number(v & 0x7fn); + v >>= 7n; + if (v !== 0n) byte |= 0x80; + bytes.push(byte); + } while (v !== 0n); + return Buffer.from(bytes); +} + +function encodeTag(fieldNumber: number, wireType: number): Buffer { + return encodeVarint((fieldNumber << 3) | wireType); +} + +function encodeLengthDelimited(fieldNumber: number, body: Buffer): Buffer { + return Buffer.concat([encodeTag(fieldNumber, 2), encodeVarint(body.length), body]); +} + +function encodeVarintField(fieldNumber: number, value: number): Buffer { + return Buffer.concat([encodeTag(fieldNumber, 0), encodeVarint(value)]); +} + +function grpcFrame(flag: number, payload: Buffer): Buffer { + const header = Buffer.alloc(5); + header[0] = flag; + header.writeUInt32BE(payload.length, 1); + return Buffer.concat([header, payload]); +} + +function trailerResponse(status: number, message?: string): Response { + const lines = [`grpc-status:${status}\r\n`]; + if (message) lines.push(`grpc-message:${encodeURIComponent(message)}\r\n`); + const trailer = Buffer.from(lines.join(""), "utf8"); + return new Response(Buffer.concat([grpcFrame(0x00, Buffer.alloc(0)), grpcFrame(0x80, trailer)]), { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); +} + +function listResponse(tokens: Array<{ id: string; expires: number }>): Response { + const payload = Buffer.concat( + tokens.map((token) => + encodeLengthDelimited( + 10, + Buffer.concat([ + encodeLengthDelimited(1, Buffer.from(token.id, "utf8")), + encodeVarintField(2, GRANTED), + encodeVarintField(3, token.expires), + ]) + ) + ) + ); + const trailer = Buffer.from("grpc-status:0\r\n", "utf8"); + return new Response(Buffer.concat([grpcFrame(0x00, payload), grpcFrame(0x80, trailer)]), { + status: 200, + headers: { "content-type": "application/grpc-web+proto" }, + }); +} + +test("mapGrokRedeemGrpcStatus treats grpc 0 as reset", () => { + assert.equal(mapGrokRedeemGrpcStatus("0", null), "reset"); +}); + +test("mapGrokRedeemGrpcStatus treats missing token as noCredit", () => { + assert.equal( + mapGrokRedeemGrpcStatus("9", "The token cannot be redeemed: it does not exist or is expired"), + "noCredit" + ); +}); + +test("mapGrokRedeemGrpcStatus treats already-redeemed as alreadyRedeemed", () => { + assert.equal(mapGrokRedeemGrpcStatus("9", "token already redeemed"), "alreadyRedeemed"); +}); + +test("mapGrokRedeemGrpcStatus treats invalid token_id as noCredit", () => { + assert.equal(mapGrokRedeemGrpcStatus("3", "redeem_reset(), Invalid token_id"), "noCredit"); +}); + +test("listGrokResetCreditTokens returns public rows ordered by expiry", async () => { + const listed = await listGrokResetCreditTokens("fixture-access-token", async (url) => { + assert.equal(String(url), LIST_URL); + return listResponse([ + { id: "test-token-bb", expires: EXPIRES + 86400 }, + { id: "test-token-aa", expires: EXPIRES }, + ]); + }); + assert.equal(listed.availableCount, 2); + assert.deepEqual( + listed.credits.map((credit) => credit.selectionToken), + ["test-token-aa", "test-token-bb"] + ); + assert.equal(listed.credits[0]?.expiresAt, new Date(EXPIRES * 1000).toISOString()); +}); + +test("consumeGrokResetCredit posts RedeemReset with protobuf field 10 and skips inventory when tokenId is given", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const outcome = await consumeGrokResetCredit( + "fixture-access-token", + { tokenId: TOKEN_ID }, + async (url, init = {}) => { + calls.push({ url: String(url), init }); + if (String(url) === LIST_URL) return listResponse([{ id: TOKEN_ID, expires: EXPIRES }]); + return trailerResponse(0); + } + ); + assert.equal(outcome, "reset"); + assert.equal( + calls.some((call) => call.url === LIST_URL), + false + ); + const redeem = calls.find((call) => call.url === REDEEM_URL); + assert.ok(redeem); + assert.equal(redeem?.init.method, "POST"); + const body = Buffer.from(redeem?.init.body as Buffer); + const payload = body.subarray(5); + assert.deepEqual(payload, encodeRedeemResetRequest(TOKEN_ID)); + const headers = redeem?.init.headers as Record; + assert.equal(headers.Authorization, ["Bearer", "fixture-access-token"].join(" ")); +}); + +test("consumeGrokResetCredit picks the token that expires first when none is selected", async () => { + const calls: Array<{ url: string; body: Buffer | null }> = []; + const outcome = await consumeGrokResetCredit("fixture-access-token", {}, async (url, init = {}) => { + const body = init.body ? Buffer.from(init.body as Buffer) : null; + calls.push({ url: String(url), body }); + if (String(url) === LIST_URL) { + return listResponse([ + { id: "test-token-bb", expires: EXPIRES + 86400 }, + { id: "test-token-aa", expires: EXPIRES }, + ]); + } + return trailerResponse(0); + }); + assert.equal(outcome, "reset"); + const redeem = calls.find((call) => call.url === REDEEM_URL); + assert.ok(redeem?.body); + const payload = redeem!.body!.subarray(5); + assert.deepEqual(payload, encodeRedeemResetRequest("test-token-aa")); +}); + +test("consumeGrokResetCredit maps a missing selected token via RedeemReset grpc-status 9", async () => { + const { GrokResetCreditError } = await import("../../open-sse/services/grokResetCredits.ts"); + await assert.rejects( + () => + consumeGrokResetCredit( + "fixture-access-token", + { tokenId: "missing-token" }, + async (url) => { + if (String(url) === LIST_URL) return listResponse([{ id: TOKEN_ID, expires: EXPIRES }]); + return trailerResponse( + 9, + "The token cannot be redeemed: it does not exist or is expired" + ); + } + ), + (error: unknown) => + error instanceof GrokResetCreditError && error.status === 409 && error.code === "no_credit" + ); +}); diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index cb01fa1f9f..8b93c0ea92 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -425,3 +425,38 @@ test("provider quota auto-refresh settings are accepted by the settings schema", assert.equal(result.success, true); }); + +test("grok-cli banked reset credits parse as an integer reset-credit counter including zero", () => { + const parsed = providerLimitUtils.parseQuotaData("grok-cli", { + quotas: { + weekly: { used: 37.25, total: 100, remainingPercentage: 62.75, isPercentageOnly: true }, + }, + bankedResetCredits: 0, + }) as ParsedQuota[]; + const resetCredits = parsed.find((quota) => quota.name === "banked_reset_credits"); + assert.ok(resetCredits); + assert.equal(resetCredits.isResetCredits, true); + assert.equal(resetCredits.creditCount, 0); +}); + +test("grok-cli omits the reset-credit row when bankedResetCredits is absent", () => { + const parsed = providerLimitUtils.parseQuotaData("grok-cli", { + quotas: { + weekly: { used: 37.25, total: 100, remainingPercentage: 62.75 }, + }, + }) as ParsedQuota[]; + assert.equal( + parsed.some((quota) => quota.name === "banked_reset_credits"), + false + ); +}); + +test("grok-cli exposes the redeem button when banked reset credits are present", () => { + const parsed = providerLimitUtils.parseQuotaData("grok-cli", { + quotas: { weekly: { used: 0, total: 100, remainingPercentage: 100 } }, + bankedResetCredits: 2, + }); + assert.equal(providerLimitUtils.computeCanRedeemResetCredit("grok-cli", parsed), true); + assert.equal(providerLimitUtils.computeCanRedeemResetCredit("codex", parsed), true); + assert.equal(providerLimitUtils.computeCanRedeemResetCredit("claude", parsed), false); +});