feat(freebuff): update model catalog + fable offer claims

This commit is contained in:
MUH. IQRAM BAHRING
2026-09-05 23:18:42 +08:00
parent cdbdcd3448
commit 9cd61d585c
4 changed files with 225 additions and 14 deletions
+112 -2
View File
@@ -40,6 +40,18 @@ const SESSION_DEFAULT_TTL_MS = 60 * 60 * 1000; // active sessions live ~1h
// before retrying (mirrors the CLI's FreebuffGateErrorKind statuses).
const SESSION_STALE_CODES = new Set([428, 409, 410]);
// Models the backend runs as a CAPACITY-LIMITED OFFER rather than a standing
// picker row. Claude Fable 5 is not in the client catalog at all: the server
// advertises it per-session-response (`limitedModelOffers`) only while its
// shared wave pool has sessions left, and a request without a live offer is
// refused. A claim must therefore peek at the current offers first instead of
// POSTing blind (mirrors the CLI: the "Claude Fable 5 · N of M left" row only
// renders from that payload). Offer state is per-account and cached briefly —
// the pool can reopen at any time, so a closed offer must NOT set a long
// cooldown.
const OFFER_GATED_MODELS = new Set(["anthropic/claude-fable-5"]);
const OFFER_CACHE_TTL_MS = 45_000;
// The free tier rejects requests whose first system message doesn't open with
// the canonical Freebuff CLI root prompt (server gate
// requestHasFreebuffSystemMarker → 403 free_mode_cli_required). The check is a
@@ -103,12 +115,21 @@ function injectEndTurnTool(body) {
// FREEBUFF_CLI_BASE3_AGENT_ID_BY_MODEL — the CLI harness moved from base2 to
// base3, and the backend can return 404 "No endpoints found" for the old
// base2 roots during the transition).
//
// Withdrawn upstream models (deepseek-v4-pro, minimax-m3, stealth/ox-alpha,
// google/gemini-3.8-flash) are deliberately absent: no new session can be
// admitted on them, so mapping them would only hide a dead pick behind a
// wrong root. z-ai/glm-5.2 stays mapped (referral-earned accounts can still
// run it) even though it is not a standing picker row.
const FREE_ROOT_AGENT_BY_MODEL = {
"deepseek/deepseek-v4-flash": "base3-free-deepseek-flash",
"deepseek/deepseek-v4-pro": "base3-free-deepseek",
"z-ai/glm-5.2": "base3-free-glm",
"z-ai/glm-5.3-flash": "base3-free-glm-5-3-flash",
"mimo/mimo-v2.5": "base3-free-mimo",
"minimax/minimax-m3": "base3-free-minimax-m3",
"openai/gpt-5.6-luna": "base3-free-luna",
"upstage/solar-pro4": "base3-free-solar-pro4",
"meta/muse-spark-1.3-contributor": "base3-free-muse-spark-1-3",
"anthropic/claude-fable-5": "base3-free-fable",
};
// Per-token+model session cache (in-memory; keyed so multi-account setups
@@ -122,11 +143,13 @@ const fbState = (globalThis[FB_STATE_KEY] ??= {
inflight: new Map(), // dedupe concurrent claims for the same key
modelLockCooldowns: new Map(), // `${token}::${model}` -> expiresAt (ms)
poolLimitCooldowns: new Map(), // `${proxyKey}::${model}` -> expiresAt (ms)
offerCache: new Map(), // `${token}` -> { fetchedAt, offers: [] } (limited-offer rows)
});
const sessionCache = fbState.sessionCache;
const inflight = fbState.inflight;
const modelLockCooldowns = fbState.modelLockCooldowns;
const poolLimitCooldowns = fbState.poolLimitCooldowns;
const offerCache = fbState.offerCache;
const MODEL_LOCK_COOLDOWN_MS = 10 * 60 * 1000; // session bound to another model (~1h) — re-check every 10 min
const POOL_LIMITED_COOLDOWN_MS = 5 * 60 * 1000; // IP tier refuses this model — try a different pool/relay
@@ -256,6 +279,10 @@ async function fetchWithNetworkRetry(url, options, proxyOptions, attempts = 3, t
}
async function requestSession(token, model, proxyOptions) {
// Offer-gated models (Fable) refuse claims while their wave pool is closed —
// checked before the POST so a closed offer never burns a claim attempt.
await guardOfferClaim(token, model, proxyOptions);
const response = await fetchWithNetworkRetry(`${sessionOrigin()}${SESSION_PATH}`, {
method: "POST",
headers: {
@@ -313,6 +340,78 @@ async function requestSession(token, model, proxyOptions) {
throw new Error(`Freebuff session rejected (${status || response.status}): ${JSON.stringify(data).slice(0, 200)}`);
}
// Fetch the account's current limited-model offers (GET — never claims).
// Cached per token for OFFER_CACHE_TTL_MS: the wave pool changes on server
// time, not ours, and a claim only needs to know "is it open right now".
async function fetchSessionOffers(token, proxyOptions) {
const now = Date.now();
const cached = offerCache.get(token);
if (cached && now - cached.fetchedAt < OFFER_CACHE_TTL_MS) {
return cached.offers;
}
const response = await fetchWithNetworkRetry(`${sessionOrigin()}${SESSION_PATH}`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": "codebuff-cli/0.0.138",
Accept: "application/json",
},
}, proxyOptions);
let data = {};
try { data = await response.json(); } catch { data = {}; }
if (response.status === 401) {
const err = new Error("Freebuff session auth failed (401) — re-login in the dashboard");
err.status = 401;
throw err;
}
if (!response.ok) {
const err = new Error(`Freebuff offer check failed: ${response.status} ${JSON.stringify(data).slice(0, 200)}`);
err.status = response.status;
throw err;
}
const offers = Array.isArray(data?.limitedModelOffers)
? data.limitedModelOffers.filter((o) => o && typeof o.model === "string")
: [];
offerCache.set(token, { fetchedAt: now, offers });
return offers;
}
// For an offer-gated model (Fable), refuse the claim BEFORE the POST when the
// backend is not currently advertising it. Returns the matching offer when the
// claim may proceed. Throws a plain Error (no JSON tail) so the executor's
// sessionGateFromError stays null and the cooldown maps are never touched —
// a closed offer is availability, not a lock, and the pool can reopen any time.
async function guardOfferClaim(token, model, proxyOptions) {
if (!OFFER_GATED_MODELS.has(model)) return null;
const offers = await fetchSessionOffers(token, proxyOptions);
const offer = offers.find((o) => o.model === model);
if (!offer || Number(offer.remaining) <= 0) {
const err = new Error(
`Claude Fable 5 is not being offered right now — it is a capacity-limited trial served in waves, and freebuff's shared Fable pool is currently empty. Watch the official freebuff CLI for the "Claude Fable 5 · N of M left" row, or retry later.`,
);
err.status = 409;
err.code = "offer_closed";
throw err;
}
const userLeft = Number(offer.userRemaining);
if (Number.isFinite(userLeft) && userLeft <= 0) {
const resetAt = Date.parse(offer.userResetAt || "");
const err = new Error(
`Your Freebuff account has used its Claude Fable 5 sessions for today (pool: ${offer.remaining} of ${offer.total} left)${Number.isFinite(resetAt) ? ` — next slot ${new Date(resetAt).toLocaleString()}` : ""}.`,
);
err.status = 409;
err.code = "offer_user_capped";
if (Number.isFinite(resetAt)) err.resetsAtMs = resetAt;
throw err;
}
return offer;
}
async function ensureSession(token, model, proxyOptions, force = false) {
const key = sessionCacheKey(token, model);
// Lazy prune: drop stale rows so the cache never accumulates expired entries.
@@ -394,6 +493,7 @@ async function finishRun(token, runId, status, proxyOptions) {
export function resetSessionCache() {
sessionCache.clear();
inflight.clear();
offerCache.clear();
}
// Snapshot sizes of in-memory freebuff state (for the dashboard memory panel).
@@ -403,6 +503,7 @@ export function sessionStateSize() {
inflight: inflight.size,
modelLocks: modelLockCooldowns.size,
poolLimits: poolLimitCooldowns.size,
offerCaches: offerCache.size,
};
}
@@ -417,6 +518,12 @@ export function pruneSessionState(now = Date.now()) {
removed += 1;
}
}
for (const [key, entry] of offerCache) {
if (now - entry.fetchedAt >= OFFER_CACHE_TTL_MS) {
offerCache.delete(key);
removed += 1;
}
}
for (const [key, until] of modelLockCooldowns) {
if (until <= now) {
modelLockCooldowns.delete(key);
@@ -686,6 +793,9 @@ export const __test__ = {
injectFreebuffMarker,
injectEndTurnTool,
fetchWithNetworkRetry,
fetchSessionOffers,
guardOfferClaim,
OFFER_GATED_MODELS,
FREEBUFF_SYSTEM_MARKER,
SESSION_STALE_CODES,
};