From 6bdfd540ba28b189b9860834b35549c63341e038 Mon Sep 17 00:00:00 2001 From: Alan Vb <53047487+allanvb@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:25:47 +0300 Subject: [PATCH] feat(providers): live monthly credit quota for Firecrawl (#8759) * feat(providers): live monthly credit quota for Firecrawl Wire Firecrawl team credits into Provider Limits / preflight via GET /v2/team/credit-usage (Bearer API key) - firecrawlQuotaFetcher + usage/firecrawl leaf - USAGE_FETCHER_PROVIDERS + USAGE_SUPPORTED_PROVIDERS + apikey allowlist - register via quotaTrackersBatch - unit tests for fetcher + usage dispatch * chore(changelog) - add changelog on live monthly credit quota for Firecrawl * fix(providers): satisfy provider limits file-size gate --------- Co-authored-by: allanvb --- .../features/8759-firecrawl-provider-quota.md | 1 + open-sse/services/firecrawlQuotaFetcher.ts | 159 +++++++++++++++++ open-sse/services/quotaTrackersBatch.ts | 4 +- open-sse/services/usage.ts | 6 + open-sse/services/usage/firecrawl.ts | 60 +++++++ .../components/ProviderLimits/quotaParsing.ts | 6 + src/lib/usage/providerLimits.ts | 2 +- src/shared/constants/providers.ts | 2 + tests/unit/firecrawl-quota-fetcher.test.ts | 155 +++++++++++++++++ tests/unit/firecrawl-usage.test.ts | 162 ++++++++++++++++++ tests/unit/provider-limits-ui.test.ts | 24 +++ 11 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 changelog.d/features/8759-firecrawl-provider-quota.md create mode 100644 open-sse/services/firecrawlQuotaFetcher.ts create mode 100644 open-sse/services/usage/firecrawl.ts create mode 100644 tests/unit/firecrawl-quota-fetcher.test.ts create mode 100644 tests/unit/firecrawl-usage.test.ts diff --git a/changelog.d/features/8759-firecrawl-provider-quota.md b/changelog.d/features/8759-firecrawl-provider-quota.md new file mode 100644 index 0000000000..8849c3fb8f --- /dev/null +++ b/changelog.d/features/8759-firecrawl-provider-quota.md @@ -0,0 +1 @@ +- **feat(providers):** live monthly credit quota for Firecrawl (`GET /v2/team/credit-usage`) in Provider Limits / preflight ([#8759](https://github.com/diegosouzapw/OmniRoute/pull/8759)) — thanks @allanvb diff --git a/open-sse/services/firecrawlQuotaFetcher.ts b/open-sse/services/firecrawlQuotaFetcher.ts new file mode 100644 index 0000000000..cc87d0573f --- /dev/null +++ b/open-sse/services/firecrawlQuotaFetcher.ts @@ -0,0 +1,159 @@ +/** + * firecrawlQuotaFetcher.ts — Firecrawl team credit quota + * + * Live credit pool for the `firecrawl` connection (web fetch + firecrawl-search). + * + * Endpoint: + * GET https://api.firecrawl.dev/v2/team/credit-usage + * Authorization: Bearer + * + * Response: + * { + * success: true, + * data: { + * remainingCredits: number, + * planCredits: number, + * billingPeriodStart?: string, + * billingPeriodEnd?: string + * } + * } + * + * Fail-open: missing key or upstream errors return null. + * Cache: 60s in-memory TTL keyed by connectionId. + */ + +import { registerQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts"; +import { registerMonitorFetcher } from "./quotaMonitor.ts"; +import { throttleQuotaFetch } from "./quotaFetchThrottle.ts"; +import { toNumberOrNull } from "@/shared/utils/numeric"; + +const CREDIT_USAGE_URL = "https://api.firecrawl.dev/v2/team/credit-usage"; +const CACHE_TTL_MS = 60_000; +const REQUEST_TIMEOUT_MS = 8_000; + +export interface FirecrawlQuota extends QuotaInfo { + remainingCredits: number; + planCredits: number; + extraCreditsInferred: number; + overPlan: boolean; + limitReached: boolean; +} + +interface CacheEntry { + quota: FirecrawlQuota | null; + fetchedAt: number; +} + +const quotaCache = new Map(); + +const _cacheCleanup = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of quotaCache) { + if (now - entry.fetchedAt > CACHE_TTL_MS * 5) { + quotaCache.delete(key); + } + } +}, 5 * 60_000); +if (typeof _cacheCleanup === "object" && _cacheCleanup && "unref" in _cacheCleanup) { + (_cacheCleanup as { unref?: () => void }).unref?.(); +} + +function toRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +export function extractFirecrawlApiKey(connection?: Record): string | null { + if (typeof connection?.apiKey === "string" && connection.apiKey.trim()) { + return connection.apiKey.trim(); + } + const credentials = toRecord(connection?.credentials); + if (typeof credentials.apiKey === "string" && credentials.apiKey.trim()) { + return credentials.apiKey.trim(); + } + return null; +} + +export function parseFirecrawlCreditUsage(data: unknown): FirecrawlQuota | null { + const root = toRecord(data); + const payload = toRecord(root.data); + const remaining = toNumberOrNull(payload.remainingCredits ?? payload.remaining_credits); + const plan = toNumberOrNull(payload.planCredits ?? payload.plan_credits); + if (remaining === null || plan === null || plan < 0) return null; + + const planCredits = Math.max(0, plan); + const remainingCredits = Math.max(0, remaining); + const extraCreditsInferred = Math.max(0, remainingCredits - planCredits); + const overPlan = extraCreditsInferred > 0; + const used = planCredits > 0 ? Math.max(0, planCredits - remainingCredits) : 0; + const percentUsed = planCredits > 0 ? used / planCredits : remainingCredits <= 0 ? 1 : 0; + const resetRaw = payload.billingPeriodEnd ?? payload.billing_period_end; + const resetAt = typeof resetRaw === "string" && resetRaw.trim() ? resetRaw.trim() : null; + + return { + used, + total: planCredits, + percentUsed, + resetAt, + remainingCredits, + planCredits, + extraCreditsInferred, + overPlan, + limitReached: remainingCredits <= 0 || percentUsed >= 1, + }; +} + +export async function fetchFirecrawlQuota( + connectionId: string, + connection?: Record +): Promise { + const cached = quotaCache.get(connectionId); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.quota; + } + + const apiKey = extractFirecrawlApiKey(connection); + if (!apiKey) { + quotaCache.set(connectionId, { quota: null, fetchedAt: Date.now() }); + return null; + } + + try { + await throttleQuotaFetch(); + + const response = await fetch(CREDIT_USAGE_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (response.status === 401 || response.status === 403) { + quotaCache.set(connectionId, { quota: null, fetchedAt: Date.now() }); + return null; + } + if (!response.ok) { + return null; + } + + const body = await response.json(); + const quota = parseFirecrawlCreditUsage(body); + quotaCache.set(connectionId, { quota, fetchedAt: Date.now() }); + return quota; + } catch { + quotaCache.set(connectionId, { quota: null, fetchedAt: Date.now() }); + return null; + } +} + +export function invalidateFirecrawlQuotaCache(connectionId: string): void { + quotaCache.delete(connectionId); +} + +export function registerFirecrawlQuotaFetcher(): void { + registerQuotaFetcher("firecrawl", fetchFirecrawlQuota); + registerMonitorFetcher("firecrawl", fetchFirecrawlQuota); +} diff --git a/open-sse/services/quotaTrackersBatch.ts b/open-sse/services/quotaTrackersBatch.ts index ac5d682526..2131efb4bc 100644 --- a/open-sse/services/quotaTrackersBatch.ts +++ b/open-sse/services/quotaTrackersBatch.ts @@ -1,6 +1,6 @@ /** * quotaTrackersBatch.ts — startup registration for batch quota trackers - * (AgentRouter, v0-vercel, freemodel-dev, grok-cli, xai-oauth). + * (AgentRouter, v0-vercel, freemodel-dev, grok-cli, xai-oauth, firecrawl). * * Kept in a dedicated module (rather than adding more inline calls to * `src/sse/handlers/chat.ts`, which is a frozen file at its LOC baseline) so the @@ -12,6 +12,7 @@ import { registerV0QuotaFetcher } from "./v0QuotaFetcher.ts"; import { registerFreeModelQuotaFetcher } from "./freeModelQuotaFetcher.ts"; import { registerGrokCliQuotaFetcher } from "./grokCliQuotaFetcher.ts"; import { registerXaiOauthQuotaFetcher } from "./xaiOauthQuotaFetcher.ts"; +import { registerFirecrawlQuotaFetcher } from "./firecrawlQuotaFetcher.ts"; export function registerQuotaTrackersBatch(): void { registerAgentrouterQuotaFetcher(); @@ -19,6 +20,7 @@ export function registerQuotaTrackersBatch(): void { registerFreeModelQuotaFetcher(); registerGrokCliQuotaFetcher(); registerXaiOauthQuotaFetcher(); + registerFirecrawlQuotaFetcher(); } // Side-effect registration at module load, mirroring the sibling diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 46b74b92e9..0f8b73e834 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -66,6 +66,7 @@ import { getVertexUsage } from "./usage/vertex.ts"; import { getXiaomiMimoUsage } from "./usage/xiaomi-mimo.ts"; import { getXaiUsage } from "./usage/xai.ts"; import { getXaiOauthUsage } from "./usage/xaiOauth.ts"; +import { getFirecrawlUsage } from "./usage/firecrawl.ts"; type JsonRecord = Record; type UsageProviderConnection = JsonRecord & { @@ -125,6 +126,8 @@ export const USAGE_FETCHER_PROVIDERS = [ // HyperAgent billing usage (creditBlocks USD) "hyperagent", "ha", + // Firecrawl team credits (GET /v2/team/credit-usage) + "firecrawl", ] as const; export type UsageFetcherProvider = (typeof USAGE_FETCHER_PROVIDERS)[number]; @@ -220,6 +223,8 @@ export async function getUsageForProvider( case "hyperagent": case "ha": return await getHyperAgentUsage(apiKey || accessToken, providerSpecificData); + case "firecrawl": + return await getFirecrawlUsage(id || "", apiKey); default: return { message: `Usage API not implemented for ${provider}` }; } @@ -249,6 +254,7 @@ export const __testing = { getXiaomiMimoUsage, getXaiUsage, getXaiOauthUsage, + getFirecrawlUsage, getVertexUsage, getMiniMaxAuthErrorMessage, getMiniMaxErrorSummary, diff --git a/open-sse/services/usage/firecrawl.ts b/open-sse/services/usage/firecrawl.ts new file mode 100644 index 0000000000..ea2ae1618f --- /dev/null +++ b/open-sse/services/usage/firecrawl.ts @@ -0,0 +1,60 @@ +/** + * usage/firecrawl.ts — Firecrawl team credit usage for Provider Limits. + * + * GET /v2/team/credit-usage via firecrawlQuotaFetcher; shapes remaining/plan + * credits into the standard `{ plan, quotas }` response. + */ + +import { fetchFirecrawlQuota, type FirecrawlQuota } from "../firecrawlQuotaFetcher.ts"; +import { createQuotaFromUsage, parseResetTime } from "./quota.ts"; + +function createFirecrawlPlanQuota(q: FirecrawlQuota) { + if (q.overPlan) { + return { + used: 0, + total: q.planCredits, + remaining: q.remainingCredits, + remainingPercentage: q.planCredits > 0 ? (q.remainingCredits / q.planCredits) * 100 : 100, + resetAt: parseResetTime(q.resetAt), + unlimited: false, + extraCreditsInferred: q.extraCreditsInferred, + overPlan: true, + }; + } + + return { + ...createQuotaFromUsage(q.used, q.total, q.resetAt), + extraCreditsInferred: 0, + overPlan: false, + }; +} + +export async function getFirecrawlUsage(connectionId: string, apiKey?: string) { + if (!connectionId) { + return { message: "Firecrawl: connection id unavailable." }; + } + + try { + const live = await fetchFirecrawlQuota(connectionId, { apiKey }); + if (!live) { + return { message: "Firecrawl API key not available or credit usage unavailable." }; + } + + const q = live as FirecrawlQuota; + const monthly = createFirecrawlPlanQuota(q); + + return { + plan: "Firecrawl · Monthly credits", + quotas: { + monthly, + }, + remainingCredits: q.remainingCredits, + planCredits: q.planCredits, + extraCreditsInferred: q.extraCreditsInferred, + overPlan: q.overPlan, + limitReached: q.limitReached, + }; + } catch (error) { + return { message: `Firecrawl usage error: ${(error as Error).message}` }; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts index 0530404c05..ee12624a54 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -54,15 +54,21 @@ function getResetAdjustedQuota(quota: any) { function normalizeQuotaEntry(name: string, quota: any = {}, extras: any = {}) { const adjusted = getResetAdjustedQuota(quota); + const remaining = Number(quota?.remaining); return { name, used: Number.isFinite(adjusted.used) ? adjusted.used : 0, total: adjusted.total, + ...(Number.isFinite(remaining) ? { remaining } : {}), resetAt: quota?.resetAt || null, staleAfterReset: adjusted.staleAfterReset, ...(adjusted.remainingPercentage !== undefined ? { remainingPercentage: adjusted.remainingPercentage } : {}), + ...(quota?.extraCreditsInferred !== undefined + ? { extraCreditsInferred: Number(quota.extraCreditsInferred) || 0 } + : {}), + ...(quota?.overPlan !== undefined ? { overPlan: quota.overPlan === true } : {}), ...extras, }; } diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 5ca7f1af29..c3c28bc353 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -36,7 +36,6 @@ import { sanitizeUsageQuotasForProvider, } from "./providerLimits/quotaNormalize"; import { syncInChunksWithSpacing } from "./providerLimits/chunkedSpacingSync"; - type JsonRecord = Record; type SyncSource = "manual" | "scheduled"; @@ -88,6 +87,7 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([ // HyperAgent session cookie → billing/usage creditBlocks "hyperagent", "ha", + "firecrawl", ]); const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70; const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index d467e3c38f..d510fc27a6 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -452,6 +452,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [ // xAI OAuth (Grok) weekly quota (id + public alias, same pattern as ha/agy) "xai-oauth", "xao", + // Firecrawl team credits (GET /v2/team/credit-usage) + "firecrawl", ]; // ── Zod validation at module load (Phase 7.2) ── diff --git a/tests/unit/firecrawl-quota-fetcher.test.ts b/tests/unit/firecrawl-quota-fetcher.test.ts new file mode 100644 index 0000000000..3df6e224a2 --- /dev/null +++ b/tests/unit/firecrawl-quota-fetcher.test.ts @@ -0,0 +1,155 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + extractFirecrawlApiKey, + fetchFirecrawlQuota, + invalidateFirecrawlQuotaCache, + parseFirecrawlCreditUsage, + registerFirecrawlQuotaFetcher, +} from "../../open-sse/services/firecrawlQuotaFetcher.ts"; +import { preflightQuota } from "../../open-sse/services/quotaPreflight.ts"; +import { clearQuotaMonitors } from "../../open-sse/services/quotaMonitor.ts"; +import { clearSessions } from "../../open-sse/services/sessionManager.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; + clearQuotaMonitors(); + clearSessions(); +}); + +function creditUsageResponse(remaining: number, plan: number, endIso?: string) { + return new Response( + JSON.stringify({ + success: true, + data: { + remainingCredits: remaining, + planCredits: plan, + billingPeriodStart: "2026-07-01T00:00:00Z", + billingPeriodEnd: endIso || "2026-07-31T23:59:59Z", + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +test("extractFirecrawlApiKey reads root and credentials shapes", () => { + assert.equal(extractFirecrawlApiKey(undefined), null); + assert.equal(extractFirecrawlApiKey({}), null); + assert.equal(extractFirecrawlApiKey({ apiKey: " fc-key " }), "fc-key"); + assert.equal(extractFirecrawlApiKey({ credentials: { apiKey: "nested-key" } }), "nested-key"); +}); + +test("parseFirecrawlCreditUsage maps remaining/plan to percent used", () => { + const q = parseFirecrawlCreditUsage({ + success: true, + data: { + remainingCredits: 250, + planCredits: 1000, + billingPeriodEnd: "2026-08-01T00:00:00Z", + }, + }); + assert.ok(q); + assert.equal(q!.used, 750); + assert.equal(q!.total, 1000); + assert.equal(q!.percentUsed, 0.75); + assert.equal(q!.remainingCredits, 250); + assert.equal(q!.limitReached, false); + assert.equal(q!.resetAt, "2026-08-01T00:00:00Z"); +}); + +test("parseFirecrawlCreditUsage preserves the plan baseline when credits are over plan", () => { + const q = parseFirecrawlCreditUsage({ + data: { remainingCredits: 1500, planCredits: 1000 }, + }); + assert.ok(q); + assert.equal(q!.used, 0); + assert.equal(q!.total, 1000); + assert.equal(q!.percentUsed, 0); + assert.equal(q!.remainingCredits, 1500); + assert.equal(q!.extraCreditsInferred, 500); + assert.equal(q!.overPlan, true); + assert.equal(q!.limitReached, false); +}); + +test("parseFirecrawlCreditUsage marks exhausted when remaining is 0", () => { + const q = parseFirecrawlCreditUsage({ + data: { remainingCredits: 0, planCredits: 1000 }, + }); + assert.ok(q); + assert.equal(q!.percentUsed, 1); + assert.equal(q!.limitReached, true); +}); + +test("fetchFirecrawlQuota returns null when no API key", async () => { + const connectionId = `fc-missing-${Date.now()}`; + const quota = await fetchFirecrawlQuota(connectionId, {}); + assert.equal(quota, null); + invalidateFirecrawlQuotaCache(connectionId); +}); + +test("fetchFirecrawlQuota calls credit-usage with Bearer and maps credits", async () => { + const connectionId = `fc-live-${Date.now()}`; + const calls: Array<{ url: string; init?: RequestInit }> = []; + + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), init }); + return creditUsageResponse(700, 1000); + }; + + const quota = await fetchFirecrawlQuota(connectionId, { apiKey: "fc-test-key" }); + assert.ok(quota); + assert.equal(quota!.used, 300); + assert.equal(quota!.total, 1000); + assert.equal(quota!.percentUsed, 0.3); + assert.ok(quota!.resetAt?.includes("2026-07-31")); + + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "https://api.firecrawl.dev/v2/team/credit-usage"); + const headers = calls[0].init?.headers as Record; + assert.equal(headers.Authorization, "Bearer fc-test-key"); + + invalidateFirecrawlQuotaCache(connectionId); +}); + +test("fetchFirecrawlQuota fail-opens on 401", async () => { + const connectionId = `fc-401-${Date.now()}`; + globalThis.fetch = async () => new Response("Unauthorized", { status: 401 }); + const quota = await fetchFirecrawlQuota(connectionId, { apiKey: "dead" }); + assert.equal(quota, null); + invalidateFirecrawlQuotaCache(connectionId); +}); + +test("fetchFirecrawlQuota caches results for 60s", async () => { + const connectionId = `fc-cache-${Date.now()}`; + let fetchCount = 0; + globalThis.fetch = async () => { + fetchCount += 1; + return creditUsageResponse(500, 1000); + }; + + const q1 = await fetchFirecrawlQuota(connectionId, { apiKey: "tok" }); + const q2 = await fetchFirecrawlQuota(connectionId, { apiKey: "tok" }); + assert.equal(fetchCount, 1); + assert.equal(q1!.percentUsed, q2!.percentUsed); + assert.equal(q1!.percentUsed, 0.5); + + invalidateFirecrawlQuotaCache(connectionId); +}); + +test("registerFirecrawlQuotaFetcher registers firecrawl for preflight", async () => { + registerFirecrawlQuotaFetcher(); + globalThis.fetch = async () => creditUsageResponse(900, 1000); + + const connectionId = `fc-reg-${Date.now()}`; + const result = await preflightQuota("firecrawl", connectionId, { + provider: "firecrawl", + id: connectionId, + apiKey: "tok", + }); + assert.equal(result.proceed, true); + + invalidateFirecrawlQuotaCache(connectionId); +}); diff --git a/tests/unit/firecrawl-usage.test.ts b/tests/unit/firecrawl-usage.test.ts new file mode 100644 index 0000000000..b227807da3 --- /dev/null +++ b/tests/unit/firecrawl-usage.test.ts @@ -0,0 +1,162 @@ +/** + * tests/unit/firecrawl-usage.test.ts + * + * Firecrawl usage.ts dispatch + Provider Limits allowlists for team credits. + */ + +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; + +const { __testing, USAGE_FETCHER_PROVIDERS, getUsageForProvider } = + await import("../../open-sse/services/usage.ts"); +const { invalidateFirecrawlQuotaCache } = + await import("../../open-sse/services/firecrawlQuotaFetcher.ts"); +const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); +const { isSupportedUsageConnection } = await import("../../src/lib/usage/providerLimits.ts"); +const { getFirecrawlUsage } = __testing; + +const originalFetch = globalThis.fetch; + +function creditUsageResponse(remaining: number, plan: number, endIso?: string) { + return new Response( + JSON.stringify({ + success: true, + data: { + remainingCredits: remaining, + planCredits: plan, + billingPeriodEnd: endIso || "2026-07-31T23:59:59Z", + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); +} + +describe("Firecrawl usage dispatch", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + invalidateFirecrawlQuotaCache("conn-fc"); + invalidateFirecrawlQuotaCache("conn-live"); + }); + + it("registers firecrawl in USAGE_FETCHER_PROVIDERS and USAGE_SUPPORTED_PROVIDERS", () => { + assert.ok((USAGE_FETCHER_PROVIDERS as readonly string[]).includes("firecrawl")); + assert.ok((USAGE_SUPPORTED_PROVIDERS as readonly string[]).includes("firecrawl")); + }); + + it("isSupportedUsageConnection accepts firecrawl apikey connections", () => { + assert.equal( + isSupportedUsageConnection({ + id: "c1", + provider: "firecrawl", + authType: "apikey", + }), + true + ); + }); + + it("getFirecrawlUsage maps live credit-usage into monthly quota", async () => { + globalThis.fetch = async () => creditUsageResponse(250, 1000); + + const r = (await getFirecrawlUsage("conn-live", "fc-key")) as { + plan?: string; + message?: string; + quotas?: { + monthly?: { + used: number; + total: number; + remaining?: number; + resetAt: string | null; + }; + }; + remainingCredits?: number; + }; + + assert.ok(r.quotas?.monthly, `expected quotas, got: ${JSON.stringify(r)}`); + assert.match(r.plan || "", /Firecrawl/i); + assert.equal(r.quotas!.monthly!.used, 750); + assert.equal(r.quotas!.monthly!.total, 1000); + assert.equal(r.quotas!.monthly!.remaining, 250); + assert.equal(r.remainingCredits, 250); + assert.ok(r.quotas!.monthly!.resetAt?.includes("2026-07-31")); + }); + + it("getFirecrawlUsage reports over-plan remaining credits as more than 100% left", async () => { + globalThis.fetch = async () => creditUsageResponse(1500, 1000); + + const r = (await getFirecrawlUsage("conn-live", "fc-key")) as { + quotas?: { + monthly?: { + used: number; + total: number; + remaining?: number; + remainingPercentage?: number; + extraCreditsInferred?: number; + overPlan?: boolean; + }; + }; + remainingCredits?: number; + planCredits?: number; + extraCreditsInferred?: number; + }; + + assert.equal(r.quotas?.monthly?.used, 0); + assert.equal(r.quotas?.monthly?.total, 1000); + assert.equal(r.quotas?.monthly?.remaining, 1500); + assert.equal(r.quotas?.monthly?.remainingPercentage, 150); + assert.equal(r.quotas?.monthly?.overPlan, true); + assert.equal(r.quotas?.monthly?.extraCreditsInferred, 500); + assert.equal(r.extraCreditsInferred, 500); + }); + + it("getFirecrawlUsage keeps the plan baseline stable as over-plan credits are spent", async () => { + globalThis.fetch = async () => creditUsageResponse(1450, 1000); + + const r = (await getFirecrawlUsage("conn-live", "fc-key")) as { + quotas?: { + monthly?: { + used: number; + total: number; + remaining?: number; + remainingPercentage?: number; + extraCreditsInferred?: number; + }; + }; + }; + + assert.equal(r.quotas?.monthly?.used, 0); + assert.equal(r.quotas?.monthly?.total, 1000); + assert.equal(r.quotas?.monthly?.remaining, 1450); + assert.equal(r.quotas?.monthly?.remainingPercentage, 145); + assert.equal(r.quotas?.monthly?.extraCreditsInferred, 450); + }); + + it("getFirecrawlUsage returns message when live quota unavailable", async () => { + globalThis.fetch = async () => new Response("Unauthorized", { status: 401 }); + const r = (await getFirecrawlUsage("conn-fc", "dead")) as { + message?: string; + quotas?: unknown; + }; + assert.ok(r.message && !r.quotas); + }); + + it("getFirecrawlUsage returns message when connection id missing", async () => { + const r = (await getFirecrawlUsage("", "key")) as { message?: string; quotas?: unknown }; + assert.ok(r.message && !r.quotas); + }); + + it("getUsageForProvider('firecrawl', ...) delegates to getFirecrawlUsage", async () => { + globalThis.fetch = async () => creditUsageResponse(100, 500); + + const r = (await getUsageForProvider({ + id: "conn-live", + provider: "firecrawl", + apiKey: "dispatch-key", + } as Parameters[0])) as { + quotas?: { monthly?: { used: number; total: number; remaining?: number } }; + }; + + assert.equal(r.quotas?.monthly?.used, 400); + assert.equal(r.quotas?.monthly?.total, 500); + assert.equal(r.quotas?.monthly?.remaining, 100); + }); +}); diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index caf2903cf3..8c772ffbc2 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -171,6 +171,30 @@ test("percentage-only quotas hide redundant usage counts while counted quotas ke assert.equal(providerLimitUtils.shouldShowQuotaUsageCount(counted[0]), true); }); +test("Firecrawl over-plan quota displays remaining credits against the plan baseline", () => { + const parsed = providerLimitUtils.parseQuotaData("firecrawl", { + quotas: { + monthly: { + used: 0, + total: 1000, + remaining: 1450, + remainingPercentage: 145, + extraCreditsInferred: 450, + overPlan: true, + }, + }, + }); + + assert.equal(parsed.length, 1); + assert.equal(parsed[0].used, 0); + assert.equal(parsed[0].total, 1000); + assert.equal(parsed[0].remaining, 1450); + assert.equal(providerLimitUtils.getQuotaRemainingPercentage(parsed[0]), 145); + assert.equal(parsed[0].extraCreditsInferred, 450); + assert.equal(parsed[0].overPlan, true); + assert.equal(providerLimitUtils.shouldShowQuotaUsageCount(parsed[0]), true); +}); + test("Codex banked reset credits parse as an integer reset-credit counter", () => { const parsed = providerLimitUtils.parseQuotaData("codex", { quotas: {