diff --git a/open-sse/services/crofUsageFetcher.ts b/open-sse/services/crofUsageFetcher.ts new file mode 100644 index 0000000000..869aa27303 --- /dev/null +++ b/open-sse/services/crofUsageFetcher.ts @@ -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(); + +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 { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function getApiKey(connection: Record | 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 +): Promise { + 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); +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 6ddafcad4e..8805ff9947 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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 = {}; + + 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) { 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": diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index a7fe0f76a0..5ca5b3523e 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -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"; diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 4bd9bfd5d3..44b39f7031 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1743,6 +1743,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "glmt", "minimax", "minimax-cn", + "crof", ]; // ── Zod validation at module load (Phase 7.2) ── diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 8079dac461..6151a60706 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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( diff --git a/tests/unit/crof-usage-fetcher.test.ts b/tests/unit/crof-usage-fetcher.test.ts new file mode 100644 index 0000000000..2cdcb0897d --- /dev/null +++ b/tests/unit/crof-usage-fetcher.test.ts @@ -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(); +}); diff --git a/tests/unit/crof-usage.test.ts b/tests/unit/crof-usage.test.ts new file mode 100644 index 0000000000..b2bc747ff6 --- /dev/null +++ b/tests/unit/crof-usage.test.ts @@ -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 }; + 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; + } +});