mirror of
https://github.com/musistudio/claude-code-router.git
synced 2026-07-09 17:18:24 +00:00
add new api probe
This commit is contained in:
parent
52a6371600
commit
c8cbc09b96
11 changed files with 559 additions and 27 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
92
packages/core/src/providers/new-api.ts
Normal file
92
packages/core/src/providers/new-api.ts
Normal 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, "");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,23 +2267,40 @@ function ProviderUsageSettings({
|
|||
) : null}
|
||||
|
||||
{draft.accountMode === "raw" ? (
|
||||
<Field className="sm:col-span-2" label={t("Connectors JSON")}>
|
||||
<Textarea
|
||||
className="min-h-[180px] font-mono text-[11px]"
|
||||
value={draft.accountConnectorsText}
|
||||
onChange={(event) => onChange({ accountConnectorsText: event.target.value })}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{t("Supports standard, http-json, plugin, and local-estimate connectors.")}</span>
|
||||
<button
|
||||
className="shrink-0 text-primary hover:underline"
|
||||
type="button"
|
||||
onClick={() => onChange({ accountConnectorsText: providerAccountConnectorExample() })}
|
||||
>
|
||||
{t("Insert example")}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
<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}
|
||||
onChange={(event) => onChange({ accountConnectorsText: event.target.value })}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{t("Supports standard, http-json, plugin, and local-estimate connectors.")}</span>
|
||||
<button
|
||||
className="shrink-0 text-primary hover:underline"
|
||||
type="button"
|
||||
onClick={() => onChange({ accountConnectorsText: providerAccountConnectorExample() })}
|
||||
>
|
||||
{t("Insert example")}
|
||||
</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}
|
||||
|
|
|
|||
|
|
@ -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 服务",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue