add new api probe

This commit is contained in:
musistudio 2026-07-07 20:29:32 +08:00
parent 52a6371600
commit c8cbc09b96
11 changed files with 559 additions and 27 deletions

View file

@ -160,6 +160,7 @@ export type ProviderAccountStatus = "ok" | "warning" | "critical" | "error" | "u
export type ProviderAccountMeterKind = "balance" | "subscription" | "quota" | "time_window" | "tokens" | "requests";
export type ProviderAccountMeterUnit = "USD" | "CNY" | "hours" | "minutes" | "tokens" | "requests" | string;
export type ProviderAccountMeterWindow = "5h" | "daily" | "weekly" | "monthly" | string;
export type ProviderAccountHttpJsonParser = "kimi-code-usages" | "new-api-key-usage" | "new-api-user-self";
export type ProviderAccountConfig = {
connectors?: ProviderAccountConnectorConfig[];
@ -193,7 +194,7 @@ export type ProviderAccountHttpJsonConnectorConfig = ProviderAccountConnectorBas
headers?: Record<string, string>;
mapping: ProviderAccountMappingConfig;
method?: "GET" | "POST";
parser?: "kimi-code-usages";
parser?: ProviderAccountHttpJsonParser;
type: "http-json";
};
@ -405,6 +406,8 @@ export type GatewayProviderCapability = {
type: GatewayProviderProtocol;
};
export type GatewayProviderDetectedProvider = "new-api";
export type GatewayProviderProbeRequest = {
apiKey?: string;
baseUrl: string;
@ -445,6 +448,7 @@ export type ProviderIconDetectionResult = {
export type GatewayProviderProbeProtocolResult = {
baseUrl?: string;
detectedProvider?: GatewayProviderDetectedProvider;
endpoint: string;
message: string;
protocol: GatewayProviderProtocol;
@ -453,7 +457,9 @@ export type GatewayProviderProbeProtocolResult = {
};
export type GatewayProviderProbeResult = {
account?: ProviderAccountConfig;
capabilities?: GatewayProviderCapability[];
detectedProvider?: GatewayProviderDetectedProvider;
detectedProtocol?: GatewayProviderProtocol;
modelDisplayNames?: Record<string, string>;
modelSource?: "anthropic" | "gemini" | "openai";

View file

@ -391,7 +391,7 @@ class GatewayService {
}
if (shouldRunGateway) {
await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.browserWebSearchMcpIntegration);
await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.coreAuthToken, this.browserWebSearchMcpIntegration);
await stopPreviousManagedCoreGateway(config, this.status.coreEndpoint);
if (await isCoreGatewayHealthy(this.status.coreEndpoint)) {
throw new Error(`Core gateway endpoint is already in use: ${this.status.coreEndpoint}`);
@ -1150,6 +1150,7 @@ export const gatewayService = new GatewayService();
async function writeCoreGatewayConfig(
config: AppConfig,
rawTraceSyncToken: string,
coreAuthToken: string,
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration
): Promise<void> {
assertLoopbackCoreHost(config.gateway.coreHost);
@ -1165,7 +1166,7 @@ async function writeCoreGatewayConfig(
...pluginService.getVirtualModelProfiles()
])), config);
const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort);
const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, browserWebSearchMcpIntegration);
const builtinToolArtifacts = await fusionBuiltinToolArtifacts(virtualModelProfiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration);
const providers = [
...config.Providers
.flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames)))
@ -1459,6 +1460,7 @@ function hasOwn(value: Record<string, unknown>, key: string): boolean {
async function fusionBuiltinToolArtifacts(
profiles: unknown[],
coreEndpoint: string,
coreAuthToken: string,
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration
): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> {
const providers: CoreGatewayProvider[] = [];
@ -1481,12 +1483,14 @@ async function fusionBuiltinToolArtifacts(
const toolServerKey = `vision:${visionConfig.toolName}`;
if (!toolServerKeys.has(toolServerKey)) {
toolServerKeys.add(toolServerKey);
const useGatewayVisionRuntime = !visionConfig.baseUrl;
mcpServers.push(fusionBuiltinMcpServer({
entry,
env: {
FUSION_BUILTIN_TOOL_KIND: "vision",
FUSION_TOOL_NAME: visionConfig.toolName,
...(visionConfig.baseUrl ? { VISION_BASE_URL: visionConfig.baseUrl } : { VISION_GATEWAY_BASE_URL: `${coreEndpoint}/v1` }),
...(useGatewayVisionRuntime ? { VISION_GATEWAY_BASE_URL: `${coreEndpoint}/v1` } : { VISION_BASE_URL: visionConfig.baseUrl || "" }),
...(useGatewayVisionRuntime && coreAuthToken ? { VISION_GATEWAY_API_KEY: coreAuthToken } : {}),
...(resolvedVision.model ? { VISION_MODEL: resolvedVision.model } : {}),
...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}),
...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {})
@ -1534,6 +1538,15 @@ async function fusionBuiltinToolArtifacts(
return { mcpServers, providers };
}
export async function fusionBuiltinToolArtifactsForTest(
profiles: unknown[],
coreEndpoint: string,
coreAuthToken: string,
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration
): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: unknown[] }> {
return fusionBuiltinToolArtifacts(profiles, coreEndpoint, coreAuthToken, browserWebSearchMcpIntegration);
}
function fusionBuiltinMcpServer({
entry,
env,

View file

@ -146,6 +146,26 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
status: statusFromMeters(meters, [], 1)
};
}
if (connector.parser === "new-api-key-usage") {
const meters = newApiKeyUsageMeters(payload);
return {
meters,
message: meters.length === 0 ? newApiKeyUsageFallbackMessage(payload) : readMappedString(connector.mapping.message, payload),
paths: flattenJsonPaths(payload),
payload,
status: statusFromMeters(meters, [], 1)
};
}
if (connector.parser === "new-api-user-self") {
const meters = newApiUserSelfMeters(payload);
return {
meters,
message: meters.length === 0 ? readMappedString(connector.mapping.message, payload) ?? "No user balance data available." : readMappedString(connector.mapping.message, payload),
paths: flattenJsonPaths(payload),
payload,
status: statusFromMeters(meters, [], 1)
};
}
const meters = mappedMetersFromPayload(connector, payload);
@ -158,6 +178,18 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
};
}
export function newApiKeyUsageMetersForTest(payload: unknown): ProviderAccountMeter[] {
return newApiKeyUsageMeters(payload);
}
export function newApiKeyUsageFallbackMessageForTest(payload: unknown): string {
return newApiKeyUsageFallbackMessage(payload);
}
export function newApiUserSelfMetersForTest(payload: unknown): ProviderAccountMeter[] {
return newApiUserSelfMeters(payload);
}
export async function resetCodexRateLimitCredit(request: ProviderAccountResetRequest): Promise<ProviderAccountResetResult> {
const providerName = request.provider?.trim();
const creditId = request.creditId?.trim();
@ -586,6 +618,24 @@ async function resolveHttpJsonConnector(
source: "http-json"
};
}
if (connector.parser === "new-api-key-usage") {
const meters = newApiKeyUsageMeters(payload);
return {
errors: [],
message: meters.length === 0 ? newApiKeyUsageFallbackMessage(payload) : readMappedString(connector.mapping.message, payload),
meters,
source: "http-json"
};
}
if (connector.parser === "new-api-user-self") {
const meters = newApiUserSelfMeters(payload);
return {
errors: [],
message: meters.length === 0 ? readMappedString(connector.mapping.message, payload) ?? "No user balance data available." : readMappedString(connector.mapping.message, payload),
meters,
source: "http-json"
};
}
const meters = mappedMetersFromPayload(connector, payload);
return {
@ -758,6 +808,90 @@ function normalizeRemoteSnapshot(
};
}
function newApiKeyUsageMeters(payload: unknown): ProviderAccountMeter[] {
const meter = newApiKeyUsageMeter(payload);
return meter ? [meter] : [];
}
function newApiKeyUsageMeter(payload: unknown): ProviderAccountMeter | undefined {
const data = newApiKeyUsageData(payload);
if (!data) {
return undefined;
}
const unlimited = readBoolean(readJsonRecordValue(data, "unlimited_quota")) === true;
const remaining = normalizeNumber(readJsonRecordValue(data, "total_available"));
const limit = normalizeNumber(readJsonRecordValue(data, "total_granted"));
const used = normalizeNumber(readJsonRecordValue(data, "total_used"));
if (unlimited || (limit === undefined && remaining === undefined && used === undefined)) {
return undefined;
}
return {
id: "new_api_key_quota",
kind: "quota",
label: "API key quota",
limit,
remaining,
source: "http-json",
unit: "quota",
used
};
}
function newApiKeyUsageFallbackMessage(payload: unknown): string {
const data = newApiKeyUsageData(payload);
if (data && readBoolean(readJsonRecordValue(data, "unlimited_quota")) === true) {
return "API key has no dedicated quota limit. Configure a New API user balance connector with an access token and New-Api-User to read account balance.";
}
return readMappedString("$.message", payload) ?? "No API key quota data available.";
}
function newApiUserSelfMeters(payload: unknown): ProviderAccountMeter[] {
const meter = newApiUserSelfMeter(payload);
return meter ? [meter] : [];
}
function newApiUserSelfMeter(payload: unknown): ProviderAccountMeter | undefined {
const data = newApiUserSelfData(payload);
if (!data) {
return undefined;
}
const remaining = normalizeNumber(readJsonRecordValue(data, "quota"));
const used = normalizeNumber(readJsonRecordValue(data, "used_quota"));
if (remaining === undefined && used === undefined) {
return undefined;
}
return {
id: "new_api_user_balance",
kind: "balance",
label: "User balance",
limit: remaining !== undefined && used !== undefined ? remaining + used : undefined,
remaining,
source: "http-json",
unit: "quota",
used
};
}
function newApiKeyUsageData(payload: unknown): Record<string, unknown> | undefined {
if (!isRecord(payload)) {
return undefined;
}
const data = readJsonRecordValue(payload, "data");
return isRecord(data) ? data : payload;
}
function newApiUserSelfData(payload: unknown): Record<string, unknown> | undefined {
if (!isRecord(payload)) {
return undefined;
}
const data = readJsonRecordValue(payload, "data");
return isRecord(data) ? data : payload;
}
function kimiCodeUsageMeters(payload: unknown): ProviderAccountMeter[] {
if (!isRecord(payload)) {
return [];

View file

@ -0,0 +1,92 @@
import type { ProviderAccountConfig, ProviderAccountHttpJsonConnectorConfig } from "@ccr/core/contracts/app";
import { compactProviderUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url";
export type DetectedProviderKind = "new-api";
const newApiHeaderNames = ["x-new-api-version", "x-oneapi-request-id"];
export function detectedProviderFromHeaders(headers: Record<string, string | undefined>): DetectedProviderKind | undefined {
return hasNewApiHeaders(headers) ? "new-api" : undefined;
}
export function hasNewApiHeaders(headers: Record<string, string | undefined>): boolean {
const normalized = new Set(Object.keys(headers).map((key) => key.toLowerCase()));
return newApiHeaderNames.some((header) => normalized.has(header));
}
export function newApiKeyUsageAccountConfig(baseUrl: string): ProviderAccountConfig {
return {
connectors: [
{
auth: "provider-api-key",
endpoint: newApiKeyUsageEndpoint(baseUrl),
mapping: {
message: "$.message",
meters: [
{
id: "new_api_key_quota",
kind: "quota",
label: "API key quota",
limit: "$.data.total_granted",
remaining: "$.data.total_available",
unit: "quota",
used: "$.data.total_used",
}
]
},
method: "GET",
parser: "new-api-key-usage",
type: "http-json"
}
],
enabled: true
};
}
export function newApiKeyUsageEndpoint(baseUrl: string): string {
const root = newApiRootBaseUrl(baseUrl);
return `${root}/api/usage/token/`;
}
export function newApiUserSelfConnectorConfig(baseUrl: string): ProviderAccountHttpJsonConnectorConfig {
return {
auth: "none",
endpoint: newApiUserSelfEndpoint(baseUrl),
headers: {
Authorization: "Bearer <new-api-access-token>",
"New-Api-User": "<user-id>"
},
mapping: {
meters: [
{
id: "new_api_user_balance",
kind: "balance",
label: "User balance",
remaining: "$.data.quota",
unit: "quota",
used: "$.data.used_quota"
}
]
},
method: "GET",
parser: "new-api-user-self",
type: "http-json"
};
}
export function newApiUserSelfEndpoint(baseUrl: string): string {
const root = newApiRootBaseUrl(baseUrl);
return `${root}/api/user/self`;
}
export function newApiRootBaseUrl(baseUrl: string): string {
try {
const url = new URL(providerUrlWithDefaultScheme(baseUrl.trim()));
url.pathname = url.pathname.replace(/\/+(v1|api)$/i, "").replace(/\/+$/, "") || "/";
url.search = "";
url.hash = "";
return compactProviderUrl(url);
} catch {
return baseUrl.trim().replace(/[?#].*$/, "").replace(/\/+$/, "").replace(/\/(v1|api)$/i, "");
}
}

View file

@ -19,6 +19,11 @@ import {
providerBaseUrlForProtocol,
type ParsedProviderBaseUrl
} from "@ccr/core/providers/url";
import {
detectedProviderFromHeaders,
newApiKeyUsageAccountConfig,
type DetectedProviderKind
} from "@ccr/core/providers/new-api";
type ModelSource = NonNullable<GatewayProviderProbeResult["modelSource"]>;
@ -27,6 +32,8 @@ type ParsedProviderUrl = ParsedProviderBaseUrl & {
};
type FetchJsonResult = {
detectedProvider?: DetectedProviderKind;
headers?: Record<string, string>;
payload?: unknown;
status?: number;
text: string;
@ -226,16 +233,21 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest)
: typedModels;
const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols, mode);
const detectedProtocol = detectProtocol(parsed, protocolResults, modelProbe.source, protocols);
const normalizedBaseUrl = detectedProtocol
? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe)
: parsed.normalizedInputBaseUrl;
const detectedProvider = detectProvider(protocolResults);
const account = detectedProvider === "new-api" ? newApiKeyUsageAccountConfig(normalizedBaseUrl) : undefined;
return {
...(account ? { account } : {}),
capabilities: capabilitiesFromProtocolResults(protocolResults),
...(detectedProvider ? { detectedProvider } : {}),
detectedProtocol,
modelDisplayNames: modelProbe.modelDisplayNames,
modelSource: modelProbe.source,
models: modelProbe.models,
normalizedBaseUrl: detectedProtocol
? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe)
: parsed.normalizedInputBaseUrl,
normalizedBaseUrl,
protocols: protocolResults
};
}
@ -491,6 +503,7 @@ async function probeProtocolSupport(
const supported = isProviderProtocolEndpointSupportedForProbe(result.status, message, protocol, parsed.hints);
const probeResult = {
baseUrl: candidate.baseUrl,
...(result.detectedProvider ? { detectedProvider: result.detectedProvider } : {}),
endpoint: candidate.endpoint,
message,
protocol,
@ -539,6 +552,7 @@ async function probeProtocolConnectivity(
const supported = isProtocolSupported(result.status, message, protocol);
const probeResult = {
baseUrl: candidate.baseUrl,
...(result.detectedProvider ? { detectedProvider: result.detectedProvider } : {}),
endpoint: candidate.endpoint,
message,
protocol,
@ -663,7 +677,10 @@ async function requestJson(url: string, init: RequestInit): Promise<FetchJsonRes
signal: controller.signal
});
const text = await response.text();
const headers = responseHeadersRecord(response.headers);
return {
detectedProvider: detectedProviderFromHeaders(headers),
headers,
payload: parseJson(text),
status: response.status,
text
@ -677,6 +694,18 @@ async function requestJson(url: string, init: RequestInit): Promise<FetchJsonRes
}
}
function detectProvider(protocols: GatewayProviderProbeProtocolResult[]): DetectedProviderKind | undefined {
return protocols.find((item) => item.detectedProvider)?.detectedProvider;
}
function responseHeadersRecord(headers: Headers): Record<string, string> {
const record: Record<string, string> = {};
headers.forEach((value, key) => {
record[key] = value;
});
return record;
}
function parseProviderUrl(value: string): ParsedProviderUrl {
const parsed = parseProviderBaseUrl(value);
const url = new URL(parsed.normalizedInputBaseUrl);

View file

@ -9,7 +9,7 @@ import {
Layers3, LoaderCircle, localAgentProviderIconUrls, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion,
Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint,
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot,
providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft,
ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol,
providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
@ -2085,7 +2085,11 @@ function ProviderUsageSettings({
const [testLoading, setTestLoading] = useState(false);
const [testResult, setTestResult] = useState<ProviderAccountTestResult>();
const [testError, setTestError] = useState("");
const [newApiUserId, setNewApiUserId] = useState("");
const modeOptions = translateOptions(providerAccountModeOptions, t);
const showNewApiUserBalanceTemplate = probe?.detectedProvider === "new-api" ||
draft.accountConnectorsText.includes("new-api-key-usage") ||
draft.accountConnectorsText.includes("new-api-user-self");
useEffect(() => {
setTestResult(undefined);
@ -2135,6 +2139,16 @@ function ProviderUsageSettings({
onChange(providerUsageFieldPatch(target, path));
}
function insertNewApiUserBalanceTemplate() {
onChange({
accountConnectorsText: providerAccountConnectorsTextWithNewApiUserBalanceTemplate(
draft.accountConnectorsText,
probe?.normalizedBaseUrl || draft.baseUrl,
newApiUserId
)
});
}
return (
<div className="sm:col-span-2 space-y-3 rounded-md border border-border bg-background/60 p-3">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
@ -2253,7 +2267,8 @@ function ProviderUsageSettings({
) : null}
{draft.accountMode === "raw" ? (
<Field className="sm:col-span-2" label={t("Connectors JSON")}>
<div className="sm:col-span-2 space-y-2">
<Field label={t("Connectors JSON")}>
<Textarea
className="min-h-[180px] font-mono text-[11px]"
value={draft.accountConnectorsText}
@ -2270,6 +2285,22 @@ function ProviderUsageSettings({
</button>
</div>
</Field>
{showNewApiUserBalanceTemplate ? (
<div className="grid grid-cols-1 items-end gap-2 rounded-md border border-border/60 bg-muted/20 p-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<div className="min-w-0 space-y-1">
<Label className="text-[11px] font-medium text-muted-foreground">{t("New API user ID")}</Label>
<Input
placeholder="<user-id>"
value={newApiUserId}
onChange={(event) => setNewApiUserId(event.target.value)}
/>
</div>
<Button size="sm" type="button" variant="outline" onClick={insertNewApiUserBalanceTemplate}>
{t("Insert New API user balance")}
</Button>
</div>
) : null}
</div>
) : null}
</div>
) : null}

View file

@ -1229,6 +1229,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
"Usage Trend": "用量趋势",
"User idle": "用户空闲",
"Insert example": "插入示例",
"Insert New API user balance": "插入 New API 用户余额",
"New API user ID": "New API 用户 ID",
"Virtual model": "Fusion",
"Virtual Models": "Fusion",
"Add MCP Server": "添加 MCP 服务",

View file

@ -252,6 +252,7 @@ import {
providerEndpointCanReceiveProviderApiKeyInList,
providerIdentitySafetyIssueInList
} from "@ccr/core/providers/presets/utils";
import { newApiUserSelfConnectorConfig } from "@ccr/core/providers/new-api";
import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url";
import {
fallbackConfig,
@ -899,7 +900,8 @@ export function createProviderConfigFromDeepLink(
if (identityIssue) {
throw new Error(identityIssue.message);
}
const accountKeySafetyIssue = providerAccountApiKeySafetyIssue(payload.account, {
const account = payload.account ?? probe?.account ?? defaultProviderAccountConfigForBaseUrl(baseUrl);
const accountKeySafetyIssue = providerAccountApiKeySafetyIssue(account, {
apiKey: payload.apiKey,
baseUrl,
providerName: name
@ -914,7 +916,7 @@ export function createProviderConfigFromDeepLink(
);
return {
account: payload.account ? cloneProviderAccountConfig(payload.account) : defaultProviderAccountConfigForBaseUrl(baseUrl),
account: cloneProviderAccountConfig(account),
api_base_url: normalizeProviderBaseUrl(baseUrl, protocol),
api_key: apiKey,
capabilities: capabilities.length > 0 ? capabilities : undefined,
@ -1262,6 +1264,15 @@ export function createProviderAccountDraftFromConfig(account: ProviderAccountCon
accountRefreshIntervalMs: account.refreshIntervalMs ? String(account.refreshIntervalMs) : ""
};
}
if (httpJsonConnector.parser) {
return {
...base,
accountConnectorsText: JSON.stringify(connectors, null, 2),
accountEnabled: account.enabled === true,
accountMode: "raw",
accountRefreshIntervalMs: account.refreshIntervalMs ? String(account.refreshIntervalMs) : ""
};
}
const balanceMeter = httpJsonConnector.mapping.meters.find((meter) => meter.kind === "balance" || meter.id === "balance");
const subscriptionMeter = httpJsonConnector.mapping.meters.find((meter) =>
@ -1609,6 +1620,31 @@ export function providerAccountConnectorExample(): string {
], null, 2);
}
export function providerAccountConnectorsTextWithNewApiUserBalanceTemplate(connectorsText: string, baseUrl: string, userId: string): string {
const connector = newApiUserSelfConnectorConfig(baseUrl.trim() || "https://new-api.example/v1");
connector.headers = {
...(connector.headers ?? {}),
"New-Api-User": userId.trim() || "<user-id>"
};
let connectors: unknown[] = [];
try {
const parsed = JSON.parse(connectorsText.trim() || "[]") as unknown;
connectors = Array.isArray(parsed) ? parsed : [];
} catch {
connectors = [];
}
return JSON.stringify([
...connectors.filter((item) => !isNewApiUserSelfConnector(item)),
connector
], null, 2);
}
function isNewApiUserSelfConnector(value: unknown): boolean {
return isPlainRecord(value) && value.type === "http-json" && value.parser === "new-api-user-self";
}
export function toProviderProtocol(value: string | undefined): GatewayProviderProtocol | undefined {
return providerProtocolOptions.some((option) => option.value === value) ? value as GatewayProviderProtocol : undefined;
}
@ -1845,10 +1881,12 @@ export function applyProviderProbeResult(draft: AddProviderDraft, probe: Gateway
const protocol = probe.detectedProtocol ?? draft.protocol;
const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, protocol, draft.presetId);
const modelDisplayNames = mergeModelDisplayNames(draft.modelDisplayNames, probe.modelDisplayNames);
const accountDraft = providerProbeAccountDraftPatch(draft, probe.account);
if (probe.models.length === 0) {
return {
...draft,
...accountDraft,
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
modelDisplayNames,
protocol,
@ -1871,6 +1909,7 @@ export function applyProviderProbeResult(draft: AddProviderDraft, probe: Gateway
return {
...draft,
...accountDraft,
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
modelDisplayNames,
protocol,
@ -1880,6 +1919,16 @@ export function applyProviderProbeResult(draft: AddProviderDraft, probe: Gateway
};
}
function providerProbeAccountDraftPatch(
draft: AddProviderDraft,
account: ProviderAccountConfig | undefined
): Partial<AddProviderDraft> {
if (!account || (draft.accountEnabled && draft.accountMode !== "standard")) {
return {};
}
return createProviderAccountDraftFromConfig(account);
}
export function mergeModelDisplayNames(
...groups: Array<Record<string, string> | undefined>
): Record<string, string> | undefined {

View file

@ -5,6 +5,7 @@ import {
fusionFallbackToolDefinitions,
fusionWebSearchToolNameForRequest,
fusionToolNamesBackedByMcpServers,
fusionBuiltinToolArtifactsForTest,
extractHostedWebSearchQueryHint,
hostedWebSearchProtocolResponseStream,
prepareGatewayUpstreamAttemptForTest,
@ -80,6 +81,56 @@ test("gateway config rewrites Fusion fixed base and vision models to core provid
assert.equal(profiles[0].baseModel.fixedModel, `${providerName}/glm-5.2`);
});
test("gateway config injects core auth token into Fusion vision MCP gateway runtime", async () => {
const profiles = [
{
enabled: true,
id: "glm-vision",
key: "glm-vision",
metadata: {
fusionVision: {
modelSelector: "VisionProvider/glm-5v",
toolName: "vision_understand_glm"
}
}
}
];
const artifacts = await fusionBuiltinToolArtifactsForTest(profiles, "http://127.0.0.1:3457", "core-token");
const server = artifacts.mcpServers.find((item) => item.name === "fusion-vision-glm-vision");
assert.ok(server);
assert.equal(server.env.VISION_GATEWAY_BASE_URL, "http://127.0.0.1:3457/v1");
assert.equal(server.env.VISION_GATEWAY_API_KEY, "core-token");
assert.equal(server.env.VISION_API_KEY, undefined);
});
test("gateway config does not inject core auth token into external Fusion vision MCP runtime", async () => {
const profiles = [
{
enabled: true,
id: "external-vision",
key: "external-vision",
metadata: {
fusionVision: {
apiKey: "external-key",
baseUrl: "https://vision.example/v1",
model: "gpt-vision",
toolName: "vision_understand_external"
}
}
}
];
const artifacts = await fusionBuiltinToolArtifactsForTest(profiles, "http://127.0.0.1:3457", "core-token");
const server = artifacts.mcpServers.find((item) => item.name === "fusion-vision-external-vision");
assert.ok(server);
assert.equal(server.env.VISION_BASE_URL, "https://vision.example/v1");
assert.equal(server.env.VISION_API_KEY, "external-key");
assert.equal(server.env.VISION_GATEWAY_API_KEY, undefined);
});
test("gateway ignores non-Gemini capabilities on Gemini preset providers", () => {
const providerName = "Google Gemini";
const config = {

View file

@ -1,5 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
newApiKeyUsageFallbackMessageForTest,
newApiKeyUsageMetersForTest,
newApiUserSelfMetersForTest
} from "../../packages/core/src/providers/account-service.ts";
import { detectedProviderFromHeaders, newApiKeyUsageAccountConfig, newApiUserSelfConnectorConfig } from "../../packages/core/src/providers/new-api.ts";
import { isProviderProtocolEndpointSupportedForProbe } from "../../packages/core/src/providers/probe.ts";
test("protocol support probe does not treat Gemini auth errors as every protocol", () => {
@ -62,3 +68,64 @@ test("protocol support probe treats Gemini Interactions input validation as Inte
false
);
});
test("New API response headers enable key quota account connector", () => {
assert.equal(detectedProviderFromHeaders({ "X-New-Api-Version": "0.8.0" }), "new-api");
assert.equal(detectedProviderFromHeaders({ "x-oneapi-request-id": "req-1" }), "new-api");
assert.equal(detectedProviderFromHeaders({ "content-type": "application/json" }), undefined);
const account = newApiKeyUsageAccountConfig("https://gateway.example/v1");
const connector = account.connectors?.[0];
assert.equal(account.enabled, true);
assert.equal(connector?.type, "http-json");
assert.equal(connector?.endpoint, "https://gateway.example/api/usage/token/");
assert.equal(connector?.parser, "new-api-key-usage");
assert.equal(connector?.mapping.meters[0]?.kind, "quota");
assert.equal(connector?.mapping.meters[0]?.remaining, "$.data.total_available");
assert.equal(connector?.mapping.meters[0]?.limit, "$.data.total_granted");
assert.equal(connector?.mapping.meters[0]?.used, "$.data.total_used");
const userBalanceConnector = newApiUserSelfConnectorConfig("https://gateway.example/v1");
assert.equal(userBalanceConnector.endpoint, "https://gateway.example/api/user/self");
assert.equal(userBalanceConnector.parser, "new-api-user-self");
assert.equal(userBalanceConnector.headers?.["New-Api-User"], "<user-id>");
});
test("New API key usage parser ignores keys without a dedicated quota", () => {
const payload = {
code: true,
data: {
name: "default",
total_available: 0,
total_granted: 0,
total_used: 0,
unlimited_quota: true
},
message: "ok"
};
assert.deepEqual(newApiKeyUsageMetersForTest(payload), []);
assert.match(newApiKeyUsageFallbackMessageForTest(payload), /no dedicated quota/i);
});
test("New API user self parser returns user balance", () => {
const payload = {
data: {
quota: 1250,
used_quota: 250
},
message: "",
success: true
};
assert.deepEqual(newApiUserSelfMetersForTest(payload), [{
id: "new_api_user_balance",
kind: "balance",
label: "User balance",
limit: 1500,
remaining: 1250,
source: "http-json",
unit: "quota",
used: 250
}]);
});

View file

@ -1,9 +1,11 @@
import assert from "node:assert/strict";
import test from "node:test";
import { newApiKeyUsageAccountConfig } from "../../packages/core/src/providers/new-api.ts";
import { geminiProviderPreset } from "../../packages/core/src/providers/presets/gemini/index.ts";
import {
applyProviderProbeResult,
createProviderDraft,
providerAccountConnectorsTextWithNewApiUserBalanceTemplate,
providerProtocolOptions,
providerProbeCandidates,
setProviderPresets
@ -71,3 +73,59 @@ test("provider probe result keeps only supported selected protocols", () => {
assert.deepEqual(next.selectedProtocols, ["gemini_generate_content"]);
});
test("provider probe result applies detected New API key quota account connector", () => {
const draft = {
...createProviderDraft([]),
accountEnabled: false,
baseUrl: "https://gateway.example/v1",
protocol: "openai_chat_completions"
};
const account = newApiKeyUsageAccountConfig("https://gateway.example/v1");
const next = applyProviderProbeResult(draft, {
account,
detectedProvider: "new-api",
detectedProtocol: "openai_chat_completions",
models: [],
normalizedBaseUrl: draft.baseUrl,
protocols: [
{
detectedProvider: "new-api",
endpoint: "https://gateway.example/v1/chat/completions",
message: "HTTP 401",
protocol: "openai_chat_completions",
status: 401,
supported: true
}
]
});
assert.equal(next.accountEnabled, true);
assert.equal(next.accountMode, "raw");
const connectors = JSON.parse(next.accountConnectorsText);
assert.equal(connectors.length, 1);
assert.equal(connectors[0].endpoint, "https://gateway.example/api/usage/token/");
assert.equal(connectors[0].parser, "new-api-key-usage");
assert.equal(connectors[0].mapping.meters[0].id, "new_api_key_quota");
assert.equal(connectors[0].mapping.meters[0].kind, "quota");
assert.equal(connectors[0].mapping.meters[0].remaining, "$.data.total_available");
});
test("New API user balance template adds configurable user self connector", () => {
const account = newApiKeyUsageAccountConfig("https://gateway.example/v1");
const text = providerAccountConnectorsTextWithNewApiUserBalanceTemplate(
JSON.stringify(account.connectors ?? [], null, 2),
"https://gateway.example/v1",
"42"
);
const connectors = JSON.parse(text);
assert.equal(connectors.length, 2);
assert.equal(connectors[0].parser, "new-api-key-usage");
assert.equal(connectors[1].endpoint, "https://gateway.example/api/user/self");
assert.equal(connectors[1].parser, "new-api-user-self");
assert.equal(connectors[1].headers.Authorization, "Bearer <new-api-access-token>");
assert.equal(connectors[1].headers["New-Api-User"], "42");
assert.equal(connectors[1].mapping.meters[0].id, "new_api_user_balance");
});