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 ProviderAccountMeterKind = "balance" | "subscription" | "quota" | "time_window" | "tokens" | "requests";
|
||||||
export type ProviderAccountMeterUnit = "USD" | "CNY" | "hours" | "minutes" | "tokens" | "requests" | string;
|
export type ProviderAccountMeterUnit = "USD" | "CNY" | "hours" | "minutes" | "tokens" | "requests" | string;
|
||||||
export type ProviderAccountMeterWindow = "5h" | "daily" | "weekly" | "monthly" | 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 = {
|
export type ProviderAccountConfig = {
|
||||||
connectors?: ProviderAccountConnectorConfig[];
|
connectors?: ProviderAccountConnectorConfig[];
|
||||||
|
|
@ -193,7 +194,7 @@ export type ProviderAccountHttpJsonConnectorConfig = ProviderAccountConnectorBas
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
mapping: ProviderAccountMappingConfig;
|
mapping: ProviderAccountMappingConfig;
|
||||||
method?: "GET" | "POST";
|
method?: "GET" | "POST";
|
||||||
parser?: "kimi-code-usages";
|
parser?: ProviderAccountHttpJsonParser;
|
||||||
type: "http-json";
|
type: "http-json";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -405,6 +406,8 @@ export type GatewayProviderCapability = {
|
||||||
type: GatewayProviderProtocol;
|
type: GatewayProviderProtocol;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GatewayProviderDetectedProvider = "new-api";
|
||||||
|
|
||||||
export type GatewayProviderProbeRequest = {
|
export type GatewayProviderProbeRequest = {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
|
@ -445,6 +448,7 @@ export type ProviderIconDetectionResult = {
|
||||||
|
|
||||||
export type GatewayProviderProbeProtocolResult = {
|
export type GatewayProviderProbeProtocolResult = {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
|
detectedProvider?: GatewayProviderDetectedProvider;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
message: string;
|
message: string;
|
||||||
protocol: GatewayProviderProtocol;
|
protocol: GatewayProviderProtocol;
|
||||||
|
|
@ -453,7 +457,9 @@ export type GatewayProviderProbeProtocolResult = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayProviderProbeResult = {
|
export type GatewayProviderProbeResult = {
|
||||||
|
account?: ProviderAccountConfig;
|
||||||
capabilities?: GatewayProviderCapability[];
|
capabilities?: GatewayProviderCapability[];
|
||||||
|
detectedProvider?: GatewayProviderDetectedProvider;
|
||||||
detectedProtocol?: GatewayProviderProtocol;
|
detectedProtocol?: GatewayProviderProtocol;
|
||||||
modelDisplayNames?: Record<string, string>;
|
modelDisplayNames?: Record<string, string>;
|
||||||
modelSource?: "anthropic" | "gemini" | "openai";
|
modelSource?: "anthropic" | "gemini" | "openai";
|
||||||
|
|
|
||||||
|
|
@ -391,7 +391,7 @@ class GatewayService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRunGateway) {
|
if (shouldRunGateway) {
|
||||||
await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.browserWebSearchMcpIntegration);
|
await writeCoreGatewayConfig(config, this.rawTraceSyncToken, this.coreAuthToken, this.browserWebSearchMcpIntegration);
|
||||||
await stopPreviousManagedCoreGateway(config, this.status.coreEndpoint);
|
await stopPreviousManagedCoreGateway(config, this.status.coreEndpoint);
|
||||||
if (await isCoreGatewayHealthy(this.status.coreEndpoint)) {
|
if (await isCoreGatewayHealthy(this.status.coreEndpoint)) {
|
||||||
throw new Error(`Core gateway endpoint is already in use: ${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(
|
async function writeCoreGatewayConfig(
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
rawTraceSyncToken: string,
|
rawTraceSyncToken: string,
|
||||||
|
coreAuthToken: string,
|
||||||
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration
|
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
assertLoopbackCoreHost(config.gateway.coreHost);
|
assertLoopbackCoreHost(config.gateway.coreHost);
|
||||||
|
|
@ -1165,7 +1166,7 @@ async function writeCoreGatewayConfig(
|
||||||
...pluginService.getVirtualModelProfiles()
|
...pluginService.getVirtualModelProfiles()
|
||||||
])), config);
|
])), config);
|
||||||
const coreEndpoint = endpoint(config.gateway.coreHost, config.gateway.corePort);
|
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 = [
|
const providers = [
|
||||||
...config.Providers
|
...config.Providers
|
||||||
.flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames)))
|
.flatMap((provider) => toCoreGatewayProviders(withCodexOauthProviderBaseUrl(provider, codexOauthProviderNames)))
|
||||||
|
|
@ -1459,6 +1460,7 @@ function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
||||||
async function fusionBuiltinToolArtifacts(
|
async function fusionBuiltinToolArtifacts(
|
||||||
profiles: unknown[],
|
profiles: unknown[],
|
||||||
coreEndpoint: string,
|
coreEndpoint: string,
|
||||||
|
coreAuthToken: string,
|
||||||
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration
|
browserWebSearchMcpIntegration?: BrowserWebSearchMcpIntegration
|
||||||
): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> {
|
): Promise<{ mcpServers: GatewayMcpServerConfig[]; providers: CoreGatewayProvider[] }> {
|
||||||
const providers: CoreGatewayProvider[] = [];
|
const providers: CoreGatewayProvider[] = [];
|
||||||
|
|
@ -1481,12 +1483,14 @@ async function fusionBuiltinToolArtifacts(
|
||||||
const toolServerKey = `vision:${visionConfig.toolName}`;
|
const toolServerKey = `vision:${visionConfig.toolName}`;
|
||||||
if (!toolServerKeys.has(toolServerKey)) {
|
if (!toolServerKeys.has(toolServerKey)) {
|
||||||
toolServerKeys.add(toolServerKey);
|
toolServerKeys.add(toolServerKey);
|
||||||
|
const useGatewayVisionRuntime = !visionConfig.baseUrl;
|
||||||
mcpServers.push(fusionBuiltinMcpServer({
|
mcpServers.push(fusionBuiltinMcpServer({
|
||||||
entry,
|
entry,
|
||||||
env: {
|
env: {
|
||||||
FUSION_BUILTIN_TOOL_KIND: "vision",
|
FUSION_BUILTIN_TOOL_KIND: "vision",
|
||||||
FUSION_TOOL_NAME: visionConfig.toolName,
|
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 } : {}),
|
...(resolvedVision.model ? { VISION_MODEL: resolvedVision.model } : {}),
|
||||||
...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}),
|
...(visionConfig.baseUrl && visionConfig.apiKey ? { VISION_API_KEY: visionConfig.apiKey } : {}),
|
||||||
...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {})
|
...(visionConfig.timeoutMs ? { VISION_TIMEOUT_MS: String(visionConfig.timeoutMs) } : {})
|
||||||
|
|
@ -1534,6 +1538,15 @@ async function fusionBuiltinToolArtifacts(
|
||||||
return { mcpServers, providers };
|
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({
|
function fusionBuiltinMcpServer({
|
||||||
entry,
|
entry,
|
||||||
env,
|
env,
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,26 @@ export async function testProviderAccountConnector(request: ProviderAccountTestR
|
||||||
status: statusFromMeters(meters, [], 1)
|
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);
|
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> {
|
export async function resetCodexRateLimitCredit(request: ProviderAccountResetRequest): Promise<ProviderAccountResetResult> {
|
||||||
const providerName = request.provider?.trim();
|
const providerName = request.provider?.trim();
|
||||||
const creditId = request.creditId?.trim();
|
const creditId = request.creditId?.trim();
|
||||||
|
|
@ -586,6 +618,24 @@ async function resolveHttpJsonConnector(
|
||||||
source: "http-json"
|
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);
|
const meters = mappedMetersFromPayload(connector, payload);
|
||||||
return {
|
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[] {
|
function kimiCodeUsageMeters(payload: unknown): ProviderAccountMeter[] {
|
||||||
if (!isRecord(payload)) {
|
if (!isRecord(payload)) {
|
||||||
return [];
|
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,
|
providerBaseUrlForProtocol,
|
||||||
type ParsedProviderBaseUrl
|
type ParsedProviderBaseUrl
|
||||||
} from "@ccr/core/providers/url";
|
} from "@ccr/core/providers/url";
|
||||||
|
import {
|
||||||
|
detectedProviderFromHeaders,
|
||||||
|
newApiKeyUsageAccountConfig,
|
||||||
|
type DetectedProviderKind
|
||||||
|
} from "@ccr/core/providers/new-api";
|
||||||
|
|
||||||
type ModelSource = NonNullable<GatewayProviderProbeResult["modelSource"]>;
|
type ModelSource = NonNullable<GatewayProviderProbeResult["modelSource"]>;
|
||||||
|
|
||||||
|
|
@ -27,6 +32,8 @@ type ParsedProviderUrl = ParsedProviderBaseUrl & {
|
||||||
};
|
};
|
||||||
|
|
||||||
type FetchJsonResult = {
|
type FetchJsonResult = {
|
||||||
|
detectedProvider?: DetectedProviderKind;
|
||||||
|
headers?: Record<string, string>;
|
||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
status?: number;
|
status?: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -226,16 +233,21 @@ async function resolveGatewayProviderProbe(request: GatewayProviderProbeRequest)
|
||||||
: typedModels;
|
: typedModels;
|
||||||
const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols, mode);
|
const protocolResults = await probeProtocols(parsed, request.apiKey, models, protocols, mode);
|
||||||
const detectedProtocol = detectProtocol(parsed, protocolResults, modelProbe.source, protocols);
|
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 {
|
return {
|
||||||
|
...(account ? { account } : {}),
|
||||||
capabilities: capabilitiesFromProtocolResults(protocolResults),
|
capabilities: capabilitiesFromProtocolResults(protocolResults),
|
||||||
|
...(detectedProvider ? { detectedProvider } : {}),
|
||||||
detectedProtocol,
|
detectedProtocol,
|
||||||
modelDisplayNames: modelProbe.modelDisplayNames,
|
modelDisplayNames: modelProbe.modelDisplayNames,
|
||||||
modelSource: modelProbe.source,
|
modelSource: modelProbe.source,
|
||||||
models: modelProbe.models,
|
models: modelProbe.models,
|
||||||
normalizedBaseUrl: detectedProtocol
|
normalizedBaseUrl,
|
||||||
? resolveProbeBaseUrl(parsed, detectedProtocol, protocolResults, modelProbe)
|
|
||||||
: parsed.normalizedInputBaseUrl,
|
|
||||||
protocols: protocolResults
|
protocols: protocolResults
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -491,6 +503,7 @@ async function probeProtocolSupport(
|
||||||
const supported = isProviderProtocolEndpointSupportedForProbe(result.status, message, protocol, parsed.hints);
|
const supported = isProviderProtocolEndpointSupportedForProbe(result.status, message, protocol, parsed.hints);
|
||||||
const probeResult = {
|
const probeResult = {
|
||||||
baseUrl: candidate.baseUrl,
|
baseUrl: candidate.baseUrl,
|
||||||
|
...(result.detectedProvider ? { detectedProvider: result.detectedProvider } : {}),
|
||||||
endpoint: candidate.endpoint,
|
endpoint: candidate.endpoint,
|
||||||
message,
|
message,
|
||||||
protocol,
|
protocol,
|
||||||
|
|
@ -539,6 +552,7 @@ async function probeProtocolConnectivity(
|
||||||
const supported = isProtocolSupported(result.status, message, protocol);
|
const supported = isProtocolSupported(result.status, message, protocol);
|
||||||
const probeResult = {
|
const probeResult = {
|
||||||
baseUrl: candidate.baseUrl,
|
baseUrl: candidate.baseUrl,
|
||||||
|
...(result.detectedProvider ? { detectedProvider: result.detectedProvider } : {}),
|
||||||
endpoint: candidate.endpoint,
|
endpoint: candidate.endpoint,
|
||||||
message,
|
message,
|
||||||
protocol,
|
protocol,
|
||||||
|
|
@ -663,7 +677,10 @@ async function requestJson(url: string, init: RequestInit): Promise<FetchJsonRes
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
const headers = responseHeadersRecord(response.headers);
|
||||||
return {
|
return {
|
||||||
|
detectedProvider: detectedProviderFromHeaders(headers),
|
||||||
|
headers,
|
||||||
payload: parseJson(text),
|
payload: parseJson(text),
|
||||||
status: response.status,
|
status: response.status,
|
||||||
text
|
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 {
|
function parseProviderUrl(value: string): ParsedProviderUrl {
|
||||||
const parsed = parseProviderBaseUrl(value);
|
const parsed = parseProviderBaseUrl(value);
|
||||||
const url = new URL(parsed.normalizedInputBaseUrl);
|
const url = new URL(parsed.normalizedInputBaseUrl);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
Layers3, LoaderCircle, localAgentProviderIconUrls, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion,
|
Layers3, LoaderCircle, localAgentProviderIconUrls, mergeProviderModelLists, modelCatalogItemMatchesQuery, motion,
|
||||||
Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint,
|
Pencil, Plus, PopoverContent, primaryProviderAccountMeter, primaryProviderPresetEndpoint,
|
||||||
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot,
|
providerAccountConnectorApiKeySafetyIssue, providerAccountConnectorExample, ProviderAccountDraftMode, providerAccountModeOptions, ProviderAccountSnapshot,
|
||||||
providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
|
providerAccountConnectorsTextWithNewApiUserBalanceTemplate, providerAccountSnapshotCredentialLabel, providerAccountSnapshotLabel, ProviderAccountTestPath,
|
||||||
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft,
|
ProviderAccountTestResult, providerBaseUrl, providerCapabilitiesSummary, ProviderCredentialDraft, ProviderDeepLinkPayload, ProviderDeepLinkRequest, providerDraftSafetyIssue, providerCredentialDraftPatchFromJson, providerHttpJsonConnectorFromDraft,
|
||||||
ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol,
|
ProviderConnectivityCheckReport, providerDeepLinkDisplayIcon, providerListItemKey, providerMatchesQuery, ProviderPreset, providerPresetIconUrls, providerProbeHasSupportedProtocol,
|
||||||
providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
|
providerModelDisplayName, providerModelDisplayTitle, providerSelectableProtocolsFromProbe, providerUsageFieldPatch, ProviderUsageFieldTarget, providerUsageMethodOptions, Search, SelectControl,
|
||||||
|
|
@ -2085,7 +2085,11 @@ function ProviderUsageSettings({
|
||||||
const [testLoading, setTestLoading] = useState(false);
|
const [testLoading, setTestLoading] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<ProviderAccountTestResult>();
|
const [testResult, setTestResult] = useState<ProviderAccountTestResult>();
|
||||||
const [testError, setTestError] = useState("");
|
const [testError, setTestError] = useState("");
|
||||||
|
const [newApiUserId, setNewApiUserId] = useState("");
|
||||||
const modeOptions = translateOptions(providerAccountModeOptions, t);
|
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(() => {
|
useEffect(() => {
|
||||||
setTestResult(undefined);
|
setTestResult(undefined);
|
||||||
|
|
@ -2135,6 +2139,16 @@ function ProviderUsageSettings({
|
||||||
onChange(providerUsageFieldPatch(target, path));
|
onChange(providerUsageFieldPatch(target, path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertNewApiUserBalanceTemplate() {
|
||||||
|
onChange({
|
||||||
|
accountConnectorsText: providerAccountConnectorsTextWithNewApiUserBalanceTemplate(
|
||||||
|
draft.accountConnectorsText,
|
||||||
|
probe?.normalizedBaseUrl || draft.baseUrl,
|
||||||
|
newApiUserId
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sm:col-span-2 space-y-3 rounded-md border border-border bg-background/60 p-3">
|
<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">
|
<div className="flex min-w-0 flex-wrap items-center justify-between gap-3">
|
||||||
|
|
@ -2253,23 +2267,40 @@ function ProviderUsageSettings({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{draft.accountMode === "raw" ? (
|
{draft.accountMode === "raw" ? (
|
||||||
<Field className="sm:col-span-2" label={t("Connectors JSON")}>
|
<div className="sm:col-span-2 space-y-2">
|
||||||
<Textarea
|
<Field label={t("Connectors JSON")}>
|
||||||
className="min-h-[180px] font-mono text-[11px]"
|
<Textarea
|
||||||
value={draft.accountConnectorsText}
|
className="min-h-[180px] font-mono text-[11px]"
|
||||||
onChange={(event) => onChange({ accountConnectorsText: event.target.value })}
|
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>
|
<div className="flex min-w-0 items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||||
<button
|
<span className="min-w-0 truncate">{t("Supports standard, http-json, plugin, and local-estimate connectors.")}</span>
|
||||||
className="shrink-0 text-primary hover:underline"
|
<button
|
||||||
type="button"
|
className="shrink-0 text-primary hover:underline"
|
||||||
onClick={() => onChange({ accountConnectorsText: providerAccountConnectorExample() })}
|
type="button"
|
||||||
>
|
onClick={() => onChange({ accountConnectorsText: providerAccountConnectorExample() })}
|
||||||
{t("Insert example")}
|
>
|
||||||
</button>
|
{t("Insert example")}
|
||||||
</div>
|
</button>
|
||||||
</Field>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -1229,6 +1229,8 @@ export const appCopy: Record<ResolvedLanguage, AppCopy> = {
|
||||||
"Usage Trend": "用量趋势",
|
"Usage Trend": "用量趋势",
|
||||||
"User idle": "用户空闲",
|
"User idle": "用户空闲",
|
||||||
"Insert example": "插入示例",
|
"Insert example": "插入示例",
|
||||||
|
"Insert New API user balance": "插入 New API 用户余额",
|
||||||
|
"New API user ID": "New API 用户 ID",
|
||||||
"Virtual model": "Fusion",
|
"Virtual model": "Fusion",
|
||||||
"Virtual Models": "Fusion",
|
"Virtual Models": "Fusion",
|
||||||
"Add MCP Server": "添加 MCP 服务",
|
"Add MCP Server": "添加 MCP 服务",
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,7 @@ import {
|
||||||
providerEndpointCanReceiveProviderApiKeyInList,
|
providerEndpointCanReceiveProviderApiKeyInList,
|
||||||
providerIdentitySafetyIssueInList
|
providerIdentitySafetyIssueInList
|
||||||
} from "@ccr/core/providers/presets/utils";
|
} from "@ccr/core/providers/presets/utils";
|
||||||
|
import { newApiUserSelfConnectorConfig } from "@ccr/core/providers/new-api";
|
||||||
import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url";
|
import { normalizeProviderBaseUrl, providerUrlWithDefaultScheme } from "@ccr/core/providers/url";
|
||||||
import {
|
import {
|
||||||
fallbackConfig,
|
fallbackConfig,
|
||||||
|
|
@ -899,7 +900,8 @@ export function createProviderConfigFromDeepLink(
|
||||||
if (identityIssue) {
|
if (identityIssue) {
|
||||||
throw new Error(identityIssue.message);
|
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,
|
apiKey: payload.apiKey,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
providerName: name
|
providerName: name
|
||||||
|
|
@ -914,7 +916,7 @@ export function createProviderConfigFromDeepLink(
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account: payload.account ? cloneProviderAccountConfig(payload.account) : defaultProviderAccountConfigForBaseUrl(baseUrl),
|
account: cloneProviderAccountConfig(account),
|
||||||
api_base_url: normalizeProviderBaseUrl(baseUrl, protocol),
|
api_base_url: normalizeProviderBaseUrl(baseUrl, protocol),
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
||||||
|
|
@ -1262,6 +1264,15 @@ export function createProviderAccountDraftFromConfig(account: ProviderAccountCon
|
||||||
accountRefreshIntervalMs: account.refreshIntervalMs ? String(account.refreshIntervalMs) : ""
|
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 balanceMeter = httpJsonConnector.mapping.meters.find((meter) => meter.kind === "balance" || meter.id === "balance");
|
||||||
const subscriptionMeter = httpJsonConnector.mapping.meters.find((meter) =>
|
const subscriptionMeter = httpJsonConnector.mapping.meters.find((meter) =>
|
||||||
|
|
@ -1609,6 +1620,31 @@ export function providerAccountConnectorExample(): string {
|
||||||
], null, 2);
|
], 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 {
|
export function toProviderProtocol(value: string | undefined): GatewayProviderProtocol | undefined {
|
||||||
return providerProtocolOptions.some((option) => option.value === value) ? value as 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 protocol = probe.detectedProtocol ?? draft.protocol;
|
||||||
const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, protocol, draft.presetId);
|
const selectedProtocols = selectedProviderProtocolsForProbe(draft.selectedProtocols, probe, protocol, draft.presetId);
|
||||||
const modelDisplayNames = mergeModelDisplayNames(draft.modelDisplayNames, probe.modelDisplayNames);
|
const modelDisplayNames = mergeModelDisplayNames(draft.modelDisplayNames, probe.modelDisplayNames);
|
||||||
|
const accountDraft = providerProbeAccountDraftPatch(draft, probe.account);
|
||||||
|
|
||||||
if (probe.models.length === 0) {
|
if (probe.models.length === 0) {
|
||||||
return {
|
return {
|
||||||
...draft,
|
...draft,
|
||||||
|
...accountDraft,
|
||||||
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
|
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
|
||||||
modelDisplayNames,
|
modelDisplayNames,
|
||||||
protocol,
|
protocol,
|
||||||
|
|
@ -1871,6 +1909,7 @@ export function applyProviderProbeResult(draft: AddProviderDraft, probe: Gateway
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...draft,
|
...draft,
|
||||||
|
...accountDraft,
|
||||||
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
|
baseUrl: probe.normalizedBaseUrl || draft.baseUrl,
|
||||||
modelDisplayNames,
|
modelDisplayNames,
|
||||||
protocol,
|
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(
|
export function mergeModelDisplayNames(
|
||||||
...groups: Array<Record<string, string> | undefined>
|
...groups: Array<Record<string, string> | undefined>
|
||||||
): Record<string, string> | undefined {
|
): Record<string, string> | undefined {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
fusionFallbackToolDefinitions,
|
fusionFallbackToolDefinitions,
|
||||||
fusionWebSearchToolNameForRequest,
|
fusionWebSearchToolNameForRequest,
|
||||||
fusionToolNamesBackedByMcpServers,
|
fusionToolNamesBackedByMcpServers,
|
||||||
|
fusionBuiltinToolArtifactsForTest,
|
||||||
extractHostedWebSearchQueryHint,
|
extractHostedWebSearchQueryHint,
|
||||||
hostedWebSearchProtocolResponseStream,
|
hostedWebSearchProtocolResponseStream,
|
||||||
prepareGatewayUpstreamAttemptForTest,
|
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`);
|
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", () => {
|
test("gateway ignores non-Gemini capabilities on Gemini preset providers", () => {
|
||||||
const providerName = "Google Gemini";
|
const providerName = "Google Gemini";
|
||||||
const config = {
|
const config = {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
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";
|
import { isProviderProtocolEndpointSupportedForProbe } from "../../packages/core/src/providers/probe.ts";
|
||||||
|
|
||||||
test("protocol support probe does not treat Gemini auth errors as every protocol", () => {
|
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
|
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 assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
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 { geminiProviderPreset } from "../../packages/core/src/providers/presets/gemini/index.ts";
|
||||||
import {
|
import {
|
||||||
applyProviderProbeResult,
|
applyProviderProbeResult,
|
||||||
createProviderDraft,
|
createProviderDraft,
|
||||||
|
providerAccountConnectorsTextWithNewApiUserBalanceTemplate,
|
||||||
providerProtocolOptions,
|
providerProtocolOptions,
|
||||||
providerProbeCandidates,
|
providerProbeCandidates,
|
||||||
setProviderPresets
|
setProviderPresets
|
||||||
|
|
@ -71,3 +73,59 @@ test("provider probe result keeps only supported selected protocols", () => {
|
||||||
|
|
||||||
assert.deepEqual(next.selectedProtocols, ["gemini_generate_content"]);
|
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