mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 15:52:52 +03:00
feat(mcp): add DeepSeek quota and limit feature
- Add deepseekQuotaFetcher.ts for DeepSeek balance API integration - Integrate with quotaPreflight and quotaMonitor systems - Support both USD and CNY currency display - Add DeepSeek to USAGE_SUPPORTED_PROVIDERS whitelist - Add DeepSeek to PROVIDER_LIMITS_APIKEY_PROVIDERS - Credits-style UI display with currency symbols and color coding - Add comprehensive unit tests Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
committed by
diegosouzapw
parent
bfb5ab0f58
commit
75008d8098
244
open-sse/services/deepseekQuotaFetcher.ts
Normal file
244
open-sse/services/deepseekQuotaFetcher.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* deepseekQuotaFetcher.ts — DeepSeek Balance Quota Fetcher
|
||||
*
|
||||
* Implements QuotaFetcher for the DeepSeek provider (quotaPreflight.ts + quotaMonitor.ts).
|
||||
*
|
||||
* DeepSeek provides a balance API:
|
||||
* GET https://api.deepseek.com/user/balance
|
||||
*
|
||||
* Response format:
|
||||
* {
|
||||
* "is_available": true,
|
||||
* "balance_infos": [
|
||||
* { "currency": "USD", "total_balance": "10.00", "granted_balance": "0.00", "topped_up_balance": "10.00" }
|
||||
* ]
|
||||
* }
|
||||
*
|
||||
* We prefer USD if available, otherwise use CNY. When balance is zero or is_available is false,
|
||||
* the account quota is considered exhausted.
|
||||
*
|
||||
* Cache: in-memory TTL (60s) to avoid hammering the balance API on every request.
|
||||
*
|
||||
* Registration: call registerDeepseekQuotaFetcher() once at server startup.
|
||||
*/
|
||||
|
||||
import { registerQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts";
|
||||
import { registerMonitorFetcher } from "./quotaMonitor.ts";
|
||||
|
||||
// DeepSeek API config
|
||||
const DEEPSEEK_CONFIG = {
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
balancePath: "/user/balance",
|
||||
};
|
||||
|
||||
// Cache TTL — short enough to be reactive, long enough to avoid rate limits
|
||||
const CACHE_TTL_MS = 60_000; // 60 seconds
|
||||
|
||||
// DeepSeek quota interface
|
||||
export interface DeepseekQuota extends QuotaInfo {
|
||||
balances: BalanceInfo[];
|
||||
isAvailable: boolean;
|
||||
limitReached: boolean;
|
||||
windowDaily?: { percentUsed: number; resetAt: string | null };
|
||||
}
|
||||
|
||||
export interface BalanceInfo {
|
||||
currency: string;
|
||||
balance: number;
|
||||
totalBalance: number;
|
||||
grantedBalance: number;
|
||||
toppedUpBalance: number;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
quota: DeepseekQuota;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
// In-memory cache: connectionId → { quota, fetchedAt }
|
||||
const quotaCache = new Map<string, CacheEntry>();
|
||||
|
||||
// Auto-cleanup stale entries every 5 minutes
|
||||
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, fallback = 0): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseFloat(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function toArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
// ─── Response Parser ─────────────────────────────────────────────────────────
|
||||
|
||||
function parseDeepseekQuotaResponse(data: unknown): DeepseekQuota | null {
|
||||
const obj = toRecord(data);
|
||||
|
||||
// Check is_available field
|
||||
const isAvailable = obj.is_available ?? obj.isAvailable;
|
||||
const isAvailableBool = isAvailable === true;
|
||||
|
||||
// Parse all balance infos
|
||||
const balanceInfos = parseAllBalanceInfos(obj);
|
||||
|
||||
if (!balanceInfos || balanceInfos.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if any balance is exhausted
|
||||
const hasPositiveBalance = balanceInfos.some((b) => b.balance > 0);
|
||||
const limitReached = !isAvailableBool || !hasPositiveBalance;
|
||||
|
||||
// percentUsed is inverse: 0% used when balance is full, 100% when exhausted
|
||||
const percentUsed = limitReached ? 1 : 0;
|
||||
|
||||
return {
|
||||
used: percentUsed * 100,
|
||||
total: 100,
|
||||
percentUsed,
|
||||
resetAt: null, // DeepSeek doesn't expose reset times
|
||||
balances: balanceInfos,
|
||||
isAvailable: isAvailableBool,
|
||||
limitReached,
|
||||
windowDaily: { percentUsed, resetAt: null },
|
||||
};
|
||||
}
|
||||
|
||||
function parseAllBalanceInfos(data: unknown): BalanceInfo[] {
|
||||
const obj = toRecord(data);
|
||||
const balanceInfos = toArray(obj.balance_infos);
|
||||
|
||||
const results: BalanceInfo[] = [];
|
||||
|
||||
for (const item of balanceInfos) {
|
||||
const record = toRecord(item);
|
||||
const currency = typeof record.currency === "string" ? record.currency.toUpperCase() : "";
|
||||
const totalBalance = toNumber(record.total_balance ?? record.totalBalance, 0);
|
||||
const grantedBalance = toNumber(record.granted_balance ?? record.grantedBalance, 0);
|
||||
const toppedUpBalance = toNumber(record.topped_up_balance ?? record.toppedUpBalance, 0);
|
||||
|
||||
if (currency) {
|
||||
results.push({
|
||||
currency,
|
||||
totalBalance,
|
||||
balance: totalBalance,
|
||||
grantedBalance,
|
||||
toppedUpBalance,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Core Fetcher ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch current quota for a DeepSeek connection.
|
||||
* Returns quota info based on balance API response.
|
||||
*
|
||||
* @param connectionId - Connection ID from the DB (used to look up credentials)
|
||||
* @param connection - Optional connection object with apiKey
|
||||
* @returns DeepseekQuota or null if fetch fails / no credentials
|
||||
*/
|
||||
export async function fetchDeepseekQuota(
|
||||
connectionId: string,
|
||||
connection?: Record<string, unknown>
|
||||
): Promise<QuotaInfo | null> {
|
||||
// Check cache first
|
||||
const cached = quotaCache.get(connectionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return cached.quota;
|
||||
}
|
||||
|
||||
// Extract API key from connection
|
||||
const apiKey =
|
||||
typeof connection?.apiKey === "string" && connection.apiKey.trim().length > 0
|
||||
? connection.apiKey
|
||||
: null;
|
||||
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = `${DEEPSEEK_CONFIG.baseUrl}${DEEPSEEK_CONFIG.balancePath}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
// 401/403: token invalid — remove from cache
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
quotaCache.delete(connectionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Other errors — fail open
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const quota = parseDeepseekQuotaResponse(data);
|
||||
|
||||
if (!quota) return null;
|
||||
|
||||
// Store in cache
|
||||
quotaCache.set(connectionId, { quota, fetchedAt: Date.now() });
|
||||
return quota;
|
||||
} catch {
|
||||
// Network error, timeout, etc. — fail open
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Invalidation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Force-invalidate the cache for a connection (e.g., after receiving quota headers).
|
||||
*/
|
||||
export function invalidateDeepseekQuotaCache(connectionId: string): void {
|
||||
quotaCache.delete(connectionId);
|
||||
}
|
||||
|
||||
// ─── Registration ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register the DeepSeek quota fetcher with the preflight and monitor systems.
|
||||
* Call this once at server startup (in chat.ts or app entry point).
|
||||
*/
|
||||
export function registerDeepseekQuotaFetcher(): void {
|
||||
registerQuotaFetcher("deepseek", fetchDeepseekQuota);
|
||||
registerMonitorFetcher("deepseek", fetchDeepseekQuota);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../config/providerHeaderProfiles.ts";
|
||||
import { safePercentage } from "@/shared/utils/formatting";
|
||||
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
|
||||
import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts";
|
||||
import {
|
||||
antigravityUserAgent,
|
||||
getAntigravityHeaders,
|
||||
@@ -106,6 +107,9 @@ type UsageQuota = {
|
||||
resetAt: string | null;
|
||||
unlimited: boolean;
|
||||
displayName?: string;
|
||||
currency?: string;
|
||||
grantedBalance?: number;
|
||||
toppedUpBalance?: number;
|
||||
};
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
@@ -645,6 +649,55 @@ async function getBailianCodingPlanUsage(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek Usage
|
||||
* Fetches balance from the DeepSeek balance API.
|
||||
* Returns all balances (USD and CNY) as "credits" for credits-style UI display.
|
||||
*/
|
||||
async function getDeepseekUsage(connectionId: string, apiKey: string) {
|
||||
try {
|
||||
const connection = { apiKey };
|
||||
const quota = await fetchDeepseekQuota(connectionId, connection);
|
||||
|
||||
if (!quota) {
|
||||
return { message: "DeepSeek API key not available. Add a key to view usage." };
|
||||
}
|
||||
|
||||
const deepseekQuota = quota as DeepseekQuota;
|
||||
const { balances, isAvailable, limitReached } = deepseekQuota;
|
||||
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
// Show all balances as credits-style entries (e.g., credits_usd, credits_cny)
|
||||
// The UI will display them as "🪙 Balance (USD) $50.00"
|
||||
for (const balanceInfo of balances) {
|
||||
const key = `credits_${balanceInfo.currency.toLowerCase()}`;
|
||||
quotas[key] = {
|
||||
used: 0,
|
||||
total: 0,
|
||||
remaining: balanceInfo.balance,
|
||||
remainingPercentage: 100,
|
||||
resetAt: null,
|
||||
unlimited: true,
|
||||
currency: balanceInfo.currency,
|
||||
grantedBalance: balanceInfo.grantedBalance,
|
||||
toppedUpBalance: balanceInfo.toppedUpBalance,
|
||||
};
|
||||
}
|
||||
|
||||
const plan = isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)";
|
||||
|
||||
return {
|
||||
plan,
|
||||
quotas,
|
||||
isAvailable,
|
||||
limitReached,
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `DeepSeek error: ${(error as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NanoGPT Usage
|
||||
* Fetches subscription-level quota from the NanoGPT API.
|
||||
@@ -768,6 +821,8 @@ export async function getUsageForProvider(connection, options: { forceRefresh?:
|
||||
return await getBailianCodingPlanUsage(id, apiKey, providerSpecificData);
|
||||
case "nanogpt":
|
||||
return await getNanoGptUsage(apiKey);
|
||||
case "deepseek":
|
||||
return await getDeepseekUsage(id, apiKey);
|
||||
default:
|
||||
return { message: `Usage API not implemented for ${provider}` };
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ const PROVIDER_CONFIG = {
|
||||
minimax: { label: "MiniMax", color: "#7C3AED" },
|
||||
"minimax-cn": { label: "MiniMax CN", color: "#DC2626" },
|
||||
nanogpt: { label: "NanoGPT", color: "#4F46E5" },
|
||||
deepseek: { label: "DeepSeek", color: "#4D6BFE" },
|
||||
};
|
||||
|
||||
const TIER_FILTERS = [
|
||||
@@ -631,7 +632,7 @@ export default function ProviderLimits() {
|
||||
}`}
|
||||
>
|
||||
{q.isCredits ? (
|
||||
/* ── AI Credits counter ── */
|
||||
/* ── AI Credits / Balance counter ── */
|
||||
<>
|
||||
<span
|
||||
className="text-[11px] font-semibold py-0.5 px-2 rounded whitespace-nowrap"
|
||||
@@ -643,9 +644,12 @@ export default function ProviderLimits() {
|
||||
className="text-[12px] font-bold tabular-nums"
|
||||
style={{ color: colors.text }}
|
||||
>
|
||||
{q.creditCount ?? q.remaining}
|
||||
{q.currency === "CNY" ? "¥" : q.currency === "USD" ? "$" : ""}
|
||||
{(q.creditCount ?? q.remaining).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted">left</span>
|
||||
</>
|
||||
) : (
|
||||
/* ── Standard quota bar ── */
|
||||
|
||||
@@ -302,6 +302,35 @@ export function parseQuotaData(provider, data) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "deepseek":
|
||||
// DeepSeek balance: credits-style display with currency
|
||||
// Handles both "credits" and "credits_usd"/"credits_cny" key formats
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([quotaKey, quota]: [string, any]) => {
|
||||
// Match credits_usd, credits_cny, or legacy credits
|
||||
if (/^credits(?:_usd|_cny)?$/.test(quotaKey)) {
|
||||
const remaining = Number(quota?.remaining ?? 0);
|
||||
const currency = quota?.currency ?? (quotaKey.includes("cny") ? "CNY" : "USD");
|
||||
normalizedQuotas.push({
|
||||
name: `${currency}`,
|
||||
used: 0,
|
||||
total: 0,
|
||||
remaining,
|
||||
resetAt: null,
|
||||
unlimited: false,
|
||||
isCredits: true,
|
||||
currency,
|
||||
creditCount: remaining,
|
||||
// Color coding based on balance amount: green >20, yellow 5-20, red <5
|
||||
remainingPercentage: remaining > 20 ? 100 : remaining > 5 ? 60 : 20,
|
||||
});
|
||||
} else {
|
||||
normalizedQuotas.push(normalizeQuotaEntry(quotaKey, quota));
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Generic fallback for unknown providers
|
||||
if (data.quotas) {
|
||||
|
||||
@@ -52,6 +52,7 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
"nanogpt",
|
||||
"deepseek",
|
||||
]);
|
||||
const DEFAULT_PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES = 70;
|
||||
const PROVIDER_LIMITS_AUTO_SYNC_SETTING_KEY = "provider_limits_auto_sync_last_run";
|
||||
|
||||
@@ -1955,6 +1955,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
"nanogpt",
|
||||
"deepseek",
|
||||
];
|
||||
|
||||
// ── Zod validation at module load (Phase 7.2) ──
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
} 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 { registerDeepseekQuotaFetcher } from "@omniroute/open-sse/services/deepseekQuotaFetcher.ts";
|
||||
import {
|
||||
getCooldownAwareRetryDecision,
|
||||
resolveCooldownAwareRetrySettings,
|
||||
@@ -91,6 +92,9 @@ registerBailianCodingPlanQuotaFetcher();
|
||||
// opt-in) when the active bucket reaches zero.
|
||||
registerCrofUsageFetcher();
|
||||
|
||||
// Register DeepSeek balance quota fetcher.
|
||||
// Hooks into quotaPreflight + quotaMonitor so combos can switch accounts before balance is exhausted.
|
||||
registerDeepseekQuotaFetcher();
|
||||
let combosCachePromise: Promise<unknown[]> | null = null;
|
||||
let combosCacheTs = 0;
|
||||
const COMBOS_CACHE_TTL_MS = 10_000;
|
||||
|
||||
329
tests/unit/deepseek-quota-fetcher.test.ts
Normal file
329
tests/unit/deepseek-quota-fetcher.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
fetchDeepseekQuota,
|
||||
invalidateDeepseekQuotaCache,
|
||||
registerDeepseekQuotaFetcher,
|
||||
} from "../../open-sse/services/deepseekQuotaFetcher.ts";
|
||||
import { preflightQuota } from "../../open-sse/services/quotaPreflight.ts";
|
||||
import {
|
||||
clearQuotaMonitors,
|
||||
getActiveMonitorCount,
|
||||
startQuotaMonitor,
|
||||
stopQuotaMonitor,
|
||||
} from "../../open-sse/services/quotaMonitor.ts";
|
||||
import { clearSessions, touchSession } from "../../open-sse/services/sessionManager.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
clearQuotaMonitors();
|
||||
clearSessions();
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota returns null when no API key exists", async () => {
|
||||
const quota = await fetchDeepseekQuota(`missing-${Date.now()}`);
|
||||
assert.equal(quota, null);
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota returns null when usage endpoint returns 404", async () => {
|
||||
const connectionId = `deepseek-404-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(null, { status: 404 });
|
||||
};
|
||||
|
||||
const quota = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
assert.equal(quota, null);
|
||||
|
||||
invalidateDeepseekQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota returns null on 401/403 (invalid token)", async () => {
|
||||
const connectionId = `deepseek-auth-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(null, { status: 401 });
|
||||
};
|
||||
|
||||
const quota = await fetchDeepseekQuota(connectionId, { apiKey: "invalid-key" });
|
||||
assert.equal(quota, null);
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota parses USD balance-based quota response", async () => {
|
||||
const connectionId = `deepseek-usd-${Date.now()}`;
|
||||
const calls = [];
|
||||
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "50.00",
|
||||
granted_balance: "5.00",
|
||||
topped_up_balance: "45.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const quota = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].init.headers.Authorization, "Bearer test-key");
|
||||
assert.equal(quota?.percentUsed, 0);
|
||||
assert.equal(quota?.limitReached, false);
|
||||
assert.equal((quota as any)?.balances?.[0]?.currency, "USD");
|
||||
assert.equal((quota as any)?.balances?.[0]?.balance, 50);
|
||||
|
||||
invalidateDeepseekQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota parses CNY balance response", async () => {
|
||||
const connectionId = `deepseek-cny-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "CNY",
|
||||
total_balance: "100.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "100.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const quota = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
|
||||
assert.equal(quota?.percentUsed, 0);
|
||||
assert.equal((quota as any)?.balances?.[0]?.currency, "CNY");
|
||||
assert.equal((quota as any)?.balances?.[0]?.balance, 100);
|
||||
|
||||
invalidateDeepseekQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota parses both USD and CNY when both available", async () => {
|
||||
const connectionId = `deepseek-multi-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "CNY",
|
||||
total_balance: "1000.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "1000.00",
|
||||
},
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "50.00",
|
||||
granted_balance: "5.00",
|
||||
topped_up_balance: "45.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const quota = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
|
||||
// Both currencies should be present in balances array
|
||||
assert.equal((quota as any)?.balances?.length, 2);
|
||||
const currencies = (quota as any)?.balances?.map((b: any) => b.currency);
|
||||
assert.ok(currencies.includes("USD"));
|
||||
assert.ok(currencies.includes("CNY"));
|
||||
|
||||
invalidateDeepseekQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota marks exhausted when is_available is false", async () => {
|
||||
const connectionId = `deepseek-exhausted-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
is_available: false,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "0.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "0.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const quota = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
|
||||
assert.equal(quota?.limitReached, true);
|
||||
assert.equal(quota?.percentUsed, 1);
|
||||
assert.equal((quota as any)?.isAvailable, false);
|
||||
|
||||
invalidateDeepseekQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota marks exhausted when balance is zero", async () => {
|
||||
const connectionId = `deepseek-zero-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "0.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "0.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const quota = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
|
||||
assert.equal(quota?.limitReached, true);
|
||||
assert.equal(quota?.percentUsed, 1);
|
||||
|
||||
invalidateDeepseekQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota caches results within TTL", async () => {
|
||||
const connectionId = `deepseek-cache-${Date.now()}`;
|
||||
const calls = [];
|
||||
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "75.00",
|
||||
granted_balance: "5.00",
|
||||
topped_up_balance: "70.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const first = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
const second = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(first, second);
|
||||
|
||||
invalidateDeepseekQuotaCache(connectionId);
|
||||
|
||||
const third = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota returns null on network error (fail-open)", async () => {
|
||||
const connectionId = `deepseek-network-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("Network error");
|
||||
};
|
||||
|
||||
const quota = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
assert.equal(quota, null);
|
||||
});
|
||||
|
||||
test("fetchDeepseekQuota returns null on timeout (fail-open)", async () => {
|
||||
const connectionId = `deepseek-timeout-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
await new Promise((_, reject) => setTimeout(reject, 100));
|
||||
throw new Error("Timeout");
|
||||
};
|
||||
|
||||
const quota = await fetchDeepseekQuota(connectionId, { apiKey: "test-key" });
|
||||
assert.equal(quota, null);
|
||||
});
|
||||
|
||||
test("registerDeepseekQuotaFetcher exposes DeepSeek quota to preflight", async () => {
|
||||
const connectionId = `deepseek-preflight-${Date.now()}`;
|
||||
|
||||
registerDeepseekQuotaFetcher();
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "100.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "100.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const preflight = await preflightQuota("deepseek", connectionId, {
|
||||
apiKey: "test-key",
|
||||
providerSpecificData: { quotaPreflightEnabled: true },
|
||||
});
|
||||
|
||||
// DeepSeek with positive balance should proceed
|
||||
assert.equal(preflight.proceed, true);
|
||||
});
|
||||
|
||||
test("registerDeepseekQuotaFetcher blocks when balance exhausted", async () => {
|
||||
const connectionId = `deepseek-block-${Date.now()}`;
|
||||
|
||||
registerDeepseekQuotaFetcher();
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
is_available: false,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "0.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "0.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const preflight = await preflightQuota("deepseek", connectionId, {
|
||||
apiKey: "test-key",
|
||||
providerSpecificData: { quotaPreflightEnabled: true },
|
||||
});
|
||||
|
||||
// DeepSeek with exhausted balance should block
|
||||
assert.equal(preflight.proceed, false);
|
||||
|
||||
invalidateDeepseekQuotaCache(connectionId);
|
||||
});
|
||||
179
tests/unit/usage-service-deepseek.test.ts
Normal file
179
tests/unit/usage-service-deepseek.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { getUsageForProvider } from "../../open-sse/services/usage.ts";
|
||||
import { invalidateDeepseekQuotaCache } from "../../open-sse/services/deepseekQuotaFetcher.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("getUsageForProvider handles deepseek with valid balance", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "50.00",
|
||||
granted_balance: "5.00",
|
||||
topped_up_balance: "45.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const result = await getUsageForProvider({
|
||||
id: "test-id",
|
||||
provider: "deepseek",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
assert.equal(result.plan, "DeepSeek");
|
||||
assert.equal(result.isAvailable, true);
|
||||
assert.equal(result.limitReached, false);
|
||||
assert.ok(result.quotas);
|
||||
assert.ok(result.quotas?.credits_usd);
|
||||
assert.equal(result.quotas.credits_usd.remaining, 50);
|
||||
assert.equal(result.quotas.credits_usd.currency, "USD");
|
||||
assert.equal(result.quotas.credits_usd.grantedBalance, 5);
|
||||
assert.equal(result.quotas.credits_usd.toppedUpBalance, 45);
|
||||
|
||||
invalidateDeepseekQuotaCache("test-id");
|
||||
});
|
||||
|
||||
test("getUsageForProvider handles deepseek with insufficient balance", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
is_available: false,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "0.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "0.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const result = await getUsageForProvider({
|
||||
id: "test-id-2",
|
||||
provider: "deepseek",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
assert.equal(result.plan, "DeepSeek (Insufficient Balance)");
|
||||
assert.equal(result.isAvailable, false);
|
||||
assert.equal(result.limitReached, true);
|
||||
assert.ok(result.quotas?.credits_usd);
|
||||
assert.equal(result.quotas.credits_usd.remaining, 0);
|
||||
|
||||
invalidateDeepseekQuotaCache("test-id-2");
|
||||
});
|
||||
|
||||
test("getUsageForProvider handles deepseek with CNY currency", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "CNY",
|
||||
total_balance: "500.00",
|
||||
granted_balance: "50.00",
|
||||
topped_up_balance: "450.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const result = await getUsageForProvider({
|
||||
id: "test-id-3",
|
||||
provider: "deepseek",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
assert.equal(result.plan, "DeepSeek");
|
||||
assert.ok(result.quotas?.credits_cny);
|
||||
assert.equal(result.quotas.credits_cny.remaining, 500);
|
||||
assert.equal(result.quotas.credits_cny.currency, "CNY");
|
||||
|
||||
invalidateDeepseekQuotaCache("test-id-3");
|
||||
});
|
||||
|
||||
test("getUsageForProvider handles deepseek with both USD and CNY balances", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
is_available: true,
|
||||
balance_infos: [
|
||||
{
|
||||
currency: "CNY",
|
||||
total_balance: "1000.00",
|
||||
granted_balance: "0.00",
|
||||
topped_up_balance: "1000.00",
|
||||
},
|
||||
{
|
||||
currency: "USD",
|
||||
total_balance: "50.00",
|
||||
granted_balance: "5.00",
|
||||
topped_up_balance: "45.00",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
const result = await getUsageForProvider({
|
||||
id: "test-id-multi",
|
||||
provider: "deepseek",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
assert.equal(result.plan, "DeepSeek");
|
||||
assert.ok(result.quotas?.credits_usd);
|
||||
assert.ok(result.quotas?.credits_cny);
|
||||
assert.equal(result.quotas.credits_usd.remaining, 50);
|
||||
assert.equal(result.quotas.credits_cny.remaining, 1000);
|
||||
|
||||
invalidateDeepseekQuotaCache("test-id-multi");
|
||||
});
|
||||
|
||||
test("getUsageForProvider returns message when deepseek API key is missing", async () => {
|
||||
globalThis.fetch = async () => {
|
||||
// This should not be called
|
||||
throw new Error("Fetch should not be called");
|
||||
};
|
||||
|
||||
const result = await getUsageForProvider({
|
||||
id: "test-id-4",
|
||||
provider: "deepseek",
|
||||
apiKey: "",
|
||||
});
|
||||
|
||||
assert.equal(result.message, "DeepSeek API key not available. Add a key to view usage.");
|
||||
});
|
||||
|
||||
test("getUsageForProvider handles deepseek network error gracefully", async () => {
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("Network error");
|
||||
};
|
||||
|
||||
const result = await getUsageForProvider({
|
||||
id: "test-id-5",
|
||||
provider: "deepseek",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
// On network error, the quota fetcher returns null (fail-open),
|
||||
// which results in "API key not available" message
|
||||
// This is acceptable behavior - the system continues with rate limit fallback
|
||||
assert.ok(result.message);
|
||||
});
|
||||
Reference in New Issue
Block a user