mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
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 <allanvb@users.noreply.github.com>
This commit is contained in:
1
changelog.d/features/8759-firecrawl-provider-quota.md
Normal file
1
changelog.d/features/8759-firecrawl-provider-quota.md
Normal file
@@ -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
|
||||
159
open-sse/services/firecrawlQuotaFetcher.ts
Normal file
159
open-sse/services/firecrawlQuotaFetcher.ts
Normal file
@@ -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 <apiKey>
|
||||
*
|
||||
* 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<string, CacheEntry>();
|
||||
|
||||
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<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function extractFirecrawlApiKey(connection?: Record<string, unknown>): 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<string, unknown>
|
||||
): Promise<QuotaInfo | null> {
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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,
|
||||
|
||||
60
open-sse/services/usage/firecrawl.ts
Normal file
60
open-sse/services/usage/firecrawl.ts
Normal file
@@ -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}` };
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
sanitizeUsageQuotasForProvider,
|
||||
} from "./providerLimits/quotaNormalize";
|
||||
import { syncInChunksWithSpacing } from "./providerLimits/chunkedSpacingSync";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
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";
|
||||
|
||||
@@ -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) ──
|
||||
|
||||
155
tests/unit/firecrawl-quota-fetcher.test.ts
Normal file
155
tests/unit/firecrawl-quota-fetcher.test.ts
Normal file
@@ -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<string, string>;
|
||||
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);
|
||||
});
|
||||
162
tests/unit/firecrawl-usage.test.ts
Normal file
162
tests/unit/firecrawl-usage.test.ts
Normal file
@@ -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<typeof getUsageForProvider>[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);
|
||||
});
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user