diff --git a/packages/core/src/contracts/app.ts b/packages/core/src/contracts/app.ts index 685a35e..0dbfa56 100644 --- a/packages/core/src/contracts/app.ts +++ b/packages/core/src/contracts/app.ts @@ -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; 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; modelSource?: "anthropic" | "gemini" | "openai"; diff --git a/packages/core/src/gateway/service.ts b/packages/core/src/gateway/service.ts index ef274df..5936e7a 100644 --- a/packages/core/src/gateway/service.ts +++ b/packages/core/src/gateway/service.ts @@ -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 { 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, 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, diff --git a/packages/core/src/providers/account-service.ts b/packages/core/src/providers/account-service.ts index 31df114..adeb61d 100644 --- a/packages/core/src/providers/account-service.ts +++ b/packages/core/src/providers/account-service.ts @@ -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 { 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 | undefined { + if (!isRecord(payload)) { + return undefined; + } + const data = readJsonRecordValue(payload, "data"); + return isRecord(data) ? data : payload; +} + +function newApiUserSelfData(payload: unknown): Record | 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 []; diff --git a/packages/core/src/providers/new-api.ts b/packages/core/src/providers/new-api.ts new file mode 100644 index 0000000..44c2609 --- /dev/null +++ b/packages/core/src/providers/new-api.ts @@ -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): DetectedProviderKind | undefined { + return hasNewApiHeaders(headers) ? "new-api" : undefined; +} + +export function hasNewApiHeaders(headers: Record): 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-User": "" + }, + 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, ""); + } +} diff --git a/packages/core/src/providers/probe.ts b/packages/core/src/providers/probe.ts index e5b24d4..0a82346 100644 --- a/packages/core/src/providers/probe.ts +++ b/packages/core/src/providers/probe.ts @@ -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; @@ -27,6 +32,8 @@ type ParsedProviderUrl = ParsedProviderBaseUrl & { }; type FetchJsonResult = { + detectedProvider?: DetectedProviderKind; + headers?: Record; 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 item.detectedProvider)?.detectedProvider; +} + +function responseHeadersRecord(headers: Headers): Record { + const record: Record = {}; + headers.forEach((value, key) => { + record[key] = value; + }); + return record; +} + function parseProviderUrl(value: string): ParsedProviderUrl { const parsed = parseProviderBaseUrl(value); const url = new URL(parsed.normalizedInputBaseUrl); diff --git a/packages/ui/src/pages/home/components/providers.tsx b/packages/ui/src/pages/home/components/providers.tsx index 040617c..5f1753a 100644 --- a/packages/ui/src/pages/home/components/providers.tsx +++ b/packages/ui/src/pages/home/components/providers.tsx @@ -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(); 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 (
@@ -2253,23 +2267,40 @@ function ProviderUsageSettings({ ) : null} {draft.accountMode === "raw" ? ( - -