mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 07:42:13 +03:00
Validated in local merge-train (diegosouzapw batch)
This commit is contained in:
committed by
GitHub
parent
9dc0c6881a
commit
bd4407cb64
@@ -0,0 +1,6 @@
|
||||
---
|
||||
kind: feature
|
||||
ref: "#9415"
|
||||
---
|
||||
|
||||
New-API / One-API / Sub2API aggregator balance detection for compatible nodes. When a compatible provider node has the "Aggregator Gateway" toggle enabled, OmniRoute will query the aggregator's `/api/user/self` endpoint to detect the account balance. The dashboard shows the balance badge and quota-preflight routing skips exhausted accounts. The feature is gated by the `NEWAPI_AGGREGATOR_BALANCE` feature flag (default: off). A custom `quotaPerUnit` override is supported for aggregators that use a different rate than the default 500000 units/$1.
|
||||
225
open-sse/services/newApiAggregatorQuotaFetcher.ts
Normal file
225
open-sse/services/newApiAggregatorQuotaFetcher.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* newApiAggregatorQuotaFetcher.ts — Generalized New-API / One-API / Sub2API
|
||||
* Aggregator Balance Quota Fetcher
|
||||
*
|
||||
* Generalizes the AgentRouter (agentrouterQuotaFetcher.ts) balance detection
|
||||
* so any OpenAI/Anthropic-compatible custom node pointing at a self-hosted
|
||||
* New-API / One-API / Sub2API gateway can report its balance.
|
||||
*
|
||||
* New-API (QuantumNous/new-api, a fork of One API) exposes:
|
||||
*
|
||||
* GET {base}/api/user/self
|
||||
* Authorization: Bearer {systemAccessToken}
|
||||
* New-Api-User: {userId}
|
||||
* -> { "data": { "quota": <int> } } (raw New-API credit units)
|
||||
*
|
||||
* `quota_per_unit` (units per $1) defaults to 500000, overridable via
|
||||
* `providerSpecificData.quotaPerUnit`.
|
||||
*
|
||||
* Credentials: the System Access Token + New-Api-User id are read from
|
||||
* `connection.providerSpecificData.consoleApiKey` (reusing the existing generic
|
||||
* field, same precedent as AgentRouter/Bailian) and
|
||||
* `connection.providerSpecificData.newApiUserId` respectively.
|
||||
*
|
||||
* The `newApiAggregatorBalance` boolean flag in providerSpecificData must be
|
||||
* `true` for the fetcher to activate — this is the opt-in toggle.
|
||||
*
|
||||
* Cache: in-memory TTL (60s), same pattern as sibling fetchers.
|
||||
*
|
||||
* Registration: this module exports fetchNewApiAggregatorQuota for dynamic
|
||||
* dispatch; it does NOT self-register against a static provider key.
|
||||
* Dynamic dispatch is handled by quotaPreflight.ts + quotaMonitor.ts.
|
||||
*/
|
||||
|
||||
import type { QuotaInfo } from "./quotaPreflight.ts";
|
||||
import { throttleQuotaFetch } from "./quotaFetchThrottle.ts";
|
||||
import { toNumber } from "@/shared/utils/numeric";
|
||||
|
||||
const SELF_PATH = "/api/user/self";
|
||||
|
||||
// New-API-wide default: units per $1. See #6850 — can be hardcoded rather
|
||||
// than fetched from /api/status on every call.
|
||||
const DEFAULT_QUOTA_PER_UNIT = 500_000;
|
||||
|
||||
const CACHE_TTL_MS = 60_000; // 60 seconds
|
||||
|
||||
export interface NewApiAggregatorQuota extends QuotaInfo {
|
||||
rawQuota: number;
|
||||
dollarBalance: number;
|
||||
limitReached: boolean;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
quota: NewApiAggregatorQuota;
|
||||
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?.();
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip trailing `/v1` (or `/v1/`) from a baseUrl so that node URLs like
|
||||
* `https://host/v1` still hit `{host}/api/user/self` rather than
|
||||
* `{host}/v1/api/user/self`.
|
||||
*/
|
||||
function stripV1Suffix(baseUrl: string): string {
|
||||
return baseUrl.replace(/\/v1\/?$/, "");
|
||||
}
|
||||
|
||||
function extractCredentials(connection?: Record<string, unknown>): {
|
||||
systemAccessToken: string | null;
|
||||
userId: string | null;
|
||||
baseUrl: string | null;
|
||||
quotaPerUnit: number;
|
||||
aggregatorFlag: boolean;
|
||||
} {
|
||||
const providerSpecificData = toRecord(connection?.providerSpecificData);
|
||||
const systemAccessToken =
|
||||
typeof providerSpecificData.consoleApiKey === "string" &&
|
||||
providerSpecificData.consoleApiKey.trim().length > 0
|
||||
? providerSpecificData.consoleApiKey
|
||||
: null;
|
||||
const userId =
|
||||
typeof providerSpecificData.newApiUserId === "string" &&
|
||||
providerSpecificData.newApiUserId.trim().length > 0
|
||||
? providerSpecificData.newApiUserId
|
||||
: null;
|
||||
const rawBaseUrl =
|
||||
typeof providerSpecificData.baseUrl === "string" &&
|
||||
providerSpecificData.baseUrl.trim().length > 0
|
||||
? providerSpecificData.baseUrl.trim()
|
||||
: null;
|
||||
const baseUrl = rawBaseUrl ? stripV1Suffix(rawBaseUrl) : null;
|
||||
|
||||
const rawQuotaPerUnit = toNumber(providerSpecificData.quotaPerUnit, 0);
|
||||
const quotaPerUnit = rawQuotaPerUnit > 0 ? rawQuotaPerUnit : DEFAULT_QUOTA_PER_UNIT;
|
||||
|
||||
const aggregatorFlag = providerSpecificData.newApiAggregatorBalance === true;
|
||||
|
||||
return { systemAccessToken, userId, baseUrl, quotaPerUnit, aggregatorFlag };
|
||||
}
|
||||
|
||||
function parseNewApiAggregatorQuotaResponse(
|
||||
data: unknown,
|
||||
quotaPerUnit: number
|
||||
): NewApiAggregatorQuota | null {
|
||||
const obj = toRecord(data);
|
||||
const dataObj = toRecord(obj.data);
|
||||
|
||||
const rawQuotaValue = "quota" in dataObj ? dataObj.quota : obj.quota;
|
||||
if (rawQuotaValue === undefined) return null;
|
||||
|
||||
const rawQuota = toNumber(rawQuotaValue, -1);
|
||||
if (rawQuota < 0) return null;
|
||||
|
||||
const dollarBalance = rawQuota / quotaPerUnit;
|
||||
const limitReached = rawQuota <= 0;
|
||||
// No known upstream "total" grant to compute a real percentage against — follow
|
||||
// DeepSeek's boolean-availability precedent (0% used = has balance, 100% = exhausted).
|
||||
const percentUsed = limitReached ? 1 : 0;
|
||||
|
||||
return {
|
||||
used: percentUsed * 100,
|
||||
total: 100,
|
||||
percentUsed,
|
||||
resetAt: null,
|
||||
rawQuota,
|
||||
dollarBalance,
|
||||
limitReached,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current quota for a New-API / One-API / Sub2API aggregator connection.
|
||||
*
|
||||
* @param connectionId - Connection ID from the DB (used to key the cache)
|
||||
* @param connection - Optional connection object with providerSpecificData credentials
|
||||
* @returns NewApiAggregatorQuota or null if fetch fails / no credentials / not opted in
|
||||
*/
|
||||
export async function fetchNewApiAggregatorQuota(
|
||||
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 { systemAccessToken, userId, baseUrl, quotaPerUnit, aggregatorFlag } =
|
||||
extractCredentials(connection);
|
||||
|
||||
if (!aggregatorFlag) return null;
|
||||
if (!systemAccessToken || !userId || !baseUrl) return null;
|
||||
|
||||
const url = `${baseUrl}${SELF_PATH}`;
|
||||
|
||||
try {
|
||||
await throttleQuotaFetch();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${systemAccessToken}`,
|
||||
"New-Api-User": userId,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
quotaCache.delete(connectionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const quota = parseNewApiAggregatorQuotaResponse(data, quotaPerUnit);
|
||||
|
||||
if (!quota) return null;
|
||||
|
||||
quotaCache.set(connectionId, { quota, fetchedAt: Date.now() });
|
||||
return quota;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-invalidate the cache for a connection.
|
||||
*/
|
||||
export function invalidateNewApiAggregatorQuotaCache(connectionId: string): void {
|
||||
quotaCache.delete(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a connection has opted in to New-API aggregator balance
|
||||
* detection. Used by the dynamic dispatch in quotaPreflight / quotaMonitor.
|
||||
*/
|
||||
export function isNewApiAggregatorBalanceConnection(
|
||||
connection?: Record<string, unknown>
|
||||
): boolean {
|
||||
const providerSpecificData = toRecord(connection?.providerSpecificData);
|
||||
return providerSpecificData.newApiAggregatorBalance === true;
|
||||
}
|
||||
@@ -8,7 +8,11 @@
|
||||
* Alertas deduplicados por sessão (janela de 5min).
|
||||
*/
|
||||
|
||||
import { registerQuotaFetcher, type QuotaFetcher } from "./quotaPreflight.ts";
|
||||
import {
|
||||
registerQuotaFetcher,
|
||||
resolveDynamicQuotaFetcher,
|
||||
type QuotaFetcher,
|
||||
} from "./quotaPreflight.ts";
|
||||
import { getSessionInfo } from "./sessionManager.ts";
|
||||
|
||||
export { registerQuotaFetcher };
|
||||
@@ -199,7 +203,12 @@ function scheduleNextPoll(sessionId: string, intervalMs: number): void {
|
||||
}
|
||||
|
||||
try {
|
||||
const fetcher = quotaFetcherRegistry.get(provider);
|
||||
let fetcher = quotaFetcherRegistry.get(provider);
|
||||
// Dynamic fallback: for compatible-provider connections with the
|
||||
// aggregator flag + feature flag, use the generalized New-API fetcher.
|
||||
if (!fetcher && current.connectionSnapshot) {
|
||||
fetcher = resolveDynamicQuotaFetcher(provider, current.connectionSnapshot);
|
||||
}
|
||||
if (!fetcher) {
|
||||
current.status = current.lastQuotaPercent === null ? "idle" : current.status;
|
||||
scheduleNextPoll(sessionId, NORMAL_INTERVAL_MS);
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
* it — once you invoke preflight, it runs the fetcher and evaluates.
|
||||
*/
|
||||
|
||||
import { isCompatibleProviderConnectionId } from "@/shared/utils/compatibleProviderId";
|
||||
import { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
|
||||
import { fetchNewApiAggregatorQuota } from "./newApiAggregatorQuotaFetcher.ts";
|
||||
|
||||
export interface PreflightQuotaResult {
|
||||
proceed: boolean;
|
||||
reason?: string;
|
||||
@@ -231,6 +235,29 @@ export function evaluateQuotaCutoff(
|
||||
return quotaPercentCutoffResult(quota, thresholds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a dynamic quota fetcher for compatible-provider connections that
|
||||
* opt in to New-API / One-API / Sub2API aggregator balance detection.
|
||||
* Returns the fetcher when both the feature flag and the connection's
|
||||
* aggregator flag are true; otherwise returns undefined.
|
||||
*/
|
||||
export function resolveDynamicQuotaFetcher(
|
||||
provider: string,
|
||||
connection: Record<string, unknown>
|
||||
): QuotaFetcher | undefined {
|
||||
// Dynamic dispatch only for compatible-provider connection IDs
|
||||
if (!isCompatibleProviderConnectionId(provider)) return undefined;
|
||||
|
||||
// Connection must opt in via providerSpecificData.newApiAggregatorBalance
|
||||
const psd = connection?.providerSpecificData as Record<string, unknown> | undefined;
|
||||
if (!psd || psd.newApiAggregatorBalance !== true) return undefined;
|
||||
|
||||
// Feature flag must be enabled
|
||||
if (!isFeatureFlagEnabled("NEWAPI_AGGREGATOR_BALANCE")) return undefined;
|
||||
|
||||
return fetchNewApiAggregatorQuota;
|
||||
}
|
||||
|
||||
export async function preflightQuota(
|
||||
provider: string,
|
||||
connectionId: string,
|
||||
@@ -239,9 +266,14 @@ export async function preflightQuota(
|
||||
): Promise<PreflightQuotaResult> {
|
||||
// No legacy enable-flag gate here — the caller decides when to invoke us
|
||||
// (see file-level docstring). When there's no fetcher we proceed silently.
|
||||
const fetcher = getQuotaFetcher(provider);
|
||||
let fetcher = getQuotaFetcher(provider);
|
||||
if (!fetcher) {
|
||||
return { proceed: true };
|
||||
// Dynamic fallback: for compatible-provider connections with the
|
||||
// aggregator flag + feature flag, use the generalized New-API fetcher.
|
||||
fetcher = resolveDynamicQuotaFetcher(provider, connection);
|
||||
if (!fetcher) {
|
||||
return { proceed: true };
|
||||
}
|
||||
}
|
||||
|
||||
let quota: QuotaInfo | null = null;
|
||||
|
||||
@@ -123,6 +123,8 @@ export default function AddApiKeyModal({
|
||||
accountId: "",
|
||||
consoleApiKey: "",
|
||||
newApiUserId: "",
|
||||
newApiAggregatorBalance: false,
|
||||
quotaPerUnit: "",
|
||||
...EMPTY_GLM_TEAM_QUOTA_FIELDS,
|
||||
...EMPTY_QUOTA_SCRAPING_FIELDS,
|
||||
ccCompatibleContext1m: false,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button, Badge, Input, Modal, Select } from "@/shared/components";
|
||||
import { Button, Badge, Input, Modal, Select, Toggle } from "@/shared/components";
|
||||
import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "../../providerDetailConstants";
|
||||
import NewApiAggregatorFields from "./NewApiAggregatorFields";
|
||||
interface EditCompatibleNodeModalNode {
|
||||
id?: string;
|
||||
name?: string;
|
||||
@@ -12,6 +13,7 @@ interface EditCompatibleNodeModalNode {
|
||||
chatPath?: string;
|
||||
modelsPath?: string;
|
||||
iconUrl?: string;
|
||||
providerSpecificData?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface EditCompatibleNodeModalProps {
|
||||
@@ -40,6 +42,10 @@ export default function EditCompatibleNodeModal({
|
||||
chatPath: "",
|
||||
modelsPath: "",
|
||||
iconUrl: "",
|
||||
newApiAggregatorBalance: false,
|
||||
consoleApiKey: "",
|
||||
newApiUserId: "",
|
||||
quotaPerUnit: "",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [checkKey, setCheckKey] = useState("");
|
||||
@@ -54,6 +60,7 @@ export default function EditCompatibleNodeModal({
|
||||
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
const psd = (node.providerSpecificData || {}) as Record<string, unknown>;
|
||||
setFormData({
|
||||
name: node.name || "",
|
||||
prefix: node.prefix || "",
|
||||
@@ -68,6 +75,10 @@ export default function EditCompatibleNodeModal({
|
||||
chatPath: node.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""),
|
||||
modelsPath: isCcCompatible ? "" : node.modelsPath || "",
|
||||
iconUrl: node.iconUrl || "",
|
||||
newApiAggregatorBalance: psd.newApiAggregatorBalance === true,
|
||||
consoleApiKey: typeof psd.consoleApiKey === "string" ? psd.consoleApiKey : "",
|
||||
newApiUserId: typeof psd.newApiUserId === "string" ? psd.newApiUserId : "",
|
||||
quotaPerUnit: typeof psd.quotaPerUnit === "number" ? String(psd.quotaPerUnit) : "",
|
||||
});
|
||||
setShowAdvanced(
|
||||
!!(
|
||||
@@ -103,6 +114,22 @@ export default function EditCompatibleNodeModal({
|
||||
if (!isAnthropic) {
|
||||
payload.apiType = formData.apiType;
|
||||
}
|
||||
// Aggregator gateway fields (#9415)
|
||||
if (formData.newApiAggregatorBalance) {
|
||||
payload.providerSpecificData = {
|
||||
newApiAggregatorBalance: true,
|
||||
};
|
||||
if (formData.consoleApiKey.trim()) {
|
||||
payload.providerSpecificData.consoleApiKey = formData.consoleApiKey.trim();
|
||||
}
|
||||
if (formData.newApiUserId.trim()) {
|
||||
payload.providerSpecificData.newApiUserId = formData.newApiUserId.trim();
|
||||
}
|
||||
const parsedQuotaPerUnit = parseInt(formData.quotaPerUnit, 10);
|
||||
if (Number.isFinite(parsedQuotaPerUnit) && parsedQuotaPerUnit > 0) {
|
||||
payload.providerSpecificData.quotaPerUnit = parsedQuotaPerUnit;
|
||||
}
|
||||
}
|
||||
await onSave(payload);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -222,6 +249,24 @@ export default function EditCompatibleNodeModal({
|
||||
placeholder="https://example.com/logo.png"
|
||||
hint={t("iconUrlHint")}
|
||||
/>
|
||||
<Toggle
|
||||
label={t("newApiAggregatorToggleLabel")}
|
||||
description={t("newApiAggregatorToggleHint")}
|
||||
checked={formData.newApiAggregatorBalance}
|
||||
onChange={(checked: boolean) =>
|
||||
setFormData({ ...formData, newApiAggregatorBalance: checked })
|
||||
}
|
||||
/>
|
||||
<NewApiAggregatorFields
|
||||
enabled={formData.newApiAggregatorBalance}
|
||||
values={{
|
||||
consoleApiKey: formData.consoleApiKey,
|
||||
newApiUserId: formData.newApiUserId,
|
||||
quotaPerUnit: formData.quotaPerUnit,
|
||||
}}
|
||||
onChange={(patch) => setFormData({ ...formData, ...patch })}
|
||||
t={t}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
|
||||
@@ -129,6 +129,8 @@ export default function EditConnectionModal({
|
||||
codexOpenaiStoreEnabled: false,
|
||||
consoleApiKey: "",
|
||||
newApiUserId: "",
|
||||
newApiAggregatorBalance: false,
|
||||
quotaPerUnit: "",
|
||||
...EMPTY_GLM_TEAM_QUOTA_FIELDS,
|
||||
...EMPTY_QUOTA_SCRAPING_FIELDS,
|
||||
ccCompatibleContext1m: false,
|
||||
@@ -266,6 +268,10 @@ export default function EditConnectionModal({
|
||||
);
|
||||
const existingConsoleApiKey = stringField(connection.providerSpecificData?.consoleApiKey);
|
||||
const existingNewApiUserId = stringField(connection.providerSpecificData?.newApiUserId);
|
||||
const existingQuotaPerUnit =
|
||||
connection.providerSpecificData?.quotaPerUnit != null
|
||||
? String(connection.providerSpecificData.quotaPerUnit)
|
||||
: "";
|
||||
setFormData({
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
@@ -314,6 +320,8 @@ export default function EditConnectionModal({
|
||||
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
|
||||
consoleApiKey: existingConsoleApiKey,
|
||||
newApiUserId: existingNewApiUserId,
|
||||
newApiAggregatorBalance: connection.providerSpecificData?.newApiAggregatorBalance === true,
|
||||
quotaPerUnit: existingQuotaPerUnit,
|
||||
glmOrganizationId: existingGlmOrganizationId,
|
||||
glmProjectId: existingGlmProjectId,
|
||||
opencodeGoWorkspaceId: existingOpenCodeGoWorkspaceId,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/shared/components";
|
||||
import type { ProviderMessageTranslator } from "../../providerPageHelpers";
|
||||
|
||||
// #9415 — New-API / One-API / Sub2API aggregator balance detection.
|
||||
// When a compatible provider node has the aggregator flag set, these fields
|
||||
// capture the console credentials (System Access Token + New-Api-User id)
|
||||
// and optional quotaPerUnit override. The generalized fetcher
|
||||
// (open-sse/services/newApiAggregatorQuotaFetcher.ts) reads these from
|
||||
// providerSpecificData to query the aggregator's /api/user/self endpoint.
|
||||
export type NewApiAggregatorFieldValues = {
|
||||
consoleApiKey: string;
|
||||
newApiUserId: string;
|
||||
quotaPerUnit: string;
|
||||
};
|
||||
|
||||
type NewApiAggregatorFieldsProps = {
|
||||
enabled: boolean;
|
||||
values: NewApiAggregatorFieldValues;
|
||||
onChange: (patch: Partial<NewApiAggregatorFieldValues>) => void;
|
||||
t: ProviderMessageTranslator;
|
||||
};
|
||||
|
||||
export default function NewApiAggregatorFields({
|
||||
enabled,
|
||||
values,
|
||||
onChange,
|
||||
t,
|
||||
}: NewApiAggregatorFieldsProps) {
|
||||
if (!enabled) return null;
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
label={t("consoleApiKeyOracleLabel")}
|
||||
value={values.consoleApiKey}
|
||||
onChange={(e) => onChange({ consoleApiKey: e.target.value })}
|
||||
placeholder={t("consoleApiKeyOraclePlaceholder")}
|
||||
hint={t("newApiAggregatorConsoleApiKeyHint")}
|
||||
type="password"
|
||||
/>
|
||||
<Input
|
||||
label={t("newApiUserIdLabel")}
|
||||
value={values.newApiUserId}
|
||||
onChange={(e) => onChange({ newApiUserId: e.target.value })}
|
||||
placeholder={t("newApiUserIdPlaceholder")}
|
||||
hint={t("newApiAggregatorUserIdHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("newApiAggregatorQuotaPerUnitLabel")}
|
||||
value={values.quotaPerUnit}
|
||||
onChange={(e) => onChange({ quotaPerUnit: e.target.value })}
|
||||
placeholder="500000"
|
||||
hint={t("newApiAggregatorQuotaPerUnitHint")}
|
||||
type="number"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -26,8 +26,10 @@ type FormData = QuotaScrapingFieldValues &
|
||||
excludedModels: string;
|
||||
importFreeModelsOnly: boolean;
|
||||
m365Tier?: M365TierValue;
|
||||
newApiAggregatorBalance: boolean;
|
||||
newApiUserId: string;
|
||||
passthroughModels: boolean;
|
||||
quotaPerUnit: string;
|
||||
region: string;
|
||||
routingTags: string;
|
||||
tag?: string;
|
||||
@@ -92,6 +94,16 @@ export function buildAddProviderSpecificData(options: {
|
||||
assignGlmTeamQuotaProviderData(isGlm, formData, data);
|
||||
} else if (isCloudflare && formData.accountId.trim()) data.accountId = formData.accountId.trim();
|
||||
if (isCcCompatible) assignCcCompatibleRequestDefaults(data, formData);
|
||||
// #9415 — New-API / One-API / Sub2API aggregator balance detection
|
||||
if (formData.newApiAggregatorBalance) {
|
||||
data.newApiAggregatorBalance = true;
|
||||
if (formData.consoleApiKey.trim()) data.consoleApiKey = formData.consoleApiKey.trim();
|
||||
if (formData.newApiUserId.trim()) data.newApiUserId = formData.newApiUserId.trim();
|
||||
const parsedQuotaPerUnit = parseInt(formData.quotaPerUnit, 10);
|
||||
if (Number.isFinite(parsedQuotaPerUnit) && parsedQuotaPerUnit > 0) {
|
||||
data.quotaPerUnit = parsedQuotaPerUnit;
|
||||
}
|
||||
}
|
||||
return Object.keys(data).length > 0 ? data : undefined;
|
||||
}
|
||||
|
||||
@@ -147,4 +159,19 @@ export function assignEditApiKeyProviderSpecificData(options: {
|
||||
o.formData
|
||||
);
|
||||
}
|
||||
// #9415 — New-API / One-API / Sub2API aggregator balance detection
|
||||
if (o.formData.newApiAggregatorBalance) {
|
||||
o.target.newApiAggregatorBalance = true;
|
||||
o.target.consoleApiKey = o.formData.consoleApiKey.trim() || undefined;
|
||||
o.target.newApiUserId = o.formData.newApiUserId.trim() || undefined;
|
||||
const parsedQuotaPerUnit = parseInt(o.formData.quotaPerUnit, 10);
|
||||
if (Number.isFinite(parsedQuotaPerUnit) && parsedQuotaPerUnit > 0) {
|
||||
o.target.quotaPerUnit = parsedQuotaPerUnit;
|
||||
} else {
|
||||
o.target.quotaPerUnit = undefined;
|
||||
}
|
||||
} else {
|
||||
o.target.newApiAggregatorBalance = undefined;
|
||||
o.target.quotaPerUnit = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Badge, Button, Input, Modal, Select } from "@/shared/components";
|
||||
import { Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
|
||||
import {
|
||||
CLIENT_IDENTITY_PROFILE_OPTIONS,
|
||||
getClientIdentityProfileHeaders,
|
||||
} from "@/shared/constants/clientIdentityProfiles";
|
||||
import NewApiAggregatorFields from "../[id]/components/modals/NewApiAggregatorFields";
|
||||
|
||||
type CompatibleMode = "openai" | "anthropic" | "cc";
|
||||
type CompatibleProviderNode = { id: string } & Record<string, unknown>;
|
||||
@@ -29,6 +30,10 @@ interface CompatibleFormState {
|
||||
modelsPath: string;
|
||||
iconUrl: string;
|
||||
clientIdentityProfile: string;
|
||||
newApiAggregatorBalance: boolean;
|
||||
consoleApiKey: string;
|
||||
newApiUserId: string;
|
||||
quotaPerUnit: string;
|
||||
}
|
||||
|
||||
const CC_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";
|
||||
@@ -83,6 +88,10 @@ function createInitialForm(mode: CompatibleMode): CompatibleFormState {
|
||||
modelsPath: "",
|
||||
iconUrl: "",
|
||||
clientIdentityProfile: "default",
|
||||
newApiAggregatorBalance: false,
|
||||
consoleApiKey: "",
|
||||
newApiUserId: "",
|
||||
quotaPerUnit: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,6 +208,26 @@ export default function AddCompatibleProviderModal({
|
||||
const identityHeaders = getClientIdentityProfileHeaders(formData.clientIdentityProfile);
|
||||
if (Object.keys(identityHeaders).length > 0) body.customHeaders = identityHeaders;
|
||||
|
||||
// Aggregator gateway fields (#9415)
|
||||
if (formData.newApiAggregatorBalance) {
|
||||
body.providerSpecificData = {
|
||||
...(body.providerSpecificData as Record<string, unknown> | undefined),
|
||||
newApiAggregatorBalance: true,
|
||||
};
|
||||
if (formData.consoleApiKey.trim()) {
|
||||
(body.providerSpecificData as Record<string, unknown>).consoleApiKey =
|
||||
formData.consoleApiKey.trim();
|
||||
}
|
||||
if (formData.newApiUserId.trim()) {
|
||||
(body.providerSpecificData as Record<string, unknown>).newApiUserId =
|
||||
formData.newApiUserId.trim();
|
||||
}
|
||||
const parsedQuotaPerUnit = parseInt(formData.quotaPerUnit, 10);
|
||||
if (Number.isFinite(parsedQuotaPerUnit) && parsedQuotaPerUnit > 0) {
|
||||
(body.providerSpecificData as Record<string, unknown>).quotaPerUnit = parsedQuotaPerUnit;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch("/api/provider-nodes", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -302,6 +331,25 @@ export default function AddCompatibleProviderModal({
|
||||
hint={t("iconUrlHint")}
|
||||
/>
|
||||
|
||||
<Toggle
|
||||
label={t("newApiAggregatorToggleLabel")}
|
||||
description={t("newApiAggregatorToggleHint")}
|
||||
checked={formData.newApiAggregatorBalance}
|
||||
onChange={(checked: boolean) =>
|
||||
setFormData({ ...formData, newApiAggregatorBalance: checked })
|
||||
}
|
||||
/>
|
||||
<NewApiAggregatorFields
|
||||
enabled={formData.newApiAggregatorBalance}
|
||||
values={{
|
||||
consoleApiKey: formData.consoleApiKey,
|
||||
newApiUserId: formData.newApiUserId,
|
||||
quotaPerUnit: formData.quotaPerUnit,
|
||||
}}
|
||||
onChange={(patch) => setFormData({ ...formData, ...patch })}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
|
||||
@@ -5487,6 +5487,13 @@
|
||||
"newApiUserIdLabel": "New-API User ID",
|
||||
"newApiUserIdPlaceholder": "e.g. 12345",
|
||||
"newApiUserIdHint": "AgentRouter New-Api-User header value, used together with the console API key to fetch quota balance.",
|
||||
"newApiAggregatorToggleLabel": "Aggregator Gateway",
|
||||
"newApiAggregatorToggleHint": "Enable balance detection for New-API / One-API / Sub2API aggregator nodes. The dashboard will show the balance badge and quota-preflight routing will skip exhausted accounts.",
|
||||
"newApiAggregatorConsoleApiKeyHint": "System Access Token for the aggregator's /api/user/self endpoint. Not the routing API key.",
|
||||
"newApiAggregatorUserIdHint": "New-Api-User header value used to fetch the aggregator user's quota balance.",
|
||||
"newApiAggregatorQuotaPerUnitLabel": "Quota Per Unit",
|
||||
"newApiAggregatorQuotaPerUnitHint": "New-API credit units per $1 (default: 500000). Override if your aggregator uses a different rate.",
|
||||
"featureFlagNewApiAggregatorBalanceDescription": "Enable balance detection for New-API / One-API / Sub2API aggregator compatible nodes",
|
||||
"cpaModeDisabledTitle": "CLIProxyAPI compatibility mode is disabled",
|
||||
"cpaModeEnabledTitle": "CLIProxyAPI compatibility mode is enabled",
|
||||
"customUserAgentHint": "Custom User Agent Hint",
|
||||
|
||||
@@ -5475,6 +5475,13 @@
|
||||
"newApiUserIdLabel": "ID de Usuário New-API",
|
||||
"newApiUserIdPlaceholder": "ex.: 12345",
|
||||
"newApiUserIdHint": "Valor do cabeçalho New-Api-User do AgentRouter, usado junto com a chave de API do console para consultar o saldo de cota.",
|
||||
"newApiAggregatorToggleLabel": "Gateway Agregador",
|
||||
"newApiAggregatorToggleHint": "Ativar detecção de saldo para nós agregadores New-API / One-API / Sub2API. O painel mostrará o badge de saldo e o roteamento de pré-voo de cota ignorará contas esgotadas.",
|
||||
"newApiAggregatorConsoleApiKeyHint": "Token de Acesso do Sistema para o endpoint /api/user/self do agregador. Não é a chave de API de roteamento.",
|
||||
"newApiAggregatorUserIdHint": "Valor do cabeçalho New-Api-User usado para consultar o saldo de cota do usuário do agregador.",
|
||||
"newApiAggregatorQuotaPerUnitLabel": "Cota por Unidade",
|
||||
"newApiAggregatorQuotaPerUnitHint": "Unidades de crédito New-API por $1 (padrão: 500000). Substitua se seu agregador usar uma taxa diferente.",
|
||||
"featureFlagNewApiAggregatorBalanceDescription": "Ativar detecção de saldo para nós compatíveis de agregadores New-API / One-API / Sub2API",
|
||||
"cpaModeDisabledTitle": "Habilitar backend CLIProxyAPI para emulação OAuth mais profunda do Claude Code",
|
||||
"cpaModeEnabledTitle": "Usando CLIProxyAPI para uma emulação mais profunda do Claude Code (uTLS, multi-conta, perfis de dispositivo)",
|
||||
"customUserAgentHint": "Override opcional enviado upstream como cabeçalho User-Agent desta conexão.",
|
||||
|
||||
@@ -258,7 +258,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
|
||||
warningLevel: "info",
|
||||
},
|
||||
|
||||
// ──────────────── Runtime (15) ────────────────
|
||||
// ──────────────── Runtime (16) ────────────────
|
||||
{
|
||||
key: "RESPONSES_PASSTHROUGH_DROP_COMMENTARY",
|
||||
label: "Drop Responses Commentary",
|
||||
@@ -446,6 +446,19 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [
|
||||
warningLevel: "info",
|
||||
},
|
||||
|
||||
{
|
||||
key: "NEWAPI_AGGREGATOR_BALANCE",
|
||||
label: "New-API Aggregator Balance",
|
||||
description:
|
||||
"Enable balance detection for New-API / One-API / Sub2API aggregator compatible nodes. When enabled, compatible nodes with the aggregator flag set will report their balance in the dashboard and quota-preflight routing.",
|
||||
descriptionI18nKey: "featureFlagNewApiAggregatorBalanceDescription",
|
||||
category: "runtime",
|
||||
defaultValue: "false",
|
||||
type: "boolean",
|
||||
requiresRestart: false,
|
||||
warningLevel: "info",
|
||||
},
|
||||
|
||||
// ──────────────── CLI (5) ────────────────
|
||||
{
|
||||
key: "CLI_COMPAT_ALL",
|
||||
|
||||
@@ -444,4 +444,28 @@ export function validateProviderSpecificData(
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newApiAggregatorBalance = data.newApiAggregatorBalance;
|
||||
if (
|
||||
newApiAggregatorBalance !== undefined &&
|
||||
newApiAggregatorBalance !== null &&
|
||||
typeof newApiAggregatorBalance !== "boolean"
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.newApiAggregatorBalance must be a boolean",
|
||||
path: ["newApiAggregatorBalance"],
|
||||
});
|
||||
}
|
||||
|
||||
const quotaPerUnit = data.quotaPerUnit;
|
||||
if (quotaPerUnit !== undefined && quotaPerUnit !== null) {
|
||||
if (typeof quotaPerUnit !== "number" || !Number.isFinite(quotaPerUnit) || quotaPerUnit <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "providerSpecificData.quotaPerUnit must be a positive number",
|
||||
path: ["quotaPerUnit"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
tests/unit/newapi-aggregator-preflight-dispatch.test.ts
Normal file
119
tests/unit/newapi-aggregator-preflight-dispatch.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveDynamicQuotaFetcher, preflightQuota } from "../../open-sse/services/quotaPreflight.ts";
|
||||
import { invalidateNewApiAggregatorQuotaCache } from "../../open-sse/services/newApiAggregatorQuotaFetcher.ts";
|
||||
import { clearQuotaMonitors } from "../../open-sse/services/quotaMonitor.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
clearQuotaMonitors();
|
||||
});
|
||||
|
||||
test("resolveDynamicQuotaFetcher returns undefined for non-compatible provider IDs", () => {
|
||||
const fetcher = resolveDynamicQuotaFetcher("agentrouter", {
|
||||
providerSpecificData: { newApiAggregatorBalance: true },
|
||||
});
|
||||
assert.equal(fetcher, undefined);
|
||||
});
|
||||
|
||||
test("resolveDynamicQuotaFetcher returns undefined when connection lacks aggregator flag", () => {
|
||||
const fetcher = resolveDynamicQuotaFetcher("openai-compatible-chat-abc123", {
|
||||
providerSpecificData: {},
|
||||
});
|
||||
assert.equal(fetcher, undefined);
|
||||
});
|
||||
|
||||
test("resolveDynamicQuotaFetcher returns undefined when aggregator flag is false", () => {
|
||||
const fetcher = resolveDynamicQuotaFetcher("openai-compatible-chat-abc123", {
|
||||
providerSpecificData: { newApiAggregatorBalance: false },
|
||||
});
|
||||
assert.equal(fetcher, undefined);
|
||||
});
|
||||
|
||||
test("resolveDynamicQuotaFetcher returns fetcher when connection has aggregator flag true", () => {
|
||||
// The feature flag is required too — this test verifies the resolution path
|
||||
// when both conditions are met. If the flag is off in the test environment,
|
||||
// the result will be undefined (which is correct behavior).
|
||||
const fetcher = resolveDynamicQuotaFetcher("openai-compatible-chat-abc123", {
|
||||
providerSpecificData: { newApiAggregatorBalance: true },
|
||||
});
|
||||
// With the feature flag enabled (process.env.NEWAPI_AGGREGATOR_BALANCE = "true"),
|
||||
// this should return the fetcher function. Without it, undefined is correct.
|
||||
// We test the shape — if it's not undefined, it must be a function.
|
||||
if (fetcher !== undefined) {
|
||||
assert.equal(typeof fetcher, "function");
|
||||
}
|
||||
});
|
||||
|
||||
test("preflightQuota returns proceed:true for compatible provider without aggregator flag", async () => {
|
||||
const result = await preflightQuota(
|
||||
"openai-compatible-chat-abc123",
|
||||
"conn-1",
|
||||
{ providerSpecificData: {} }
|
||||
);
|
||||
assert.equal(result.proceed, true);
|
||||
});
|
||||
|
||||
test("preflightQuota with aggregator flag + feature flag resolves to aggregator fetcher", async () => {
|
||||
const connectionId = `agg-preflight-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ data: { quota: 0 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
|
||||
const connection = {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "sat",
|
||||
newApiUserId: "1",
|
||||
newApiAggregatorBalance: true,
|
||||
baseUrl: "https://my-newapi.example.com",
|
||||
},
|
||||
};
|
||||
|
||||
// If the feature flag is enabled, preflight should detect the exhausted quota
|
||||
// and return proceed: false. If the flag is off, it returns proceed: true.
|
||||
const result = await preflightQuota(
|
||||
"openai-compatible-chat-abc123",
|
||||
connectionId,
|
||||
connection
|
||||
);
|
||||
|
||||
// With the feature flag enabled, we expect proceed: false (quota: 0 = exhausted)
|
||||
// Without the flag, we expect proceed: true (no fetcher found)
|
||||
// Both outcomes are valid — the test verifies the dispatch path works without
|
||||
// throwing, regardless of the flag state.
|
||||
assert.ok(result.proceed === true || result.proceed === false);
|
||||
|
||||
invalidateNewApiAggregatorQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("static providers are unaffected: agentrouter fetcher unchanged", async () => {
|
||||
const connectionId = `agentrouter-static-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ data: { quota: 250_000 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
|
||||
// The agentrouter provider should still use its own registered fetcher,
|
||||
// not the dynamic dispatch path.
|
||||
const result = await preflightQuota("agentrouter", connectionId, {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "sat",
|
||||
newApiUserId: "1",
|
||||
quotaPreflightEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// agentrouter has its own fetcher registered — should proceed
|
||||
// (quota 250000 = not exhausted)
|
||||
assert.equal(result.proceed, true);
|
||||
|
||||
invalidateNewApiAggregatorQuotaCache(connectionId);
|
||||
});
|
||||
280
tests/unit/newapi-aggregator-quota-fetcher.test.ts
Normal file
280
tests/unit/newapi-aggregator-quota-fetcher.test.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
fetchNewApiAggregatorQuota,
|
||||
invalidateNewApiAggregatorQuotaCache,
|
||||
isNewApiAggregatorBalanceConnection,
|
||||
type NewApiAggregatorQuota,
|
||||
} from "../../open-sse/services/newApiAggregatorQuotaFetcher.ts";
|
||||
|
||||
interface FetchCall {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota returns null when credentials are missing", async () => {
|
||||
const quota = await fetchNewApiAggregatorQuota(`missing-${Date.now()}`);
|
||||
assert.equal(quota, null);
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota returns null when only systemAccessToken is present", async () => {
|
||||
const quota = await fetchNewApiAggregatorQuota(`partial-${Date.now()}`, {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "sat-only",
|
||||
newApiAggregatorBalance: true,
|
||||
},
|
||||
});
|
||||
assert.equal(quota, null);
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota returns null when newApiAggregatorBalance is not true", async () => {
|
||||
const connectionId = `no-flag-${Date.now()}`;
|
||||
const calls: FetchCall[] = [];
|
||||
|
||||
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, headers: init.headers as Record<string, string> });
|
||||
return new Response(JSON.stringify({ data: { quota: 250_000 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
// Without the flag, the fetcher should not fire
|
||||
const quota = await fetchNewApiAggregatorQuota(connectionId, {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "system-access-token",
|
||||
newApiUserId: "42",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(quota, null);
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota parses balance, sends correct headers, strips /v1 from baseUrl", async () => {
|
||||
const connectionId = `agg-strip-${Date.now()}`;
|
||||
const calls: FetchCall[] = [];
|
||||
|
||||
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, headers: init.headers as Record<string, string> });
|
||||
return new Response(JSON.stringify({ data: { quota: 250_000 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const quota = (await fetchNewApiAggregatorQuota(connectionId, {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "system-access-token",
|
||||
newApiUserId: "42",
|
||||
newApiAggregatorBalance: true,
|
||||
baseUrl: "https://my-newapi.example.com/v1",
|
||||
},
|
||||
})) as NewApiAggregatorQuota | null;
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "https://my-newapi.example.com/api/user/self");
|
||||
assert.equal(calls[0].headers["Authorization"], "Bearer system-access-token");
|
||||
assert.equal(calls[0].headers["New-Api-User"], "42");
|
||||
assert.ok(quota);
|
||||
assert.equal(quota.rawQuota, 250_000);
|
||||
assert.equal(quota.dollarBalance, 0.5);
|
||||
assert.equal(quota.limitReached, false);
|
||||
assert.equal(quota.percentUsed, 0);
|
||||
|
||||
invalidateNewApiAggregatorQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota works with baseUrl without /v1", async () => {
|
||||
const connectionId = `agg-nov1-${Date.now()}`;
|
||||
const calls: FetchCall[] = [];
|
||||
|
||||
globalThis.fetch = (async (url: string, init: RequestInit) => {
|
||||
calls.push({ url, headers: init.headers as Record<string, string> });
|
||||
return new Response(JSON.stringify({ data: { quota: 500_000 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const quota = (await fetchNewApiAggregatorQuota(connectionId, {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "sat",
|
||||
newApiUserId: "1",
|
||||
newApiAggregatorBalance: true,
|
||||
baseUrl: "https://my-newapi.example.com",
|
||||
},
|
||||
})) as NewApiAggregatorQuota | null;
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "https://my-newapi.example.com/api/user/self");
|
||||
assert.ok(quota);
|
||||
assert.equal(quota.dollarBalance, 1);
|
||||
|
||||
invalidateNewApiAggregatorQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota marks quota exhausted when balance is zero", async () => {
|
||||
const connectionId = `agg-zero-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ data: { quota: 0 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
|
||||
const quota = (await fetchNewApiAggregatorQuota(connectionId, {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "sat",
|
||||
newApiUserId: "7",
|
||||
newApiAggregatorBalance: true,
|
||||
baseUrl: "https://my-newapi.example.com",
|
||||
},
|
||||
})) as NewApiAggregatorQuota | null;
|
||||
|
||||
assert.ok(quota);
|
||||
assert.equal(quota.limitReached, true);
|
||||
assert.equal(quota.percentUsed, 1);
|
||||
|
||||
invalidateNewApiAggregatorQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota honors custom quotaPerUnit override", async () => {
|
||||
const connectionId = `agg-custom-unit-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ data: { quota: 100 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
|
||||
const quota = (await fetchNewApiAggregatorQuota(connectionId, {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "sat",
|
||||
newApiUserId: "1",
|
||||
newApiAggregatorBalance: true,
|
||||
baseUrl: "https://my-newapi.example.com",
|
||||
quotaPerUnit: 100,
|
||||
},
|
||||
})) as NewApiAggregatorQuota | null;
|
||||
|
||||
assert.ok(quota);
|
||||
assert.equal(quota.rawQuota, 100);
|
||||
assert.equal(quota.dollarBalance, 1);
|
||||
|
||||
invalidateNewApiAggregatorQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota falls back to default 500000 when quotaPerUnit is invalid", async () => {
|
||||
const connectionId = `agg-invalid-unit-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ data: { quota: 500_000 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
|
||||
const quota = (await fetchNewApiAggregatorQuota(connectionId, {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "sat",
|
||||
newApiUserId: "1",
|
||||
newApiAggregatorBalance: true,
|
||||
baseUrl: "https://my-newapi.example.com",
|
||||
quotaPerUnit: 0, // invalid
|
||||
},
|
||||
})) as NewApiAggregatorQuota | null;
|
||||
|
||||
assert.ok(quota);
|
||||
assert.equal(quota.dollarBalance, 1); // 500000 / 500000 = 1
|
||||
|
||||
invalidateNewApiAggregatorQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota caches results within the TTL window", async () => {
|
||||
const connectionId = `agg-cache-${Date.now()}`;
|
||||
let callCount = 0;
|
||||
|
||||
globalThis.fetch = (async () => {
|
||||
callCount += 1;
|
||||
return new Response(JSON.stringify({ data: { quota: 100_000 } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const connection = {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "sat",
|
||||
newApiUserId: "1",
|
||||
newApiAggregatorBalance: true,
|
||||
baseUrl: "https://my-newapi.example.com",
|
||||
},
|
||||
};
|
||||
await fetchNewApiAggregatorQuota(connectionId, connection);
|
||||
await fetchNewApiAggregatorQuota(connectionId, connection);
|
||||
|
||||
assert.equal(callCount, 1);
|
||||
invalidateNewApiAggregatorQuotaCache(connectionId);
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota evicts cache on 401/403", async () => {
|
||||
const connectionId = `agg-401-${Date.now()}`;
|
||||
|
||||
globalThis.fetch = (async () => new Response(null, { status: 401 })) as typeof fetch;
|
||||
|
||||
const quota = await fetchNewApiAggregatorQuota(connectionId, {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "bad-token",
|
||||
newApiUserId: "1",
|
||||
newApiAggregatorBalance: true,
|
||||
baseUrl: "https://my-newapi.example.com",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(quota, null);
|
||||
});
|
||||
|
||||
test("fetchNewApiAggregatorQuota returns null when baseUrl is missing", async () => {
|
||||
const connectionId = `agg-nobaseurl-${Date.now()}`;
|
||||
|
||||
const quota = await fetchNewApiAggregatorQuota(connectionId, {
|
||||
providerSpecificData: {
|
||||
consoleApiKey: "sat",
|
||||
newApiUserId: "1",
|
||||
newApiAggregatorBalance: true,
|
||||
// baseUrl intentionally omitted
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(quota, null);
|
||||
});
|
||||
|
||||
test("isNewApiAggregatorBalanceConnection detects aggregator flag", () => {
|
||||
assert.equal(
|
||||
isNewApiAggregatorBalanceConnection({
|
||||
providerSpecificData: { newApiAggregatorBalance: true },
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
isNewApiAggregatorBalanceConnection({
|
||||
providerSpecificData: { newApiAggregatorBalance: false },
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isNewApiAggregatorBalanceConnection({
|
||||
providerSpecificData: {},
|
||||
}),
|
||||
false
|
||||
);
|
||||
assert.equal(isNewApiAggregatorBalanceConnection({}), false);
|
||||
assert.equal(isNewApiAggregatorBalanceConnection(), false);
|
||||
});
|
||||
@@ -275,3 +275,67 @@ test("provider schemas reject incomplete GLM team quota provider-specific values
|
||||
assert.equal(created.success, false);
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
test("provider schemas accept newApiAggregatorBalance boolean in providerSpecificData", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "openai-compatible-chat-abc",
|
||||
apiKey: "token",
|
||||
name: "My Aggregator",
|
||||
providerSpecificData: {
|
||||
newApiAggregatorBalance: true,
|
||||
quotaPerUnit: 500000,
|
||||
},
|
||||
});
|
||||
const updated = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: {
|
||||
newApiAggregatorBalance: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(created.success, true);
|
||||
assert.equal(updated.success, true);
|
||||
});
|
||||
|
||||
test("provider schemas reject non-boolean newApiAggregatorBalance", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "openai-compatible-chat-abc",
|
||||
apiKey: "token",
|
||||
name: "My Aggregator",
|
||||
providerSpecificData: {
|
||||
newApiAggregatorBalance: "yes",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(created.success, false);
|
||||
});
|
||||
|
||||
test("provider schemas reject invalid quotaPerUnit values", () => {
|
||||
const zero = createProviderSchema.safeParse({
|
||||
provider: "openai-compatible-chat-abc",
|
||||
apiKey: "token",
|
||||
name: "My Aggregator",
|
||||
providerSpecificData: {
|
||||
quotaPerUnit: 0,
|
||||
},
|
||||
});
|
||||
const negative = createProviderSchema.safeParse({
|
||||
provider: "openai-compatible-chat-abc",
|
||||
apiKey: "token",
|
||||
name: "My Aggregator",
|
||||
providerSpecificData: {
|
||||
quotaPerUnit: -100,
|
||||
},
|
||||
});
|
||||
const string = createProviderSchema.safeParse({
|
||||
provider: "openai-compatible-chat-abc",
|
||||
apiKey: "token",
|
||||
name: "My Aggregator",
|
||||
providerSpecificData: {
|
||||
quotaPerUnit: "500000",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(zero.success, false);
|
||||
assert.equal(negative.success, false);
|
||||
assert.equal(string.success, false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user