From 8343d54bf26c019776a697bb66f9f239d29a4fc9 Mon Sep 17 00:00:00 2001 From: "MUH. IQRAM BAHRING" Date: Thu, 6 Aug 2026 00:30:06 +0800 Subject: [PATCH] feat: add Freebuff provider and fix dompurify security vulnerabilities - Add Freebuff provider integration (executor, registry, OAuth, usage tracking) - Upgrade monaco-editor to ^0.56.0 - Add dompurify ^3.4.13 with override to fix security vulnerabilities - Add Freebuff provider icon and test files --- open-sse/executors/freebuff.js | 459 ++++++++++++++++++ open-sse/providers/registry/freebuff.js | 84 ++++ open-sse/services/usage/freebuff.js | 115 +++++ package.json | 6 +- public/providers/freebuff.png | Bin 0 -> 2647 bytes src/lib/oauth/providers/freebuff.js | 131 ++++++ tests/unit/freebuff-provider.test.js | 588 ++++++++++++++++++++++++ tests/unit/freebuff-usage.test.js | 204 ++++++++ 8 files changed, 1586 insertions(+), 1 deletion(-) create mode 100644 open-sse/executors/freebuff.js create mode 100644 open-sse/providers/registry/freebuff.js create mode 100644 open-sse/services/usage/freebuff.js create mode 100644 public/providers/freebuff.png create mode 100644 src/lib/oauth/providers/freebuff.js create mode 100644 tests/unit/freebuff-provider.test.js create mode 100644 tests/unit/freebuff-usage.test.js diff --git a/open-sse/executors/freebuff.js b/open-sse/executors/freebuff.js new file mode 100644 index 00000000..655060aa --- /dev/null +++ b/open-sse/executors/freebuff.js @@ -0,0 +1,459 @@ +import crypto from "node:crypto"; +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { dbg } from "../utils/debugLog.js"; +import { + FETCH_CONNECT_TIMEOUT_MS, + DEFAULT_RETRY_CONFIG, + resolveRetryEntry, +} from "../config/runtimeConfig.js"; + +/** + * Freebuff Executor — OpenAI-compatible chat completions on + * https://www.codebuff.com/api/v1/chat/completions (the Codebuff/Freebuff backend). + * + * Wire shape mirrors the official CLI exactly. The CLI (Vercel AI SDK with the + * codebuff openai-compatible provider) builds `providerOptions.codebuff` = + * { codebuff_metadata, provider } and the provider spreads those entries at + * the TOP LEVEL of the request body — i.e. the body is: + * { model, messages, codebuff_metadata: { run_id, client_id, cost_mode, + * freebuff_instance_id? }, provider: { allow_fallbacks } } + * NOT nested under a `codebuff` object (the backend rejects the nested shape + * with 400 "No runId found in request body"). + * + * The run_id is not a free-form uuid: the backend resolves it against its + * agent-run store and rejects unknown ids with 400 "runId Not Found". So every + * chat request first registers a run via POST /api/v1/agent-runs + * ({ action:"START", agentId, ancestorRunIds:[] }) → { runId }, and that id is + * what goes in codebuff_metadata.run_id. The free tier additionally gates on a + * session: POST /api/v1/freebuff/session with an `x-freebuff-model` header + * claims a row (bound to one model, ~1h); its instance id must ride along as + * codebuff_metadata.freebuff_instance_id. + */ +const SESSION_PATH = "/api/v1/freebuff/session"; +const RUN_PATH = "/api/v1/agent-runs"; +const SESSION_DEFAULT_TTL_MS = 60 * 60 * 1000; // active sessions live ~1h + +// Chat statuses that mean our claimed session is stale and must be re-claimed +// before retrying (mirrors the CLI's FreebuffGateErrorKind statuses). +const SESSION_STALE_CODES = new Set([428, 409, 410]); + +// 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 +// byte-exact prefix test on position 0, so we prepend the canonical opening. +// Same anti-abuse pattern as mimo-free's MIMO_SYSTEM_MARKER injection. +const FREEBUFF_SYSTEM_MARKER = "You are Buffy, the strategic coding assistant."; + +// Canonical openings accepted by the server gate (mirrors the CLI's +// FREEBUFF_ROOT_SYSTEM_PROMPT_OPENINGS). The check is a byte-exact prefix on +// the first message, so our injected marker must be one of these verbatim. +const FREEBUFF_ROOT_SYSTEM_OPENINGS = [ + "You are Buffy, the strategic coding assistant.", + "You are Buffy, the Freebuff Cloud project planner.", + "You are Buffy, a strategic assistant that orchestrates complex coding tasks through specialized sub-agents.", +]; + +// Ensure messages[0] opens with a canonical Freebuff root prompt (idempotent). +function injectFreebuffMarker(body) { + const messages = body?.messages; + if (!Array.isArray(messages) || messages.length === 0) return body; + const first = messages[0]; + if (first?.role === "system" && typeof first.content === "string") { + const trimmed = first.content.trimStart(); + if (FREEBUFF_ROOT_SYSTEM_OPENINGS.some((opening) => trimmed.startsWith(opening))) return body; // already marked + // Prepend the canonical opening to the existing system prompt so it stays + // the first thing the model reads (keep the rest of the messages intact). + return { + ...body, + messages: [{ ...first, content: `${FREEBUFF_SYSTEM_MARKER}\n\n${first.content}` }, ...messages.slice(1)], + }; + } + // No leading system message — insert one with the canonical opening. + return { ...body, messages: [{ role: "system", content: FREEBUFF_SYSTEM_MARKER }, ...messages] }; +} + +// Freebuff root agent id per model (mirrors the CLI's FREEBUFF_ROOT_AGENT_ID_BY_MODEL). +const FREE_ROOT_AGENT_BY_MODEL = { + "deepseek/deepseek-v4-flash": "base2-free-deepseek-flash", + "deepseek/deepseek-v4-pro": "base2-free-deepseek", + "mimo/mimo-v2.5": "base2-free-mimo", + "minimax/minimax-m3": "base2-free-minimax-m3", + "openai/gpt-5.6-luna": "base2-free-luna", +}; + +// Per-token+model session cache (in-memory; keyed so multi-account setups +// don't share one session row). Re-claims are driven by the cache expiring or +// by a 428 from chat — no early re-claim, so we never POST /session while our +// own row is still active (which could come back as a spurious model_locked). +const sessionCache = new Map(); // `${token}::${model}` -> { instanceId, expiresAt } +const inflight = new Map(); // dedupe concurrent claims for the same key + +function sessionOrigin() { + return new URL(PROVIDERS.freebuff.baseUrl).origin; // https://www.codebuff.com +} + +function sessionCacheKey(token, model) { + return `${token}::${model}`; +} + +function rootAgentIdForModel(model) { + return FREE_ROOT_AGENT_BY_MODEL[model] || "base2-free"; +} + +// Retry transient network errors (ECONNRESET, TLS reset, …) on the session/ +// run API calls — mirrors the CLI's fetchWithRetry. Only fetch-level throws +// are retried; HTTP error responses are returned as-is. +// +// The timeout signal is built per attempt: a single shared +// AbortSignal.timeout() would stay aborted forever after it fires, silently +// turning attempts 2..n into instant no-op rejections. +async function fetchWithNetworkRetry(url, options, proxyOptions, attempts = 3, timeoutMs = FETCH_CONNECT_TIMEOUT_MS) { + let lastError; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const opts = { ...options, signal: AbortSignal.timeout(timeoutMs) }; + return await proxyAwareFetch(url, opts, proxyOptions); + } catch (error) { + lastError = error; + if (attempt + 1 < attempts) { + await new Promise((resolve) => setTimeout(resolve, 750)); + } + } + } + throw lastError; +} + +async function requestSession(token, model, proxyOptions) { + const response = await fetchWithNetworkRetry(`${sessionOrigin()}${SESSION_PATH}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": "codebuff-cli/0.0.138", + "x-freebuff-model": model, + }, + }, 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 session request failed: ${response.status} ${JSON.stringify(data).slice(0, 200)}`); + err.status = response.status; + throw err; + } + + const status = data?.status; + if (status === "active") { + const parsedExp = Date.parse(data.expiresAt || ""); + const entry = { + instanceId: data.instanceId, + expiresAt: Number.isFinite(parsedExp) ? parsedExp : Date.now() + SESSION_DEFAULT_TTL_MS, + }; + sessionCache.set(sessionCacheKey(token, model), entry); + return { instanceId: data.instanceId, status: "active" }; + } + if (status === "none") { + // Not session-gated right now — proceed without an instance id; a 428 on + // chat tells us the admission gate actually requires a session. + return { instanceId: null, status: "none" }; + } + + const GATE_MESSAGES = { + country_blocked: "Freebuff is not available in your region (country blocked).", + banned: "Your Freebuff account has been banned.", + ip_capped: "Freebuff IP cap reached — try again later.", + rate_limited: "Freebuff session limit reached for this model — try again later.", + spend_limited: "Freebuff spend limit reached — add credits or wait for the window to reset.", + model_locked: "Freebuff session is locked to another model — end it in the CLI or wait for it to expire.", + model_unavailable: "This model is not available on Freebuff right now.", + 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); + } + throw new Error(`Freebuff session rejected (${status || response.status}): ${JSON.stringify(data).slice(0, 200)}`); +} + +async function ensureSession(token, model, proxyOptions, force = false) { + const key = sessionCacheKey(token, model); + const cached = sessionCache.get(key); + if (!force && cached && cached.expiresAt > Date.now()) { + return { instanceId: cached.instanceId, status: "active" }; + } + if (force) { + // Drop both the cached row and any in-flight claim so the fresh POST can't + // race a stale one back into the cache. + sessionCache.delete(key); + inflight.delete(key); + return requestSession(token, model, proxyOptions); + } + if (!inflight.has(key)) { + inflight.set(key, requestSession(token, model, proxyOptions).finally(() => inflight.delete(key))); + } + return inflight.get(key); +} + +// Register an agent run so the chat backend can resolve the run_id we send. +async function startRun(token, model, proxyOptions) { + const response = await fetchWithNetworkRetry(`${sessionOrigin()}${RUN_PATH}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": "codebuff-cli/0.0.138", + }, + body: JSON.stringify({ + action: "START", + agentId: rootAgentIdForModel(model), + ancestorRunIds: [], + }), + }, proxyOptions); + + const text = await response.text().catch(() => ""); + let data = {}; + try { data = JSON.parse(text); } catch { data = {}; } + + if (response.status === 401) { + const err = new Error("Freebuff run auth failed (401) — re-login in the dashboard"); + err.status = 401; + throw err; + } + if (!response.ok) { + const err = new Error(`Freebuff run start failed: ${response.status} ${text.slice(0, 200)}`); + err.status = response.status; + throw err; + } + if (!data?.runId) { + throw new Error(`Freebuff run start returned no runId: ${text.slice(0, 200)}`); + } + return data.runId; +} + +// Best-effort run completion — mirrors the CLI's finishAgentRun. Never throws. +async function finishRun(token, runId, status, proxyOptions) { + if (!runId) return; + try { + await proxyAwareFetch(`${sessionOrigin()}${RUN_PATH}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": "codebuff-cli/0.0.138", + }, + body: JSON.stringify({ action: "FINISH", runId, status }), + signal: AbortSignal.timeout(10_000), + }, proxyOptions); + } catch { + // Best-effort only — the server sweeps stale runs. + } +} + +export function resetSessionCache() { + sessionCache.clear(); + inflight.clear(); +} + +export class FreebuffExecutor extends BaseExecutor { + constructor() { + super("freebuff", PROVIDERS.freebuff); + } + + buildUrl() { + return this.config.baseUrl; + } + + transformRequest(model, body, stream, credentials) { + // Top-level wire shape — see header comment. `run_id` and + // `freebuff_instance_id` are attached by execute() (they need the async + // run/session registration), so this only sets the static parts. + body.codebuff_metadata = { + client_id: + credentials?.providerSpecificData?.fingerprintId || + `9router-${crypto.randomUUID()}`, + cost_mode: "free", + }; + body.provider = { allow_fallbacks: false }; + // Free-tier gate: first system message must open with the CLI marker. + return injectFreebuffMarker(body); + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const token = credentials?.accessToken; + if (!token) { + throw new Error("Freebuff requires a connected Freebuff login (no access token found)"); + } + + let session; + try { + session = await ensureSession(token, model, proxyOptions); + } catch (error) { + log?.error?.("AUTH", `Freebuff session failed: ${error.message}`); + throw error; + } + + const url = this.buildUrl(); + const headers = this.buildHeaders(credentials, stream); + const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; + + // Registered run whose id the backend resolves on chat. Per-request, like + // the CLI's one-run-per-prompt granularity; closure-local so concurrent + // requests never share a runId. trace_session_id mirrors the CLI's + // extraCodebuffMetadata — one per run, stable across retries. + let runId = null; + const traceSessionId = crypto.randomUUID(); + + const buildBody = () => { + const transformed = this.transformRequest(model, body, stream, credentials); + transformed.codebuff_metadata.run_id = runId; + transformed.codebuff_metadata.trace_session_id = traceSessionId; + if (session?.instanceId) { + transformed.codebuff_metadata.freebuff_instance_id = session.instanceId; + } + return transformed; + }; + + // Chat POST with connect timeout + registry 429/502/503 retry + up to 2 + // retries on fetch-level network errors (per attempt the body is rebuilt + // so each retry reuses the same registered run_id). + const doChat = async () => { + let networkAttempts = 0; + const MAX_NETWORK_ATTEMPTS = 2; + for (let attempt = 0; ; attempt++) { + const transformedBody = buildBody(); + const bodyStr = JSON.stringify(transformedBody); + dbg("FETCH", `FREEBUFF → ${url} | body=${bodyStr.length}B`); + + const connectCtrl = new AbortController(); + const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS; + const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs); + const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal; + const fetchT0 = Date.now(); + let response; + try { + response = await proxyAwareFetch(url, { method: "POST", headers, body: bodyStr, signal: mergedSignal }, proxyOptions); + const ct = response.headers?.get?.("content-type") || ""; + const cl = response.headers?.get?.("content-length") || "?"; + dbg("FETCH", `FREEBUFF ← ${response.status} | ttft=${Date.now() - fetchT0}ms | ct=${ct} | cl=${cl}`); + } catch (error) { + // A caller/stream abort (AbortError) is genuine — never retry it. A + // transient socket/TLS reset (same class as the run-registration + // failure in the field) gets a couple of quick retries so a network + // blip doesn't fail the request and lock the model for 30s. + const aborted = error?.name === "AbortError"; + if (aborted || networkAttempts >= MAX_NETWORK_ATTEMPTS) throw error; + networkAttempts += 1; + log?.debug?.("RETRY", `network error on ${url} (${error.message}), retry ${networkAttempts}/${MAX_NETWORK_ATTEMPTS}`); + await new Promise((resolve) => setTimeout(resolve, 750)); + continue; + } finally { + clearTimeout(connectTimer); + } + + const entry = resolveRetryEntry(retryConfig[response.status]); + if (entry && attempt < entry.attempts) { + log?.debug?.("RETRY", `${response.status} on ${url}, retry ${attempt + 1}/${entry.attempts} after ${entry.delayMs / 1000}s`); + await new Promise((resolve) => setTimeout(resolve, entry.delayMs)); + continue; + } + return { response, transformedBody }; + } + }; + + // The run currently in flight. Only this one is FINISH-able: after a stale + // session (428/409/410) the old run is FINISH'd "cancelled" and cleared, so + // a later failure can never double-FINISH it (the server rejects duplicate + // FINISHes for the same runId). + let activeRunId = null; + const markFinished = (status) => { + if (!activeRunId) return; + const id = activeRunId; + activeRunId = null; + finishRun(token, id, status, proxyOptions); + }; + + try { + try { + runId = await startRun(token, model, proxyOptions); + activeRunId = runId; + } catch (error) { + log?.error?.("AUTH", `Freebuff run start failed: ${error.message}`); + throw error; + } + + let { response, transformedBody } = await doChat(); + + // Session gates that mean our claimed session is stale/absent: + // 428 waiting_room_required — no session row / instance id missing + // 409 session_superseded — another instance took over the session + // 409 session_model_mismatch — session bound to a different model + // 410 session_expired — the active session's expires_at passed + // In every case: abandon the run, force a fresh session claim + a fresh + // run, then retry exactly once. + if (SESSION_STALE_CODES.has(response.status)) { + log?.debug?.("AUTH", `Freebuff ${response.status} session gate — re-claiming session`); + markFinished("cancelled"); + try { + session = await ensureSession(token, model, proxyOptions, true); + runId = await startRun(token, model, proxyOptions); + activeRunId = runId; + } catch (error) { + log?.error?.("AUTH", `Freebuff session re-claim failed: ${error.message}`); + throw error; + } + ({ response, transformedBody } = await doChat()); + + if (SESSION_STALE_CODES.has(response.status)) { + const text = await response.text().catch(() => ""); + const err = new Error( + `Freebuff session gate refused (${response.status}) — another freebuff instance may be holding the session. ${text.slice(0, 160)}`, + ); + err.status = response.status; + throw err; + } + } + + // The authToken has no refresh path — when it dies, the user re-logs in. + // Drop the cached session for this token so a re-login starts clean. + if (response.status === 401) { + sessionCache.delete(sessionCacheKey(token, model)); + const text = await response.text().catch(() => ""); + const err = new Error(`Freebuff auth failed (401) — re-login in the dashboard. ${text.slice(0, 120)}`); + err.status = 401; + throw err; + } + + // Best-effort run accounting, mirroring the CLI. + markFinished(response.ok ? "completed" : "failed"); + + return { response, url, headers, transformedBody }; + } finally { + // Never leave the current run dangling on thrown paths (network/abort/gate). + if (activeRunId) { + finishRun(token, activeRunId, "failed", proxyOptions); + } + } + } +} + +export const __test__ = { + ensureSession, + requestSession, + startRun, + resetSessionCache, + rootAgentIdForModel, + injectFreebuffMarker, + fetchWithNetworkRetry, + FREEBUFF_SYSTEM_MARKER, + SESSION_STALE_CODES, +}; + +export default FreebuffExecutor; diff --git a/open-sse/providers/registry/freebuff.js b/open-sse/providers/registry/freebuff.js new file mode 100644 index 00000000..a3431050 --- /dev/null +++ b/open-sse/providers/registry/freebuff.js @@ -0,0 +1,84 @@ +/** + * Freebuff — the free, ad-supported coding agent by Codebuff (freebuff.com). + * + * The Freebuff CLI (github.com/CodebuffAI/freebuff) is an interactive TUI that + * talks to the Codebuff/Freebuff backend. Two hosts are involved: + * - login flow (freebuff mode) runs on https://freebuff.com + * POST /api/auth/cli/code {fingerprintId} → { loginUrl, fingerprintHash, expiresAt } + * open loginUrl in browser, then GET /api/auth/cli/status until {user}. + * (The server echoes the request host into loginUrl, so calling + * freebuff.com yields freebuff.com/login?auth_code=… exactly like the + * official CLI — www.codebuff.com would yield the wrong link.) + * - LLM traffic goes to the OpenAI-compatible endpoint on + * https://www.codebuff.com/api/v1/chat/completions + * (freebuff.com does NOT serve /api/v1/* — it 404s with the SPA shell.) + * + * Both hosts share one backend: the authToken obtained via the freebuff.com + * login validates against www.codebuff.com (Bearer auth). The request body + * must carry the CLI's `codebuff` provider block + * (`codebuff_metadata.run_id/client_id/cost_mode`) — injected by + * executors/freebuff.js. cost_mode:"free" is what admits a session on the free + * (country-gated, session-limited) tier instead of billing credits. + */ +export default { + id: "freebuff", + priority: 45, + hasFree: true, + alias: "fb", + uiAlias: "fb", + display: { + name: "Freebuff", + icon: "bolt", + color: "#84CC16", + textIcon: "FB", + 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.", + }, + }, + category: "free", + authType: "oauth", + authModes: ["oauth"], + hasOAuth: true, + transport: { + baseUrl: "https://www.codebuff.com/api/v1/chat/completions", + format: "openai", + headers: { + "User-Agent": "ai-sdk/openai-compatible/1.0/codebuff", + }, + retry: { + 429: { attempts: 2, delayMs: 2000 }, + 503: { attempts: 2, delayMs: 1500 }, + }, + // Session endpoint doubles as the quota API: GET /api/v1/freebuff/session + // returns the shared daily session quota (rateLimitsByModel) without + // claiming anything — POST would burn a session, so quota reads are GET + // only (see services/usage/freebuff.js). + usage: { + url: "https://www.codebuff.com/api/v1/freebuff/session", + }, + }, + features: { + usage: true, + }, + // Mirrors the CLI's free picker (FREEBUFF_ROOT_AGENT_ID_BY_MODEL). + // mimo/mimo-v2.5-pro is intentionally absent — it is not a free-tier model + // and would bill credits or be rejected under the base2-free agent. + models: [ + { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash" }, + { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" }, + { id: "mimo/mimo-v2.5", name: "MiMo 2.5" }, + { id: "minimax/minimax-m3", name: "MiniMax M3" }, + { id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna" }, + ], + // Login-flow host — the CLI in freebuff mode logs in via freebuff.com, and + // the server builds loginUrl from the host it was called on, so the link the + // user opens must come from freebuff.com to match the official CLI. + oauth: { + baseUrl: "https://freebuff.com", + loginCodePath: "/api/auth/cli/code", + loginStatusPath: "/api/auth/cli/status", + oauthTimeoutMs: 300000, + }, +}; diff --git a/open-sse/services/usage/freebuff.js b/open-sse/services/usage/freebuff.js new file mode 100644 index 00000000..c90d6ae5 --- /dev/null +++ b/open-sse/services/usage/freebuff.js @@ -0,0 +1,115 @@ +/** + * Freebuff usage handler + * + * Freebuff has no separate billing/quota API — the daily free/premium session + * quota lives on the session endpoint itself. Reading it MUST use + * GET /api/v1/freebuff/session (the CLI's status poll): POST would CLAIM a + * session and burn 1.0 unit of the daily quota, which a quota tracker must + * never do. + * + * The GET response carries the shared session quota as `rateLimitsByModel`, + * keyed by model id, on the pre-join (`none`), `active`, and `ended` states: + * { limit, recentCount, resetAt, period: 'pacific_day'|'pacific_week', + * resetTimeZone, entitlementBreakdown? } + * `recentCount` is fractional — a long agent run can consume 1.3 units — and + * includes the active session's own 1.0-unit reservation. `limit` can be + * raised by referral/streak rewards (entitlementBreakdown.base + referral + + * streak). + */ + +import REGISTRY from "../../providers/registry/index.js"; +import { U, fetchWithTimeout } from "./shared.js"; + +// Friendly labels from the registry model list (mirrors the CLI picker). +const freebuffRegistry = REGISTRY.find((r) => r.id === "freebuff") || {}; +const MODEL_LABELS = Object.fromEntries( + (freebuffRegistry.models || []).map((m) => [m.id, m.name]), +); + +function sessionUrl() { + return U("freebuff").url; +} + +export async function getFreebuffUsage(accessToken, providerSpecificData, proxyOptions = null) { + if (!accessToken) { + return { message: "Freebuff credential not available — connect a Freebuff login first." }; + } + + try { + const response = await fetchWithTimeout( + sessionUrl(), + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "User-Agent": "codebuff-cli/0.0.138", + Accept: "application/json", + }, + }, + 15000, + proxyOptions, + ); + + if (response.status === 401) { + return { message: "Freebuff credential invalid or expired — re-login in the dashboard." }; + } + if (response.status === 403) { + // A 403 from the session endpoint is usually a server-side gate status + // (country_blocked / banned), not a credential problem — telling the + // user to re-login would be misleading (mirrors the CLI's + // callFreebuffSession 403 branch). + const body = await response.json().catch(() => ({})); + if (body?.status === "country_blocked") { + return { message: "Freebuff is not available in your region." }; + } + if (body?.status === "banned") { + return { message: "Your Freebuff account has been banned." }; + } + return { + message: `Freebuff quota access denied (403)${body?.message ? `: ${body.message}` : ""}.`, + }; + } + // 404 = no session row at all → pre-join state, no quota to report. + if (response.status === 404) { + return { plan: "Freebuff", message: "Freebuff connected. No session quota to report right now." }; + } + if (!response.ok) { + return { message: `Freebuff quota API error (${response.status}).` }; + } + + const data = await response.json().catch(() => ({})); + const rateLimits = { ...(data.rateLimitsByModel || {}) }; + // An active session carries its own `rateLimit` row — fold it in when the + // shared map omits the model (older servers). + if (data.status === "active" && data.rateLimit && !rateLimits[data.model]) { + rateLimits[data.model] = data.rateLimit; + } + + const quotas = {}; + for (const [model, rl] of Object.entries(rateLimits)) { + if (!rl || typeof rl !== "object") continue; + const used = Number(rl.recentCount); + const total = Number(rl.limit); + quotas[model] = { + used: Number.isFinite(used) ? used : 0, + total: Number.isFinite(total) ? total : 0, + resetAt: rl.resetAt || null, + unlimited: false, + // Daily/weekly Pacific session allowance replenishes at resetAt — the + // UI must say "Resets in", not "Expires in". + recurring: true, + ...(MODEL_LABELS[model] ? { displayName: MODEL_LABELS[model] } : {}), + }; + } + + 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 }; + } catch (error) { + return { message: `Freebuff usage error: ${error.message}` }; + } +} + +export default getFreebuffUsage; diff --git a/package.json b/package.json index 4374a515..8661b19b 100644 --- a/package.json +++ b/package.json @@ -26,12 +26,13 @@ "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", "confbox": "^0.2.4", + "dompurify": "^3.4.13", "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", "marked": "^18.0.1", "material-symbols": "^0.44.6", - "monaco-editor": "^0.55.1", + "monaco-editor": "^0.56.0", "next": "^16.1.6", "node-forge": "^1.3.3", "node-machine-id": "^1.1.12", @@ -58,5 +59,8 @@ "eslint-config-next": "16.1.6", "postcss": "^8.5.6", "tailwindcss": "^4" + }, + "overrides": { + "dompurify": "^3.4.13" } } diff --git a/public/providers/freebuff.png b/public/providers/freebuff.png new file mode 100644 index 0000000000000000000000000000000000000000..4dc21151e83241cb19c4316270bf89c9f6159d44 GIT binary patch literal 2647 zcmd5;`8(8I8~@H2ni<;+rLxpS*0Q9C5e8XiEJG2=lI%-%Mkq6~WE`Rm+CQB-l zh7>AfvStY-V;f6kZH9Ne&vQNRzwn;x`kc>wf6nK=uk*uwuInUOS{U=gMd1Jd@MBHR zp5ria7f>F~{!YiGokI{$J##$(s7~i)xo~rGl&i@(a{!2x;rQYJU~AVu4FF*(05E$Q z0MJv>$BQ=|0+ zkA=<8r#(FuI=LnCsaHbH_U0deZSEl8xqmey~<=|UI` zHg9K-i?xv#*9A?E>1SeUD`$POrx33aMe<`8_8_<6{nRcYfQ)b9(bXT&+hp1%IYNf$ zzu(a3QO!>&9-&e|o-X{}4bqKobu|oEQBsK4bxo5c6g?RarO&XOt-e2P!sU>~AfhhV z6DCXafIC>?=PKuvSpAp>N)tk)NIM&dP=nhXcyT-iL%VHT{$33nUqxPAjQ)`VX#8o! zEU2DZnebt!g+L14(!6hhJGY@t>5;Q6jrB<4wSy)xZJUrhHgiLaexjinM{#2I$I7y9as#wpW)tLxyA=l zP*Ct1pg6{@4=ObuS)>=B0GfFTlj--VqvIh`%IL#NY>G%!EMSnp_aJDZ6&W*hH#2ie zQhZrp9tJkcR*g>(U~gQATo|ceZWu3aDL`SYtnR?x78|CN1&q-=oP_TfHid!R+}tdK zf~vjU{r#0X^E7)pd?jn3uPlJ=)NEbPHPb?!>t!`H-GzPbamc`Xz(z-&y8p=6&i5G^ zLOJSJ?O(1^C0Wo{IO6KuK+GEDaG_eyi>t$O^78U5k-0;OsJr`IJKFxM7NppM$YMpK!)EV3 zo9d#>jGTx&;%)8ZxmY;;dW zYTOMNY>wxRQd2^d*res;h{`6`RD{eAskyc!@8dxyNA?}U99SQ&?aG4+3e?@RgsMa= zU3Q)6e$<*IEX$W_Q>$%+S8$+)ezbcXUU?#%`SlgSt;{V9N31&IxjDv|{P`2%Ji!Zx zN4xeKlf@4*B36S4Cp|h){cCIC`qo9f`;|8C6Bb7wm6pm#d>9h$&Ck0Qjs#BoS|mzo zX*zre(}~`=Jl>LF#O?5-SQHe&L=na;@-*(qnCJY|(z>R38RNF&n$gzyjT)9BDk@r5 zU46%YjAq{1*{S`#af9ko4;jDv=$I{Kqc>e{eNox2iumc%C$BZ;;C7$O#hSm;CA5rW z@dXb#Q{6rMGT>c}^Bt`qp@4ZM5TNAs)!fYNl!&sEU+=(xmgZe|35SU^HQ)XNw~p(j zd-jQ7-7SeiGsFmm-75bSy5zQyLF$4h-msD zZ8&4fe-1gC#xJH`HX(r?O|LxRb*cZm!ZH_Hyk=@8N#UMb82agfFM`X1}+@6Ra>Bv1<2GU7-)XYgO#ZbU5e z#;7bWKMGLg-BEBfLV)lH;!eW$OwVJTll6rd=}2hYq`PH6ZhO8y=kCAWXg37ie5?d@ zjHm#O>c-J-I{AGZGh7np+qoE0aZ483bo#~_7USwBj!%#(NagL-i`yjU-QK6CO;$b3H)Aow?(w z-gYlS{%mNgHRq?e<1al~l}aEAp{~$dTgmr;>hF(l)u}E($JZKDe|=!s33ZJ?61bj| z$pS-fqE3JBsSac+J-i+mV{{zrfFu^Oucr|t^5d;1I4XiU_}rFnHadD z2IizvsFCP200(@BLQgwO5rr@s;O5Q&=>Msu%s_)DqjPZ_x>P6u{`m1@xXtqsSLjh` z%^X-YUmfpeO0SIjE zN;SDh;R>Rx*==C#59~Hq?K8~D_gTur^zbZQF33i102r3!alBlkQW8wkW=6m0-4D_r zwPO@S>gf7qIF_WPU{5lJnCVxn3=S*~X~7*8(|jhB39SlfXMOhMH3tXsa0^z;kqRan za1KYB*xF-FZ95SART$_MuyO zh0df*=B%FruhXexMSsROF1@w)!2&x*`T2&|KHy28;~55t`g` { + const fingerprintId = crypto.randomUUID(); + const baseUrl = (config.baseUrl || LOGIN_HOST).replace(/\/$/, ""); + const response = await fetch( + `${baseUrl}${config.loginCodePath || "/api/auth/cli/code"}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "codebuff-cli/0.0.138", + }, + body: JSON.stringify({ fingerprintId }), + }, + ); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Freebuff login code request failed: ${error}`); + } + const data = await response.json(); + const loginUrl = typeof data.loginUrl === "string" ? data.loginUrl : ""; + const authCode = loginUrl.match(/auth_code=([^&]+)/)?.[1] || ""; + const expiresAt = Number(data.expiresAt) || 0; + // Server codes live ~1h, but the official CLI stops polling after 5 minutes + // (and OAuthModal derives its deadline from expires_in). Clamp to + // oauthTimeoutMs so the modal doesn't hammer /api/auth/cli/status for an hour. + // When the server omits expiresAt, fall back to the full oauthTimeoutMs — + // never the 60s floor, or the user gets only a minute to finish login. + const timeoutSec = Math.max(60, Math.floor((config.oauthTimeoutMs || 300000) / 1000)); + const serverMs = + Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt - Date.now() : timeoutSec * 1000; + const expiresIn = Math.max(60, Math.min(Math.floor(serverMs / 1000), timeoutSec)); + return { + // fingerprintId + fingerprintHash + expiresAt travel inside device_code; + // pollToken decodes them for /api/auth/cli/status. + device_code: JSON.stringify({ + fingerprintId: data.fingerprintId || fingerprintId, + fingerprintHash: data.fingerprintHash, + expiresAt, + }), + user_code: authCode || "", + verification_uri: loginUrl, + verification_uri_complete: loginUrl, + expires_in: expiresIn, + interval: 5, + }; + }, + pollToken: async (config, deviceCode) => { + let parsed = {}; + try { + parsed = JSON.parse(deviceCode) || {}; + } catch { + parsed = {}; + } + const { fingerprintId, fingerprintHash, expiresAt } = parsed; + if (!fingerprintId || !fingerprintHash || !expiresAt) { + return { ok: true, data: { error: "authorization_pending" } }; + } + // The status endpoint is a GET with query params; it returns 401 + // {"error":"Authentication failed"} while the device is still waiting for + // the browser sign-in, and 200 { user } once authorized. Mirror the CLI: + // keep polling on anything except a `user` payload (the modal enforces its + // own 5-minute deadline). + const baseUrl = (config.baseUrl || LOGIN_HOST).replace(/\/$/, ""); + const query = new URLSearchParams({ fingerprintId, fingerprintHash, expiresAt: String(expiresAt) }); + const response = await fetch( + `${baseUrl}${config.loginStatusPath || "/api/auth/cli/status"}?${query.toString()}`, + { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": "codebuff-cli/0.0.138", + }, + }, + ); + let data; + try { + data = await response.json(); + } catch { + data = {}; + } + + if (data?.user?.authToken) { + return { ok: true, data: { access_token: data.user.authToken, ...data.user } }; + } + return { ok: true, data: { error: "authorization_pending" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: null, + email: tokens.email || undefined, + displayName: tokens.name || undefined, + providerSpecificData: { + authMethod: "device_code", + fingerprintId: tokens.fingerprintId || null, + userId: tokens.id || null, + }, + }), +}; + +export default freebuff; diff --git a/tests/unit/freebuff-provider.test.js b/tests/unit/freebuff-provider.test.js new file mode 100644 index 00000000..a96c57f0 --- /dev/null +++ b/tests/unit/freebuff-provider.test.js @@ -0,0 +1,588 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import freebuff from "../../src/lib/oauth/providers/freebuff.js"; + +// Mock proxyAwareFetch so session/run/chat flows never hit the network. +const fetchMock = vi.fn(); +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: (...args) => fetchMock(...args), +})); + +import { FreebuffExecutor, __test__ } from "../../open-sse/executors/freebuff.js"; + +const { + ensureSession, + requestSession, + startRun, + resetSessionCache, + rootAgentIdForModel, + injectFreebuffMarker, + FREEBUFF_SYSTEM_MARKER, +} = __test__; + +const CONFIG = { + baseUrl: "https://freebuff.com", + loginCodePath: "/api/auth/cli/code", + loginStatusPath: "/api/auth/cli/status", +}; + +function jsonResponse(data, { ok = true, status = 200 } = {}) { + return { + ok, + status, + json: async () => data, + text: async () => JSON.stringify(data), + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +beforeEach(() => { + fetchMock.mockReset(); + resetSessionCache(); +}); + +describe("freebuff oauth flow", () => { + it("requestDeviceCode posts a fingerprint to the freebuff.com login host and surfaces the login URL", async () => { + const loginUrl = "https://freebuff.com/login?auth_code=AbCd-123"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + fingerprintId: "fp-1", + fingerprintHash: "hash-1", + loginUrl, + expiresAt: Date.now() + 60000, + }), + }), + ); + const out = await freebuff.requestDeviceCode(CONFIG); + expect(out.verification_uri_complete).toBe(loginUrl); + expect(out.user_code).toBe("AbCd-123"); + expect(out.interval).toBe(5); + expect(out.expires_in).toBe(60); + // The server echoes the request host into loginUrl — it must be freebuff.com, + // not www.codebuff.com, to match the official CLI's login link. + const [url] = global.fetch.mock.calls[0]; + expect(url.startsWith("https://freebuff.com/api/auth/cli/code")).toBe(true); + const payload = JSON.parse(out.device_code); + expect(payload.fingerprintId).toBe("fp-1"); + expect(payload.fingerprintHash).toBe("hash-1"); + }); + + it("requestDeviceCode falls back to oauthTimeoutMs when the server omits expiresAt", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + fingerprintId: "fp-1", + fingerprintHash: "hash-1", + loginUrl: "https://freebuff.com/login?auth_code=Ab", + // no expiresAt — must NOT collapse to the 60s floor + }), + }), + ); + const out = await freebuff.requestDeviceCode(CONFIG); + expect(out.expires_in).toBe(300); + }); + + it("requestDeviceCode leaves user_code empty when loginUrl has no auth_code", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + fingerprintId: "fp-1", + fingerprintHash: "hash-1", + loginUrl: "https://freebuff.com/login", + expiresAt: Date.now() + 60000, + }), + }), + ); + const out = await freebuff.requestDeviceCode(CONFIG); + expect(out.user_code).toBe(""); + }); + + it("requestDeviceCode clamps expires_in to oauthTimeoutMs", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + fingerprintId: "fp-1", + fingerprintHash: "hash-1", + loginUrl: "https://freebuff.com/login?auth_code=Ab", + // server-side codes live ~1h; the modal deadline must stay at 5 min + expiresAt: Date.now() + 3600000, + }), + }), + ); + const out = await freebuff.requestDeviceCode(CONFIG); + expect(out.expires_in).toBe(300); + }); + + it("pollToken keeps polling on 401 pending (GET with query params)", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: "Authentication failed" }), + }), + ); + const res = await freebuff.pollToken( + CONFIG, + JSON.stringify({ fingerprintId: "fp-1", fingerprintHash: "h", expiresAt: 123 }), + ); + expect(res.ok).toBe(true); + expect(res.data.error).toBe("authorization_pending"); + const [url, opts] = global.fetch.mock.calls[0]; + expect(url).toContain("https://freebuff.com/api/auth/cli/status?"); + expect(url).toContain("fingerprintId=fp-1"); + expect(opts.method).toBe("GET"); + }); + + it("pollToken returns the authToken on success", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + user: { id: "u1", email: "a@b.c", name: "A", authToken: "tok-123", fingerprintId: "fp-1" }, + }), + }), + ); + const res = await freebuff.pollToken( + CONFIG, + JSON.stringify({ fingerprintId: "fp-1", fingerprintHash: "h", expiresAt: 123 }), + ); + expect(res.data.access_token).toBe("tok-123"); + }); + + it("mapTokens stores accessToken + identity", () => { + const t = freebuff.mapTokens({ + access_token: "tok", + email: "a@b.c", + name: "A", + id: "u1", + fingerprintId: "fp", + }); + expect(t.accessToken).toBe("tok"); + expect(t.email).toBe("a@b.c"); + expect(t.displayName).toBe("A"); + expect(t.refreshToken).toBeNull(); + expect(t.providerSpecificData.fingerprintId).toBe("fp"); + expect(t.providerSpecificData.authMethod).toBe("device_code"); + }); +}); + +describe("freebuff executor wire shape", () => { + it("injects codebuff_metadata at TOP LEVEL (mirrors the CLI, not nested under codebuff)", () => { + const ex = new FreebuffExecutor(); + const body = { model: "deepseek/deepseek-v4-flash", messages: [{ role: "user", content: "hi" }] }; + const out = ex.transformRequest(body.model, body, true, { + providerSpecificData: { fingerprintId: "fp-1" }, + }); + // Top-level keys — the backend rejects the nested shape with + // "No runId found in request body". + expect(out.codebuff_metadata.cost_mode).toBe("free"); + expect(out.codebuff_metadata.client_id).toBe("fp-1"); + // run_id is the registered runId and is attached by execute(), not here. + expect(out.codebuff_metadata.run_id).toBeUndefined(); + expect(out.codebuff).toBeUndefined(); + expect(out.provider.allow_fallbacks).toBe(false); + // Free-tier marker is prepended so the first message opens with the CLI root prompt. + expect(out.messages[0].content).toBe(FREEBUFF_SYSTEM_MARKER); + }); + + it("buildUrl targets the Codebuff chat completions endpoint (www.codebuff.com)", () => { + const ex = new FreebuffExecutor(); + expect(ex.buildUrl()).toBe("https://www.codebuff.com/api/v1/chat/completions"); + }); +}); + +describe("freebuff session pre-flight", () => { + it("claims a session via POST /session with x-freebuff-model and caches it per token+model", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + status: "active", + instanceId: "inst-1", + model: "deepseek/deepseek-v4-flash", + expiresAt: new Date(Date.now() + 3600000).toISOString(), + }), + ); + const first = await ensureSession("tok-1", "deepseek/deepseek-v4-flash", null); + expect(first).toEqual({ instanceId: "inst-1", status: "active" }); + + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("https://www.codebuff.com/api/v1/freebuff/session"); + expect(opts.method).toBe("POST"); + expect(opts.headers["x-freebuff-model"]).toBe("deepseek/deepseek-v4-flash"); + expect(opts.headers.Authorization).toBe("Bearer tok-1"); + + // Second call for the same token+model hits the cache — no new claim. + await ensureSession("tok-1", "deepseek/deepseek-v4-flash", null); + expect(fetchMock.mock.calls.length).toBe(1); + + // Different model → separate claim. + fetchMock.mockResolvedValue( + jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }), + ); + await ensureSession("tok-1", "minimax/minimax-m3", null); + expect(fetchMock.mock.calls.length).toBe(2); + }); + + it("treats status none as no-session-needed (instanceId null)", async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: "none", accessTier: "full" })); + const res = await ensureSession("tok-1", "deepseek/deepseek-v4-flash", null); + expect(res).toEqual({ instanceId: null, status: "none" }); + }); + + it("throws a friendly error on rate_limited", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ status: "rate_limited", message: "4 of 6 sessions used today" }), + ); + await expect(ensureSession("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toThrow( + /session limit reached/i, + ); + }); + + 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( + /not available in your region/i, + ); + }); + + it("throws a 401 re-login error when the session endpoint rejects the token", async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: "unauthorized" }, { status: 401, ok: false })); + await expect(requestSession("tok-expired", "deepseek/deepseek-v4-flash", null)).rejects.toThrow(/re-login/i); + }); +}); + +describe("freebuff free-tier system marker", () => { + it("prepends the canonical marker when the first message is a system prompt", () => { + const out = injectFreebuffMarker({ + messages: [{ role: "system", content: "You are a helpful assistant." }, { role: "user", content: "hi" }], + }); + expect(out.messages[0].content).toBe(`${FREEBUFF_SYSTEM_MARKER}\n\nYou are a helpful assistant.`); + expect(out.messages[1].role).toBe("user"); + }); + + it("inserts a marker system message when the first message is not a system prompt", () => { + const out = injectFreebuffMarker({ messages: [{ role: "user", content: "hi" }] }); + expect(out.messages[0]).toEqual({ role: "system", content: FREEBUFF_SYSTEM_MARKER }); + expect(out.messages[1]).toEqual({ role: "user", content: "hi" }); + }); + + it("is idempotent when the first system message already opens with the marker", () => { + const messages = [{ role: "system", content: FREEBUFF_SYSTEM_MARKER }]; + const out = injectFreebuffMarker({ messages }); + expect(out.messages).toBe(messages); + }); + + it("inserts a marker system message when the first system content is a block array", () => { + const out = injectFreebuffMarker({ + messages: [{ role: "system", content: [{ type: "text", text: "hi" }] }, { role: "user", content: "x" }], + }); + expect(out.messages[0]).toEqual({ role: "system", content: FREEBUFF_SYSTEM_MARKER }); + expect(out.messages[1].content).toEqual([{ type: "text", text: "hi" }]); + expect(out.messages[2].content).toBe("x"); + }); +}); + +describe("freebuff run registration", () => { + it("maps freebuff models to their root free agent ids", () => { + expect(rootAgentIdForModel("deepseek/deepseek-v4-flash")).toBe("base2-free-deepseek-flash"); + expect(rootAgentIdForModel("deepseek/deepseek-v4-pro")).toBe("base2-free-deepseek"); + expect(rootAgentIdForModel("mimo/mimo-v2.5")).toBe("base2-free-mimo"); + expect(rootAgentIdForModel("minimax/minimax-m3")).toBe("base2-free-minimax-m3"); + expect(rootAgentIdForModel("openai/gpt-5.6-luna")).toBe("base2-free-luna"); + expect(rootAgentIdForModel("some/unknown-model")).toBe("base2-free"); + }); + + it("registers a run via POST /agent-runs and returns the runId", async () => { + fetchMock.mockResolvedValue(jsonResponse({ runId: "run-abc" })); + const runId = await startRun("tok-1", "deepseek/deepseek-v4-flash", null); + expect(runId).toBe("run-abc"); + + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("https://www.codebuff.com/api/v1/agent-runs"); + expect(opts.method).toBe("POST"); + expect(opts.headers.Authorization).toBe("Bearer tok-1"); + const payload = JSON.parse(opts.body); + expect(payload.action).toBe("START"); + expect(payload.agentId).toBe("base2-free-deepseek-flash"); + expect(payload.ancestorRunIds).toEqual([]); + }); + + it("throws when the run start fails", async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: "bad" }, { status: 500, ok: false })); + await expect(startRun("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toThrow(/run start failed/i); + }); + + it("retries transient network errors on run registration", async () => { + fetchMock.mockImplementation(async (url) => { + if (url.includes("/agent-runs")) { + const calls = fetchMock.mock.calls.filter(([u]) => u.includes("/agent-runs")).length; + if (calls === 1) throw new Error("fetch failed (cause: ECONNRESET)"); + return jsonResponse({ runId: "run-retried" }); + } + throw new Error("unexpected url"); + }); + const runId = await startRun("tok-1", "deepseek/deepseek-v4-flash", null); + expect(runId).toBe("run-retried"); + }); +}); + +describe("freebuff executor execute", () => { + const CHAT_URL = "https://www.codebuff.com/api/v1/chat/completions"; + const SESSION_URL = "https://www.codebuff.com/api/v1/freebuff/session"; + const RUN_URL = "https://www.codebuff.com/api/v1/agent-runs"; + const MODEL = "deepseek/deepseek-v4-flash"; + const credentials = { accessToken: "tok-1", providerSpecificData: { fingerprintId: "fp-1" } }; + + // Default happy-path backend: session active, run registered, chat 200. + const happyPath = () => { + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) { + return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + } + if (url === RUN_URL) { + return jsonResponse({ runId: "run-1" }); + } + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + }; + + it("sends the registered runId + session instance id on the chat request", async () => { + happyPath(); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + + expect(response.status).toBe(200); + const chatCall = fetchMock.mock.calls.find(([u]) => u === CHAT_URL); + expect(chatCall).toBeTruthy(); + const sent = JSON.parse(chatCall[1].body); + expect(sent.codebuff_metadata.run_id).toBe("run-1"); + expect(sent.codebuff_metadata.freebuff_instance_id).toBe("inst-1"); + expect(sent.codebuff_metadata.trace_session_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(sent.codebuff_metadata.cost_mode).toBe("free"); + expect(sent.codebuff_metadata.client_id).toBe("fp-1"); + expect(sent.codebuff).toBeUndefined(); + // Free-tier marker present at position 0 of the request body. + expect(sent.messages[0].content.startsWith("You are Buffy,")).toBe(true); + }); + + it("retries exactly once on 428 with a fresh session AND a fresh run", async () => { + let chatHits = 0; + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) { + return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + } + if (url === RUN_URL) { + return jsonResponse({ runId: "run-2" }); + } + chatHits += 1; + if (chatHits === 1) return jsonResponse({ error: "waiting_room_required" }, { status: 428, ok: false }); + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + + expect(response.status).toBe(200); + expect(chatHits).toBe(2); + // Session was claimed twice (initial + forced re-claim). + expect(fetchMock.mock.calls.filter(([u]) => u === SESSION_URL).length).toBe(2); + // Runs: START #1, FINISH(cancelled) #1 (abandoned on 428), START #2, + // FINISH(completed) #2. + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + expect(runCalls.length).toBe(4); + const runActions = runCalls.map((c) => JSON.parse(c[1].body).action); + expect(runActions.filter((a) => a === "START").length).toBe(2); + expect(runActions.filter((a) => a === "FINISH").length).toBe(2); + const finishPayload = JSON.parse(runCalls[runCalls.length - 1][1].body); + expect(finishPayload.status).toBe("completed"); + // The retried chat request carried the re-claimed session + fresh run. + const lastChat = fetchMock.mock.calls.filter(([u]) => u === CHAT_URL).pop(); + const sent = JSON.parse(lastChat[1].body); + expect(sent.codebuff_metadata.run_id).toBe("run-2"); + expect(sent.codebuff_metadata.freebuff_instance_id).toBe("inst-2"); + }); + + it("re-claims the session on 409 session_superseded and retries once", async () => { + let chatHits = 0; + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-2" }); + chatHits += 1; + if (chatHits === 1) { + return jsonResponse({ error: "session_superseded", message: "Another instance took over" }, { status: 409, ok: false }); + } + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + + expect(response.status).toBe(200); + expect(chatHits).toBe(2); + // Session re-claimed (initial + forced) and runs restarted. + expect(fetchMock.mock.calls.filter(([u]) => u === SESSION_URL).length).toBe(2); + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + expect(runCalls.filter((c) => JSON.parse(c[1].body).action === "START").length).toBe(2); + }); + + it("re-claims the session on 410 session_expired and retries once", async () => { + let chatHits = 0; + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-2" }); + chatHits += 1; + if (chatHits === 1) return jsonResponse({ error: "session_expired" }, { status: 410, ok: false }); + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + expect(response.status).toBe(200); + expect(chatHits).toBe(2); + }); + + it("throws a 401 re-login error when the chat endpoint rejects the token", async () => { + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-1" }); + return jsonResponse({ error: "unauthorized" }, { status: 401, ok: false }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + await expect( + ex.execute({ model: MODEL, body, stream: false, credentials, log: null }), + ).rejects.toThrow(/re-login/i); + }); + + it("throws when no access token is present", async () => { + const ex = new FreebuffExecutor(); + await expect( + ex.execute({ model: MODEL, body: { messages: [] }, stream: false, credentials: {}, log: null }), + ).rejects.toThrow(/no access token/i); + }); + + it("finishes the run as failed when the chat upstream errors", async () => { + // 400 (not in the 429/502/503 retry set) so the test stays fast and mirrors + // the real upstream rejection. + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-1" }); + return jsonResponse({ error: "upstream boom" }, { status: 400, ok: false }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + + expect(response.status).toBe(400); + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + expect(runCalls.length).toBe(2); // START + FINISH + const finishPayload = JSON.parse(runCalls[1][1].body); + expect(finishPayload.action).toBe("FINISH"); + expect(finishPayload.status).toBe("failed"); + }); + + it("finishes the run as failed when execute throws mid-flight", async () => { + // AbortError (caller/stream abort) is never retried, keeping this test fast. + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-1" }); + throw Object.assign(new Error("aborted"), { name: "AbortError" }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + await expect( + ex.execute({ model: MODEL, body, stream: false, credentials, log: null }), + ).rejects.toThrow(/aborted/); + + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + expect(runCalls.length).toBe(2); // START + FINISH(failed) from the finally block + const finishPayload = JSON.parse(runCalls[1][1].body); + expect(finishPayload.action).toBe("FINISH"); + expect(finishPayload.status).toBe("failed"); + }); + + it("retries the chat POST on a transient fetch-level network error", async () => { + let chatHits = 0; + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-1" }); + chatHits += 1; + if (chatHits === 1) throw new Error("fetch failed (cause: ECONNRESET)"); + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + + expect(response.status).toBe(200); + expect(chatHits).toBe(2); + // Run FINISHed exactly once (completed) — no double-FINISH from the retry. + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + expect(runCalls.length).toBe(2); // START + FINISH(completed) + expect(JSON.parse(runCalls[1][1].body).status).toBe("completed"); + }); + + it("does not double-FINISH the abandoned run when the re-claim fails", async () => { + let chatHits = 0; + let runStartCount = 0; + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) { + // First START succeeds (run-1). Every later call fails with the + // transient ECONNRESET: the fire-and-forget FINISH swallows it, and + // the re-claim START propagates after its 3 network-retry attempts. + if (runStartCount === 0) { + runStartCount += 1; + return jsonResponse({ runId: "run-1" }); + } + throw new Error("fetch failed (cause: ECONNRESET)"); + } + chatHits += 1; + if (chatHits === 1) return jsonResponse({ error: "session_superseded" }, { status: 409, ok: false }); + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + // The re-claim failure rethrows the raw upstream error (the log line above + // it carries the "session re-claim failed" context). + await expect( + ex.execute({ model: MODEL, body, stream: false, credentials, log: null }), + ).rejects.toThrow(/fetch failed \(cause: ECONNRESET\)/); + + // run-1 was FINISH'd exactly once, as "cancelled" — the failed re-claim + // must NOT trigger a second (rejected by the server) FINISH. + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + const finishes = runCalls.filter(([, o]) => JSON.parse(o.body).action === "FINISH"); + expect(finishes.length).toBe(1); + expect(JSON.parse(finishes[0][1].body).status).toBe("cancelled"); + }); +}); diff --git a/tests/unit/freebuff-usage.test.js b/tests/unit/freebuff-usage.test.js new file mode 100644 index 00000000..7f53a2e3 --- /dev/null +++ b/tests/unit/freebuff-usage.test.js @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; +import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const SESSION_URL = "https://www.codebuff.com/api/v1/freebuff/session"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const PRE_JOIN = { + status: "none", + accessTier: "limited", + rateLimitsByModel: { + "deepseek/deepseek-v4-flash": { + limit: 6, + recentCount: 4.1, + period: "pacific_day", + resetTimeZone: "America/Los_Angeles", + resetAt: "2026-08-06T07:00:00.000Z", + entitlementBreakdown: { base: 6, referral: 0, streak: 0 }, + }, + "openai/gpt-5.6-luna": { + limit: 6, + recentCount: 1, + period: "pacific_day", + resetTimeZone: "America/Los_Angeles", + resetAt: "2026-08-06T07:00:00.000Z", + }, + }, +}; + +describe("freebuff registry usage flag", () => { + it("exposes the session endpoint as transport.usage url", () => { + const cfg = PROVIDERS["freebuff"]; + expect(cfg.usage?.url).toBe(SESSION_URL); + }); + + it("is listed in USAGE_SUPPORTED_PROVIDERS (features.usage)", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("freebuff"); + }); +}); + +describe("getUsageForProvider(freebuff)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("GETs the session endpoint and normalizes per-model session quotas", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(PRE_JOIN)); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Freebuff (Limited)"); + expect(usage.quotas["deepseek/deepseek-v4-flash"]).toMatchObject({ + used: 4.1, + total: 6, + resetAt: "2026-08-06T07:00:00.000Z", + recurring: true, + unlimited: false, + displayName: "DeepSeek V4 Flash", + }); + expect(usage.quotas["openai/gpt-5.6-luna"]).toMatchObject({ + used: 1, + total: 6, + displayName: "GPT-5.6 Luna", + }); + + // Quota reads MUST be GET (a POST would claim a session and burn quota). + const [url, opts] = proxyAwareFetch.mock.calls[0]; + expect(url).toBe(SESSION_URL); + expect(opts.method).toBe("GET"); + expect(opts.headers.Authorization).toBe("Bearer tok-1"); + }); + + it("folds the active session's own rateLimit into the shared map", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + status: "active", + accessTier: "full", + instanceId: "inst-1", + model: "deepseek/deepseek-v4-pro", + expiresAt: new Date(Date.now() + 3600000).toISOString(), + rateLimit: { + limit: 6, + recentCount: 2.4, + period: "pacific_day", + resetAt: "2026-08-06T07:00:00.000Z", + }, + rateLimitsByModel: {}, + }), + ); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.plan).toBe("Freebuff"); + expect(usage.quotas["deepseek/deepseek-v4-pro"]).toMatchObject({ + used: 2.4, + total: 6, + displayName: "DeepSeek V4 Pro", + }); + }); + + it("surfaces a re-login message on 401", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "expired", + }); + + expect(usage.message).toMatch(/expired|re-login/i); + expect(usage.quotas).toBeUndefined(); + }); + + it("surfaces a region message on 403 country_blocked (not a re-login hint)", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ status: "country_blocked", countryCode: "XX" }, 403), + ); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.message).toMatch(/not available in your region/i); + }); + + it("treats 404 (no session row) as pre-join with no quota", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({}, 404)); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.message).toMatch(/no session quota/i); + }); + + it("returns a message when the session response carries no quota map", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ status: "none", accessTier: "full" })); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.plan).toBe("Freebuff"); + expect(usage.message).toMatch(/no session quota/i); + }); + + it("does not throw when the session fetch fails", async () => { + proxyAwareFetch.mockRejectedValueOnce(new Error("network down")); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.message).toMatch(/usage error/i); + }); +}); + +describe("parseQuotaData(freebuff)", () => { + it("uses displayName for the row label and keeps modelKey for ordering", () => { + const rows = parseQuotaData("freebuff", { + plan: "Freebuff (Limited)", + quotas: { + "deepseek/deepseek-v4-flash": { + used: 4.1, + total: 6, + resetAt: "2026-08-06T07:00:00.000Z", + displayName: "DeepSeek V4 Flash", + }, + }, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: "DeepSeek V4 Flash", + modelKey: "deepseek/deepseek-v4-flash", + used: 4.1, + total: 6, + }); + }); +});