mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(providers): wire CrofAI /usage_api/ into quota preflight, monitor & Limits page (#1606)
Integrated into release/v3.7.0
This commit is contained in:
182
open-sse/services/crofUsageFetcher.ts
Normal file
182
open-sse/services/crofUsageFetcher.ts
Normal 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);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1743,6 +1743,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"glmt",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
];
|
||||
|
||||
// ── Zod validation at module load (Phase 7.2) ──
|
||||
|
||||
@@ -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(
|
||||
|
||||
101
tests/unit/crof-usage-fetcher.test.ts
Normal file
101
tests/unit/crof-usage-fetcher.test.ts
Normal 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();
|
||||
});
|
||||
114
tests/unit/crof-usage.test.ts
Normal file
114
tests/unit/crof-usage.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user