feat(freebuff): enhance Freebucks handling and pricing logic across services

This commit is contained in:
MUH. IQRAM BAHRING
2026-09-07 22:26:28 +08:00
parent eedfdf982b
commit 9c17c5f96c
9 changed files with 352 additions and 19 deletions
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { formatResetTime, getRemainingPercentage } from "./utils";
import { formatFreebucksPrice, formatResetTime, getRemainingPercentage } from "./utils";
const PAGE_SIZE = 10;
@@ -168,9 +168,20 @@ export default function QuotaTable({
{/* Name */}
<div className="flex w-36 min-w-0 items-center gap-1.5">
<span className="text-[10px] shrink-0">{colors.emoji}</span>
<span className={`${nameText} font-medium text-text-primary truncate`}>
{quota.name}
</span>
<div className="min-w-0">
<div className={`${nameText} font-medium text-text-primary truncate`}>
{quota.name}
</div>
{quota.price !== undefined && (
<div
className="text-[9px] leading-tight text-text-muted truncate"
title={quota.priceNote || ""}
>
{formatFreebucksPrice(quota.price)}
{quota.priceNote ? ` · ${quota.priceNote}` : ""}
</div>
)}
</div>
</div>
{/* Progress + used/total */}
@@ -9,6 +9,7 @@ import {
parseQuotaData,
calculatePercentage,
filterQuotasByVisibility,
formatFreebucksHeader,
getHiddenQuotaRows,
getQuotaVisibilityKey,
getConnectionLabel,
@@ -1237,6 +1238,11 @@ export default function ProviderLimits() {
</div>
<div className="px-2 py-1.5">
{quota?.raw?.freebucks && !error && !isLoading && (
<div className="mb-1.5 rounded-md bg-black/[0.03] px-2 py-1.5 text-[10px] leading-relaxed text-text-muted dark:bg-white/[0.03]">
{formatFreebucksHeader(quota.raw.freebucks)}
</div>
)}
{isLoading ? (
<div className="text-center py-5 text-text-muted">
<span className="material-symbols-outlined text-[28px] animate-spin">
@@ -258,6 +258,49 @@ export function formatResetTime(date) {
}
}
/**
* Freebucks account header line — mirrors upstream freebucksHeaderLine:
* "10/25 Freebucks daily · resets in 4h 12m · 20 in wallet · $6.50 monthly usage left".
* Wallet shown only when non-empty; monthly only when the server sent it
* (older servers omit it — showing $0 would read as "nothing left").
* @param {{balance: number|null, daily: {limit:number,spent:number,remaining:number,resetAt:string|null}, wallet:{balance:number}, monthly?:{remainingUsd:number,limitUsd:number|null,resetAt:string|null}}} freebucks
* @returns {string|null}
*/
export function formatFreebucksHeader(freebucks) {
if (!freebucks?.daily) return null;
const parts = [
`${Math.max(0, Math.round(freebucks.daily.remaining))}/${Math.max(0, Math.round(freebucks.daily.limit))} Freebucks daily`,
];
const countdown = formatResetTime(freebucks.daily.resetAt);
if (countdown !== "-") parts.push(`resets in ${countdown}`);
if (Number(freebucks.wallet?.balance) > 0) {
parts.push(`${Math.max(0, Math.round(freebucks.wallet.balance))} in wallet`);
}
if (freebucks.monthly && Number.isFinite(Number(freebucks.monthly.remainingUsd))) {
parts.push(`${formatFreebucksUsd(freebucks.monthly.remainingUsd)} monthly usage left`);
}
return parts.join(" · ");
}
/**
* "$25", "$4.20", "$0" — whole dollars until the figure is small enough that
* the cents are the story. Mirrors upstream formatAllowanceUsd.
*/
export function formatFreebucksUsd(usd) {
const safe = Math.max(0, Number(usd));
if (safe >= 10) return `$${Math.round(safe)}`;
if (safe >= 1) return `$${safe.toFixed(1).replace(/\.0$/, "")}`;
return `$${safe.toFixed(2)}`;
}
/**
* "15 Freebucks/hr" — a bare number would read as dollars; the unit is the
* hour, not the message.
*/
export function formatFreebucksPrice(price) {
return `${Math.max(0, Math.round(Number(price) || 0))} Freebucks/hr`;
}
/**
* Get Tailwind color class based on percentage
* @param {number} percentage - Remaining percentage (0-100)
@@ -545,6 +588,8 @@ export function parseQuotaData(provider, data) {
case "freebuff":
// Session quotas keyed by model id — label rows with the friendly
// displayName (from the registry) and keep modelKey for ordering.
// Metered rows carry the live Freebucks price (price) + promo tagline
// (priceNote), both server-authoritative.
if (data.quotas) {
Object.entries(data.quotas).forEach(([modelKey, quota]) => {
normalizedQuotas.push({
@@ -554,6 +599,8 @@ export function parseQuotaData(provider, data) {
total: quota.total || 0,
resetAt: quota.resetAt || null,
recurring: quota.recurring !== false,
price: quota.price,
priceNote: quota.priceNote,
});
});
}
+8 -2
View File
@@ -287,9 +287,15 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
} else if (resetsAtMs && resetsAtMs > Date.now()) {
shouldFallback = true;
// Antigravity quota API provides exact per-model resetAt. Do not truncate it.
cooldownMs = resolveProviderId(provider) === "antigravity"
// Freebucks exhaustion is likewise a hard stop until the daily Pacific
// reset (up to ~24h) — skip the account for the day rather than re-poke it
// every 30 min; guard at 26h so a bad server value can't lock forever.
const providerId = resolveProviderId(provider);
cooldownMs = providerId === "antigravity"
? resetsAtMs - Date.now()
: Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
: providerId === "freebuff"
? Math.min(resetsAtMs - Date.now(), 26 * 60 * 60 * 1000)
: Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS);
newBackoffLevel = 0;
} else {
({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel));