diff --git a/open-sse/executors/freebuff.js b/open-sse/executors/freebuff.js index aa7079f3..83a16519 100644 --- a/open-sse/executors/freebuff.js +++ b/open-sse/executors/freebuff.js @@ -334,8 +334,26 @@ async function requestSession(token, model, proxyOptions) { premium_slot_taken: "Freebuff premium slot is taken — try another model.", }; if (GATE_MESSAGES[status]) { - const message = data?.message ? `${GATE_MESSAGES[status]} ${data.message}` : GATE_MESSAGES[status]; - throw new Error(message); + const err = new Error(data?.message ? `${GATE_MESSAGES[status]} ${data.message}` : GATE_MESSAGES[status]); + // Freebucks / session-allowance exhaustion is a hard stop until the daily + // Pacific reset — mark the account unavailable until then so accountFallback + // SKIPS it for the rest of the day instead of retrying every 30s and getting + // refused repeatedly. resetsAtMs is honored by markAccountUnavailable; + // freebuff bypasses the generic 30-min cap (see auth.js). + if (status === "rate_limited" || status === "spend_limited") { + const resetAtMs = Date.parse(data?.resetAt || ""); + if (Number.isFinite(resetAtMs) && resetAtMs > Date.now()) { + err.resetsAtMs = resetAtMs; + err.status = 429; + } else { + const retryAfterMs = Number(data?.retryAfterMs); + if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) { + err.resetsAtMs = Date.now() + retryAfterMs; + err.status = 429; + } + } + } + throw err; } throw new Error(`Freebuff session rejected (${status || response.status}): ${JSON.stringify(data).slice(0, 200)}`); } diff --git a/open-sse/providers/registry/freebuff.js b/open-sse/providers/registry/freebuff.js index c79e3253..21324ee6 100644 --- a/open-sse/providers/registry/freebuff.js +++ b/open-sse/providers/registry/freebuff.js @@ -34,7 +34,7 @@ export default { website: "https://freebuff.com", notice: { signupUrl: "https://freebuff.com", - text: "Free ad-supported coding agent by Codebuff. Sign in with your Freebuff/Codebuff account via browser login. Free tier is ad-supported and limited in some regions (limited mode: 6 x 1-hour sessions/day); full mode runs in select countries. ⚠️ One account has ONE active session locked to ONE model — requesting a different model while a session is active returns 'model_locked' (409); use a separate account per model, or wait for the session to expire.", + text: "Free ad-supported coding agent by Codebuff. Sign in with your Freebuff/Codebuff account via browser login. Each model is priced in Freebucks per hour of session, charged once when the session starts. Your daily Freebucks refill at midnight Pacific; the wallet keeps what you buy or earn. Free tier is ad-supported and limited in some regions (limited mode: 6 x 1-hour sessions/day); full mode runs in select countries. ⚠️ One account has ONE active session locked to ONE model — requesting a different model while a session is active returns 'model_locked' (409); use a separate account per model, or wait for the session to expire.", }, }, category: "free", @@ -63,15 +63,21 @@ export default { usage: true, }, // Mirrors the Freebuff waiting-room picker (upstream FREEBUFF_MODELS) as of - // 2026-09-05, plus the capacity-limited Fable trial. deepseek-v4-pro and - // minimax-m3 were withdrawn upstream (2026-08-26 / 2026-08-20) and ox-alpha - // (2026-08-27) + gemini-3.8-flash (2026-09-03) never stuck — none are - // claimable anymore. z-ai/glm-5.2 is a referral reward (not a free pick), - // luna-es / kimi-k3-eco are god-only rows, and the `-max` variants are - // provisioned per-account — all intentionally omitted. Fable is a - // capacity-limited WAVE trial: sessions only claim while the backend - // advertises it via limitedModelOffers on the session status (the executor - // auto-checks before claiming); the model is otherwise refused. + // 2026-09-07. NO per-model prices live here: Freebucks pricing is + // server-authoritative — the session response's `freebucks` block carries + // `prices` (model → Freebucks/hr) plus an announced `priceChanges` schedule + // (promos like Solar Pro 4's Labor Day run expire server-side; see + // services/usage/freebuff.js which folds both in, exactly like the upstream + // CLI which hardcodes no number). Plus the capacity-limited Fable trial. + // deepseek-v4-pro and minimax-m3 were withdrawn upstream (2026-08-26 / + // 2026-08-20) and ox-alpha (2026-08-27) + gemini-3.8-flash (2026-09-03) + // never stuck — none are claimable anymore. + // z-ai/glm-5.2 is a referral reward (not a free pick), luna-es / kimi-k3-eco + // are god-only rows, and the `-max` variants are provisioned per-account — + // all intentionally omitted. Fable is a capacity-limited WAVE trial: sessions + // only claim while the backend advertises it via limitedModelOffers on the + // session status (the executor auto-checks before claiming); the model is + // otherwise refused. models: [ { id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash" }, { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, diff --git a/open-sse/services/usage/freebuff.js b/open-sse/services/usage/freebuff.js index c90d6ae5..e8e5e333 100644 --- a/open-sse/services/usage/freebuff.js +++ b/open-sse/services/usage/freebuff.js @@ -15,6 +15,22 @@ * includes the active session's own 1.0-unit reservation. `limit` can be * raised by referral/streak rewards (entitlementBreakdown.base + referral + * streak). + * + * Freebucks-metered accounts (2026-09-02+) get NO pool rows: the server sends + * a `freebucks` block instead — { balance, daily:{limit,spent,remaining,resetAt}, + * wallet:{balance,...}, monthly?:{limitUsd,spentUsd,remainingUsd,resetAt}, + * planId, prices:{model→Freebucks/hr}, priceNotices?, priceChanges? }. The + * daily pool replaces rateLimitsByModel, so the tracker folds it in by + * replicating the pool snapshot under every priced model id, exactly as the + * official picker does. + * + * Pricing is SERVER-AUTHORITATIVE and nothing here hardcodes a number: each + * row carries the live `prices[model]` (plus its `priceNotices` tagline), and + * `applyFreebucksPriceChanges` folds the server's announced `priceChanges` + * schedule into the map — a promo (e.g. Solar Pro 4's Labor Day 0-Freebucks + * offer) expires and reverts on the server's timeline with no client release, + * mirroring upstream common/src/util/freebuff-price-changes.ts. A compact + * account summary (`freebucks`) rides the response for the card header. */ import REGISTRY from "../../providers/registry/index.js"; @@ -26,6 +42,28 @@ const MODEL_LABELS = Object.fromEntries( (freebuffRegistry.models || []).map((m) => [m.id, m.name]), ); +// Fold the server's announced price schedule into the live price map — +// mirrors upstream applyFreebucksPriceChanges: due changes override the price +// AND its picker tagline, and drop off the pending list. +function applyFreebucksPriceChanges(info) { + const now = Date.now(); + const due = (info?.priceChanges || []).filter((c) => Date.parse(c.at) <= now); + if (due.length === 0) return info; + const prices = { ...(info.prices || {}) }; + const priceNotices = { ...(info.priceNotices || {}) }; + for (const change of due.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))) { + if (prices[change.modelId] === undefined) continue; + prices[change.modelId] = change.price; + priceNotices[change.modelId] = change.tagline; + } + return { + ...info, + prices, + priceNotices, + priceChanges: (info.priceChanges || []).filter((c) => Date.parse(c.at) > now), + }; +} + function sessionUrl() { return U("freebuff").url; } @@ -102,11 +140,59 @@ export async function getFreebuffUsage(accessToken, providerSpecificData, proxyO }; } + // Freebucks meter: the daily Freebucks pool is the quota for metered + // accounts, replicated under each priced model like the picker's rings. + // Prices come off the wire (prices + priceChanges folded in); each row + // carries its own price/tagline, and a compact summary rides the response + // for the account header in the UI. + let freebucksSummary = null; + const rawFreebucks = data.freebucks; + if (rawFreebucks && rawFreebucks.daily && typeof rawFreebucks.daily === "object") { + const freebucks = applyFreebucksPriceChanges(rawFreebucks); + const spent = Number(freebucks.daily.spent); + const limit = Number(freebucks.daily.limit); + for (const [model, price] of Object.entries(freebucks.prices || {})) { + quotas[model] = { + used: Number.isFinite(spent) ? spent : 0, + total: Number.isFinite(limit) ? limit : 0, + resetAt: freebucks.daily.resetAt || null, + unlimited: false, + recurring: true, + price: Number.isFinite(Number(price)) ? Number(price) : undefined, + ...(freebucks.priceNotices?.[model] ? { priceNote: freebucks.priceNotices[model] } : {}), + ...(MODEL_LABELS[model] ? { displayName: MODEL_LABELS[model] } : {}), + }; + } + freebucksSummary = { + balance: Number.isFinite(Number(freebucks.balance)) ? Number(freebucks.balance) : null, + daily: { + limit: Number.isFinite(limit) ? limit : 0, + spent: Number.isFinite(spent) ? spent : 0, + remaining: Number.isFinite(Number(freebucks.daily.remaining)) ? Number(freebucks.daily.remaining) : 0, + resetAt: freebucks.daily.resetAt || null, + }, + wallet: { + balance: Number.isFinite(Number(freebucks.wallet?.balance)) ? Number(freebucks.wallet.balance) : 0, + }, + ...(freebucks.monthly && Number.isFinite(Number(freebucks.monthly.remainingUsd)) + ? { + monthly: { + remainingUsd: Number(freebucks.monthly.remainingUsd), + limitUsd: Number.isFinite(Number(freebucks.monthly.limitUsd)) ? Number(freebucks.monthly.limitUsd) : null, + resetAt: freebucks.monthly.resetAt || null, + }, + } + : {}), + }; + } + const plan = data.accessTier === "limited" ? "Freebuff (Limited)" : "Freebuff"; if (Object.keys(quotas).length === 0) { return { plan, message: "Freebuff connected. No session quota to report right now." }; } - return { plan, quotas }; + const out = { plan, quotas }; + if (freebucksSummary) out.freebucks = freebucksSummary; + return out; } catch (error) { return { message: `Freebuff usage error: ${error.message}` }; } diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 57a7a9d2..0ce41026 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -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 */}
{colors.emoji} - - {quota.name} - +
+
+ {quota.name} +
+ {quota.price !== undefined && ( +
+ {formatFreebucksPrice(quota.price)} + {quota.priceNote ? ` · ${quota.priceNote}` : ""} +
+ )} +
{/* Progress + used/total */} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 683ea53d..85fed92b 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -9,6 +9,7 @@ import { parseQuotaData, calculatePercentage, filterQuotasByVisibility, + formatFreebucksHeader, getHiddenQuotaRows, getQuotaVisibilityKey, getConnectionLabel, @@ -1237,6 +1238,11 @@ export default function ProviderLimits() {
+ {quota?.raw?.freebucks && !error && !isLoading && ( +
+ {formatFreebucksHeader(quota.raw.freebucks)} +
+ )} {isLoading ? (
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index baf34221..f8d8b760 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -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, }); }); } diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 53094973..ec28b194 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -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)); diff --git a/tests/unit/freebuff-provider.test.js b/tests/unit/freebuff-provider.test.js index 51cbf2b9..8489572f 100644 --- a/tests/unit/freebuff-provider.test.js +++ b/tests/unit/freebuff-provider.test.js @@ -294,6 +294,50 @@ describe("freebuff session pre-flight", () => { ); }); + it("rate_limited with a resetAt locks the account until the daily Pacific reset (skip the day, not retry loops)", async () => { + const resetAt = "2099-01-01T00:00:00.000Z"; + fetchMock.mockResolvedValue( + jsonResponse({ + status: "rate_limited", + resetAt, + retryAfterMs: 1234, + freebucksShortfall: { price: 15, balance: 0 }, + message: "Freebucks exhausted", + }), + ); + await expect(requestSession("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toMatchObject({ + status: 429, + resetsAtMs: Date.parse(resetAt), + }); + }); + + it("spend_limited falls back to retryAfterMs when resetAt is absent", async () => { + const retryAfterMs = 90 * 60 * 1000; + fetchMock.mockResolvedValue( + jsonResponse({ status: "spend_limited", retryAfterMs, message: "daily spend cap" }), + ); + const before = Date.now(); + try { + await requestSession("tok-1", "deepseek/deepseek-v4-flash", null); + throw new Error("should have rejected"); + } catch (error) { + expect(error.status).toBe(429); + expect(error.resetsAtMs).toBeGreaterThanOrEqual(before + retryAfterMs - 1000); + expect(error.resetsAtMs).toBeLessThanOrEqual(before + retryAfterMs + 1000); + } + }); + + it("rate_limited without any reset hint stays a plain error (transient cooldown path)", async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: "rate_limited", message: "busy" })); + try { + await requestSession("tok-1", "deepseek/deepseek-v4-flash", null); + throw new Error("should have rejected"); + } catch (error) { + expect(error.status).toBeUndefined(); + expect(error.resetsAtMs).toBeUndefined(); + } + }); + it("throws a friendly error on country_blocked", async () => { fetchMock.mockResolvedValue(jsonResponse({ status: "country_blocked" })); await expect(ensureSession("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toThrow( diff --git a/tests/unit/freebuff-usage.test.js b/tests/unit/freebuff-usage.test.js index 71ffb3db..fbf99c84 100644 --- a/tests/unit/freebuff-usage.test.js +++ b/tests/unit/freebuff-usage.test.js @@ -119,6 +119,115 @@ describe("getUsageForProvider(freebuff)", () => { }); }); + it("reports the Freebucks daily pool under each priced model for metered accounts (no rateLimitsByModel)", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + status: "none", + accessTier: "limited", + rateLimitsByModel: {}, + freebucks: { + balance: 10, + daily: { limit: 25, spent: 15, remaining: 10, resetAt: "2026-09-08T07:00:00.000Z" }, + wallet: { balance: 0, monthlyBonus: 0 }, + spend: { limitUsd: 4, resetAt: "2026-09-08T07:00:00.000Z" }, + monthly: { limitUsd: 10, spentUsd: 3.5, remainingUsd: 6.5, resetAt: "2026-10-01T07:00:00.000Z" }, + planId: null, + prices: { "deepseek/deepseek-v4-flash": 15, "z-ai/glm-5.3-flash": 5 }, + }, + }), + ); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.quotas["deepseek/deepseek-v4-flash"]).toMatchObject({ + used: 15, + total: 25, + resetAt: "2026-09-08T07:00:00.000Z", + recurring: true, + unlimited: false, + price: 15, + displayName: "DeepSeek V4 Flash", + }); + expect(usage.quotas["z-ai/glm-5.3-flash"]).toMatchObject({ + used: 15, + total: 25, + price: 5, + displayName: "GLM 5.3 Flash", + }); + // No session pools → only the freebucks-derived rows exist. + expect(Object.keys(usage.quotas)).toEqual([ + "deepseek/deepseek-v4-flash", + "z-ai/glm-5.3-flash", + ]); + // Account summary rides the response for the card header (server data, + // nothing hardcoded client-side). + expect(usage.freebucks).toEqual({ + balance: 10, + daily: { + limit: 25, + spent: 15, + remaining: 10, + resetAt: "2026-09-08T07:00:00.000Z", + }, + wallet: { balance: 0 }, + monthly: { remainingUsd: 6.5, limitUsd: 10, resetAt: "2026-10-01T07:00:00.000Z" }, + }); + }); + + it("folds the server's announced priceChanges into the live price (promos expire without a client release)", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + status: "none", + accessTier: "full", + rateLimitsByModel: {}, + freebucks: { + balance: 20, + daily: { limit: 25, spent: 5, remaining: 20, resetAt: "2026-09-08T07:00:00.000Z" }, + wallet: { balance: 0, monthlyBonus: 0 }, + planId: null, + prices: { "upstage/solar-pro4": 0 }, + priceNotices: { "upstage/solar-pro4": "0 Freebucks · Labor Day weekend (through Sep 7 PT)" }, + priceChanges: [ + { + at: "2026-01-01T00:00:00.000Z", // already due + modelId: "upstage/solar-pro4", + price: 5, + tagline: "Limited-time trial", + }, + ], + }, + }), + ); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + // The due change is folded in: price 5 + new tagline, schedule emptied. + expect(usage.quotas["upstage/solar-pro4"]).toMatchObject({ + price: 5, + priceNote: "Limited-time trial", + displayName: "Solar Pro 4", + }); + }); + + it("attaches no price to legacy session-quota rows", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(PRE_JOIN)); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.quotas["deepseek/deepseek-v4-flash"].price).toBeUndefined(); + expect(usage.freebucks).toBeUndefined(); + }); + it("surfaces a re-login message on 401", async () => { proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401));