feat(providers): wire CrofAI /usage_api/ into quota preflight, monitor & Limits page (#1606)

Integrated into release/v3.7.0
This commit is contained in:
Jean Brito
2026-04-25 23:51:18 -03:00
committed by GitHub
parent df76aaef96
commit 13495d4d13
7 changed files with 512 additions and 1 deletions

View File

@@ -0,0 +1,182 @@
/**
* crofUsageFetcher.ts — CrofAI Usage Fetcher
*
* Implements QuotaFetcher for the crof provider (quotaPreflight.ts + quotaMonitor.ts).
*
* CrofAI exposes `GET https://crof.ai/usage_api/` (Bearer auth) returning:
*
* { "usable_requests": 450 | null, "credits": 12.3456 }
*
* - `usable_requests`: requests left today on a subscription plan; `null` if
* the account is on pay-as-you-go (credits only).
* - `credits`: USD-denominated credit balance.
*
* Mapping to QuotaInfo:
* - Subscription account (usable_requests is a number):
* used = 0, total = usable_requests, percentUsed = usable_requests > 0 ? 0 : 1
* We do not know the daily allotment from the API response, so we surface
* "remaining" only and only block (percentUsed = 1) when the bucket hits 0.
* - Pay-as-you-go (usable_requests null):
* percentUsed = credits > 0 ? 0 : 1
* Same blocking semantics — reserve account-switching for the credits-empty
* case rather than guessing a burn rate.
*
* Preflight is OFF by default (per quotaPreflight.isQuotaPreflightEnabled).
* Monitor display is always available once the fetcher is registered.
*
* Cache: 60s in-memory, keyed by connectionId.
*
* Registration: call registerCrofUsageFetcher() once at server startup.
*/
import { registerQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts";
import { registerMonitorFetcher } from "./quotaMonitor.ts";
const CROF_USAGE_URL = "https://crof.ai/usage_api/";
const CACHE_TTL_MS = 60_000;
/** Crof-specific quota info: surfaces both raw signals so the UI can show them. */
export interface CrofQuota extends QuotaInfo {
/** Requests left today (null when not on a subscription plan). */
usableRequests: number | null;
/** USD credit balance. */
credits: number;
}
interface CacheEntry {
quota: CrofQuota;
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" && "unref" in _cacheCleanup) {
(_cacheCleanup as { unref?: () => void }).unref?.();
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function toNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = parseFloat(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function getApiKey(connection: Record<string, unknown> | undefined): string | null {
const raw = connection?.apiKey;
if (typeof raw === "string" && raw.trim().length > 0) return raw.trim();
return null;
}
// ─── Response parser ─────────────────────────────────────────────────────────
export function parseCrofUsageResponse(data: unknown): CrofQuota | null {
const obj = toRecord(data);
if (Object.keys(obj).length === 0) return null;
const usableRequestsRaw = obj["usable_requests"];
const usableRequests =
usableRequestsRaw === null || usableRequestsRaw === undefined
? null
: toNumber(usableRequestsRaw);
const credits = toNumber(obj["credits"]) ?? 0;
// Block (percentUsed = 1) only when the active bucket hits zero. Otherwise
// surface the raw counts and leave switching decisions to the caller.
let percentUsed = 0;
if (usableRequests !== null) {
percentUsed = usableRequests > 0 ? 0 : 1;
} else {
percentUsed = credits > 0 ? 0 : 1;
}
return {
used: 0,
total: usableRequests ?? 0,
percentUsed,
resetAt: null,
usableRequests,
credits,
};
}
// ─── Core fetcher ────────────────────────────────────────────────────────────
export async function fetchCrofUsage(
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 = getApiKey(connection);
if (!apiKey) {
// No credentials available — fail open, never block requests on this.
return null;
}
try {
const response = await fetch(CROF_USAGE_URL, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
quotaCache.delete(connectionId);
}
return null;
}
const data = await response.json();
const quota = parseCrofUsageResponse(data);
if (!quota) return null;
quotaCache.set(connectionId, { quota, fetchedAt: Date.now() });
return quota;
} catch {
// Network error / timeout — fail open
return null;
}
}
/** Force-invalidate the cache for a connection (e.g., after a manual top-up). */
export function invalidateCrofUsageCache(connectionId: string): void {
quotaCache.delete(connectionId);
}
// ─── Registration ────────────────────────────────────────────────────────────
/**
* Register the CrofAI usage fetcher with both the preflight and monitor systems.
* Call this once at server startup.
*/
export function registerCrofUsageFetcher(): void {
registerQuotaFetcher("crof", fetchCrofUsage);
registerMonitorFetcher("crof", fetchCrofUsage);
}

View File

@@ -392,6 +392,111 @@ async function getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn
};
}
// CrofAI surfaces a tiny endpoint with two signals:
// GET https://crof.ai/usage_api/ → { usable_requests: number|null, credits: number }
// `usable_requests` is the daily request bucket on a subscription plan; `null`
// for pay-as-you-go. `credits` is the USD credit balance. We surface both as
// quotas so the Limits & Quotas page can render whichever the account uses.
async function getCrofUsage(apiKey: string) {
if (!apiKey) {
return { message: "CrofAI API key not available. Add a key to view usage." };
}
let response: Response;
try {
response = await fetch("https://crof.ai/usage_api/", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
Accept: "application/json",
},
});
} catch (error) {
return { message: `CrofAI connected. Unable to fetch usage: ${(error as Error).message}` };
}
const rawText = await response.text();
if (response.status === 401 || response.status === 403) {
return { message: "CrofAI connected. The API key was rejected by /usage_api/." };
}
if (!response.ok) {
return { message: `CrofAI connected. /usage_api/ returned HTTP ${response.status}.` };
}
let payload: JsonRecord = {};
if (rawText) {
try {
payload = toRecord(JSON.parse(rawText));
} catch {
return { message: "CrofAI connected. Unable to parse /usage_api/ response." };
}
}
const usableRequestsRaw = payload["usable_requests"];
const usableRequests =
usableRequestsRaw === null || usableRequestsRaw === undefined
? null
: toNumber(usableRequestsRaw, 0);
const credits = toNumber(payload["credits"], 0);
const quotas: Record<string, UsageQuota> = {};
if (usableRequests !== null) {
// CrofAI's /usage_api/ returns only the remaining count; the daily
// allotment is not exposed. CrofAI Pro plan = 1,000 requests/day per
// their pricing page, so use that as the baseline total. If the user
// is on a plan with a higher cap we widen the total to whatever they
// currently report so we never compute a negative `used`.
// Without this, total=0 makes the dashboard's percentage formula read
// 0% (interpreted as "depleted" → red) even on a fresh bucket.
const CROF_DAILY_BASELINE = 1000;
const remaining = Math.max(0, usableRequests);
const total = Math.max(CROF_DAILY_BASELINE, remaining);
const used = Math.max(0, total - remaining);
// CrofAI also does not return a reset timestamp and the docs only say
// "requests left today". The Crof.ai dashboard shows the daily bucket
// resetting at ~05:00 UTC (verified against the live countdown on
// 2026-04-25), so synthesize the next 05:00 UTC instant to match.
// Swap for a real field if Crof ever exposes one.
const now = new Date();
const RESET_HOUR_UTC = 5;
const todayResetMs = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate(),
RESET_HOUR_UTC
);
const nextResetMs =
todayResetMs > now.getTime() ? todayResetMs : todayResetMs + 24 * 60 * 60 * 1000;
const nextResetIso = new Date(nextResetMs).toISOString();
quotas["Requests Today"] = {
used,
total,
remaining,
resetAt: nextResetIso,
unlimited: false,
displayName: `Requests Today: ${remaining} left`,
};
}
// Credits are an open balance — render as unlimited so the UI shows the
// dollar value rather than a misleading 0/0 bar.
quotas["Credits"] = {
used: 0,
total: 0,
remaining: 0,
resetAt: null,
unlimited: true,
displayName: `Credits: $${credits.toFixed(4)}`,
};
return { quotas };
}
async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string, unknown>) {
const quotaUrl = getGlmQuotaUrl(providerSpecificData);
@@ -510,6 +615,8 @@ export async function getUsageForProvider(connection) {
case "minimax":
case "minimax-cn":
return await getMiniMaxUsage(apiKey, provider);
case "crof":
return await getCrofUsage(apiKey);
case "cursor":
return await getCursorUsage(accessToken);
case "bailian-coding-plan":

View File

@@ -43,7 +43,7 @@ interface ProviderConnectionLike {
backoffLevel?: number;
}
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set(["glm", "glmt", "minimax", "minimax-cn"]);
const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set(["glm", "glmt", "minimax", "minimax-cn", "crof"]);
const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70;
const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run";

View File

@@ -1743,6 +1743,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
"glmt",
"minimax",
"minimax-cn",
"crof",
];
// ── Zod validation at module load (Phase 7.2) ──

View File

@@ -70,6 +70,7 @@ import {
registerCodexQuotaFetcher,
} from "@omniroute/open-sse/services/codexQuotaFetcher.ts";
import { registerBailianCodingPlanQuotaFetcher } from "@omniroute/open-sse/services/bailianQuotaFetcher.ts";
import { registerCrofUsageFetcher } from "@omniroute/open-sse/services/crofUsageFetcher.ts";
import {
getCooldownAwareRetryDecision,
resolveCooldownAwareRetrySettings,
@@ -83,6 +84,11 @@ registerCodexQuotaFetcher();
// can proactively switch accounts before quota is exhausted.
registerBailianCodingPlanQuotaFetcher();
// Register CrofAI usage fetcher (subscription requests + credits balance).
// Surfaces usable_requests + credits in the monitor and only blocks (preflight
// opt-in) when the active bucket reaches zero.
registerCrofUsageFetcher();
function normalizeAllowedConnectionIds(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const ids = value.filter(

View File

@@ -0,0 +1,101 @@
import test from "node:test";
import assert from "node:assert/strict";
const {
parseCrofUsageResponse,
fetchCrofUsage,
invalidateCrofUsageCache,
registerCrofUsageFetcher,
} = await import("../../open-sse/services/crofUsageFetcher.ts");
test("parseCrofUsageResponse: subscription account with remaining requests", () => {
const quota = parseCrofUsageResponse({ usable_requests: 450, credits: 12.34 });
assert.ok(quota);
assert.equal(quota.usableRequests, 450);
assert.equal(quota.credits, 12.34);
assert.equal(quota.percentUsed, 0, "non-zero requests must not block");
assert.equal(quota.total, 450);
});
test("parseCrofUsageResponse: subscription account exhausted blocks (percentUsed=1)", () => {
const quota = parseCrofUsageResponse({ usable_requests: 0, credits: 0 });
assert.ok(quota);
assert.equal(quota.usableRequests, 0);
assert.equal(quota.percentUsed, 1);
});
test("parseCrofUsageResponse: pay-as-you-go account uses credits as gate", () => {
const positive = parseCrofUsageResponse({ usable_requests: null, credits: 5.5 });
assert.ok(positive);
assert.equal(positive.usableRequests, null);
assert.equal(positive.credits, 5.5);
assert.equal(positive.percentUsed, 0);
const empty = parseCrofUsageResponse({ usable_requests: null, credits: 0 });
assert.ok(empty);
assert.equal(empty.percentUsed, 1);
});
test("parseCrofUsageResponse: handles string-encoded numbers (defensive)", () => {
const quota = parseCrofUsageResponse({ usable_requests: "200", credits: "3.75" });
assert.ok(quota);
assert.equal(quota.usableRequests, 200);
assert.equal(quota.credits, 3.75);
assert.equal(quota.percentUsed, 0);
});
test("parseCrofUsageResponse: rejects non-object payloads", () => {
assert.equal(parseCrofUsageResponse(null), null);
assert.equal(parseCrofUsageResponse("hello"), null);
assert.equal(parseCrofUsageResponse([]), null);
});
test("fetchCrofUsage: returns null when the connection has no apiKey (fail-open)", async () => {
const result = await fetchCrofUsage("conn-no-key", { apiKey: "" });
assert.equal(result, null);
});
test("fetchCrofUsage: caches the parsed response and serves it from memory", async () => {
const fakeKey = `unit-${Date.now()}`;
let calls = 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
calls += 1;
return new Response(JSON.stringify({ usable_requests: 99, credits: 1.5 }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
try {
invalidateCrofUsageCache(fakeKey);
const first = await fetchCrofUsage(fakeKey, { apiKey: "test-key" });
const second = await fetchCrofUsage(fakeKey, { apiKey: "test-key" });
assert.ok(first && second);
assert.equal(first.percentUsed, 0);
assert.equal((first as { usableRequests?: number }).usableRequests, 99);
assert.equal(calls, 1, "second call must hit the cache, not the network");
} finally {
globalThis.fetch = originalFetch;
invalidateCrofUsageCache(fakeKey);
}
});
test("fetchCrofUsage: returns null on non-2xx and clears cache on auth failure", async () => {
const fakeKey = `unit-401-${Date.now()}`;
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response("nope", { status: 401 });
try {
invalidateCrofUsageCache(fakeKey);
const result = await fetchCrofUsage(fakeKey, { apiKey: "bad-key" });
assert.equal(result, null);
} finally {
globalThis.fetch = originalFetch;
invalidateCrofUsageCache(fakeKey);
}
});
test("registerCrofUsageFetcher: idempotent registration call does not throw", () => {
registerCrofUsageFetcher();
registerCrofUsageFetcher();
});

View File

@@ -0,0 +1,114 @@
import test from "node:test";
import assert from "node:assert/strict";
const usage = await import("../../open-sse/services/usage.ts");
const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
test("USAGE_SUPPORTED_PROVIDERS includes crof", () => {
assert.ok(
(USAGE_SUPPORTED_PROVIDERS as string[]).includes("crof"),
"crof must be in the usage-supported providers allowlist"
);
});
test("getUsageForProvider returns helpful message when crof has no apiKey", async () => {
const result = (await usage.getUsageForProvider({
id: "crof-no-key",
provider: "crof",
apiKey: "",
})) as { message?: string };
assert.ok(typeof result.message === "string");
assert.match(result.message!, /CrofAI/);
});
test("getUsageForProvider exposes Requests Today + Credits for subscription accounts", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ usable_requests: 450, credits: 12.3456 }), {
status: 200,
headers: { "content-type": "application/json" },
});
try {
const result = (await usage.getUsageForProvider({
id: "crof-sub",
provider: "crof",
apiKey: "test-key",
})) as {
quotas?: Record<
string,
{
used: number;
total: number;
remaining: number;
displayName?: string;
unlimited: boolean;
resetAt?: string | null;
}
>;
};
assert.ok(result.quotas, "quotas must be returned");
assert.ok(result.quotas!["Requests Today"], "Requests Today quota must be present");
assert.equal(result.quotas!["Requests Today"].remaining, 450);
assert.match(result.quotas!["Requests Today"].displayName!, /450 left/);
// Total must default to the Pro-plan baseline (1000) so the dashboard's
// percentage formula reads "remaining/total" rather than 0% (red, depleted).
assert.equal(result.quotas!["Requests Today"].total, 1000);
assert.equal(result.quotas!["Requests Today"].used, 550);
// CrofAI does not return a reset timestamp; we synthesize next UTC midnight
// so the dashboard's countdown renders. Assert it parses to a future Date
// within the next 24h without pinning the exact boundary.
const resetAt = result.quotas!["Requests Today"].resetAt;
assert.equal(typeof resetAt, "string", "Requests Today.resetAt must be an ISO string");
const resetTs = Date.parse(resetAt!);
assert.ok(Number.isFinite(resetTs), "Requests Today.resetAt must parse");
const deltaMs = resetTs - Date.now();
assert.ok(deltaMs > 0, "Requests Today.resetAt must be in the future");
assert.ok(deltaMs <= 24 * 60 * 60 * 1000, "Requests Today.resetAt must be within 24h");
assert.ok(result.quotas!["Credits"], "Credits quota must be present");
assert.equal(result.quotas!["Credits"].unlimited, true);
assert.match(result.quotas!["Credits"].displayName!, /\$12\.3456/);
assert.equal(result.quotas!["Credits"].resetAt ?? null, null, "Credits has no reset bucket");
} finally {
globalThis.fetch = originalFetch;
}
});
test("getUsageForProvider omits Requests Today on pay-as-you-go (usable_requests=null)", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(JSON.stringify({ usable_requests: null, credits: 5.5 }), {
status: 200,
headers: { "content-type": "application/json" },
});
try {
const result = (await usage.getUsageForProvider({
id: "crof-payg",
provider: "crof",
apiKey: "test-key",
})) as { quotas?: Record<string, unknown> };
assert.ok(result.quotas);
assert.equal(
result.quotas!["Requests Today"],
undefined,
"no Requests Today quota when not on a subscription plan"
);
assert.ok(result.quotas!["Credits"]);
} finally {
globalThis.fetch = originalFetch;
}
});
test("getUsageForProvider returns 401 message when crof rejects the api key", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => new Response("nope", { status: 401 });
try {
const result = (await usage.getUsageForProvider({
id: "crof-401",
provider: "crof",
apiKey: "bad-key",
})) as { message?: string };
assert.match(result.message ?? "", /rejected/);
} finally {
globalThis.fetch = originalFetch;
}
});