Reapply "feat: 支持动态提供商配置组和前缀匹配机制"

This reverts commit b8a983a3cd.
This commit is contained in:
hex2077 2026-04-05 15:20:48 +08:00
parent 47d92a41cb
commit 02fdc39571
32 changed files with 803 additions and 223 deletions

View file

@ -3,7 +3,7 @@
"dependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"adm-zip": "^0.5.16",
"axios": "^1.10.0",
"axios": "^1.14.0",
"deepmerge": "^4.3.1",
"dotenv": "^16.4.5",
"google-auth-library": "^10.1.0",

View file

@ -15,8 +15,8 @@ importers:
specifier: ^0.5.16
version: 0.5.16
axios:
specifier: ^1.10.0
version: 1.13.4
specifier: ^1.14.0
version: 1.14.0
deepmerge:
specifier: ^4.3.1
version: 4.3.1
@ -883,8 +883,8 @@ packages:
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.13.4:
resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==}
axios@1.14.0:
resolution: {integrity: sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==}
babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
@ -1308,11 +1308,12 @@ packages:
glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
google-auth-library@10.5.0:
resolution: {integrity: sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==}
@ -1834,8 +1835,9 @@ packages:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
proxy-from-env@2.1.0:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
@ -3269,11 +3271,11 @@ snapshots:
asynckit@0.4.0: {}
axios@1.13.4:
axios@1.14.0:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.5
proxy-from-env: 1.1.0
proxy-from-env: 2.1.0
transitivePeerDependencies:
- debug
@ -4441,7 +4443,7 @@ snapshots:
kleur: 3.0.3
sisteransi: 1.0.5
proxy-from-env@1.1.0: {}
proxy-from-env@2.1.0: {}
pure-rand@6.1.0: {}

View file

@ -76,6 +76,11 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP
LOGIN_MIN_INTERVAL: 5000, // 两次尝试之间的最小间隔毫秒默认1秒
PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径
MAX_ERROR_COUNT: 10, // 提供商最大错误次数
SCHEDULED_HEALTH_CHECK: {
enabled: false,
interval: 600000,
startupRun: false
},
providerFallbackChain: {}, // 跨类型 Fallback 链配置
LOG_ENABLED: true,
LOG_OUTPUT_MODE: "all",
@ -126,6 +131,8 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP
{ flag: '--login-max-attempts', configKey: 'LOGIN_MAX_ATTEMPTS', type: 'int' },
{ flag: '--login-lockout-duration', configKey: 'LOGIN_LOCKOUT_DURATION', type: 'int' },
{ flag: '--login-min-interval', configKey: 'LOGIN_MIN_INTERVAL', type: 'int' },
{ flag: '--scheduled-health-check-enabled', configKey: 'SCHEDULE_HEALTH_CHECK_ENABLED', type: 'bool' },
{ flag: '--scheduled-health-check-interval', configKey: 'SCHEDULE_HEALTH_CHECK_INTERVAL', type: 'int' },
];
// Parse command-line arguments using definitions
@ -160,6 +167,14 @@ export async function initializeConfig(args = process.argv.slice(2), configFileP
}
}
// 合并定时健康检查的 CLI 配置
if (currentConfig.SCHEDULE_HEALTH_CHECK_ENABLED !== undefined) {
currentConfig.SCHEDULED_HEALTH_CHECK.enabled = currentConfig.SCHEDULE_HEALTH_CHECK_ENABLED;
}
if (currentConfig.SCHEDULE_HEALTH_CHECK_INTERVAL !== undefined) {
currentConfig.SCHEDULED_HEALTH_CHECK.interval = currentConfig.SCHEDULE_HEALTH_CHECK_INTERVAL;
}
normalizeConfiguredProviders(currentConfig);
if (!currentConfig.SYSTEM_PROMPT_FILE_PATH) {

View file

@ -6,7 +6,7 @@ import { handleAPIRequests } from '../services/api-manager.js';
import { getApiService, getProviderStatus } from '../services/service-manager.js';
import { getProviderPoolManager } from '../services/service-manager.js';
import { MODEL_PROVIDER } from '../utils/common.js';
import { getRegisteredProviders } from '../providers/adapter.js';
import { getRegisteredProviders, isRegisteredProvider } from '../providers/adapter.js';
import { countTokensAnthropic } from '../utils/token-utils.js';
import { PROMPT_LOG_FILENAME } from '../core/config-manager.js';
import { getPluginManager } from '../core/plugin-manager.js';
@ -152,8 +152,7 @@ export function createRequestHandler(config, providerPoolManager) {
// Allow overriding MODEL_PROVIDER via request header
const modelProviderHeader = req.headers['model-provider'];
if (modelProviderHeader) {
const registeredProviders = getRegisteredProviders();
if (registeredProviders.includes(modelProviderHeader)) {
if (isRegisteredProvider(modelProviderHeader)) {
currentConfig.MODEL_PROVIDER = modelProviderHeader;
logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`);
} else {
@ -169,8 +168,7 @@ export function createRequestHandler(config, providerPoolManager) {
if (pathSegments.length > 0) {
const firstSegment = pathSegments[0];
const registeredProviders = getRegisteredProviders();
const isValidProvider = registeredProviders.includes(firstSegment);
const isValidProvider = isRegisteredProvider(firstSegment);
const isAutoMode = firstSegment === MODEL_PROVIDER.AUTO;
if (firstSegment && (isValidProvider || isAutoMode)) {

View file

@ -704,6 +704,26 @@ registerAdapter(MODEL_PROVIDER.GROK_CUSTOM, GrokApiServiceAdapter);
// 用于存储服务适配器单例的映射
export const serviceInstances = {};
/**
* 检查提供商是否已注册支持前缀匹配
* @param {string} provider - 提供商名称
* @returns {boolean} - 是否有效
*/
export function isRegisteredProvider(provider) {
if (adapterRegistry.has(provider)) {
return true;
}
// 检查前缀 (例如 openai-custom-1 -> openai-custom)
for (const key of adapterRegistry.keys()) {
if (provider.startsWith(key + '-')) {
return true;
}
}
return false;
}
// 服务适配器工厂
export function getServiceAdapter(config) {
const customNameDisplay = config.customName ? ` (${config.customName})` : '';
@ -712,7 +732,18 @@ export function getServiceAdapter(config) {
const providerKey = config.uuid ? provider + config.uuid : provider;
if (!serviceInstances[providerKey]) {
const AdapterClass = adapterRegistry.get(provider);
let AdapterClass = adapterRegistry.get(provider);
// 如果没找到精确匹配,尝试通过前缀查找 (例如 openai-custom-1 -> openai-custom)
if (!AdapterClass) {
for (const [key, value] of adapterRegistry.entries()) {
if (provider === key || provider.startsWith(key + '-')) {
AdapterClass = value;
break;
}
}
}
if (AdapterClass) {
serviceInstances[providerKey] = new AdapterClass(config);
} else {

View file

@ -63,13 +63,13 @@ export class ClaudeApiService {
}
// 配置自定义代理
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.CLAUDE_CUSTOM);
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CLAUDE_CUSTOM);
return axios.create(axiosConfig);
}
_applySidecar(axiosConfig) {
return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.CLAUDE_CUSTOM, this.baseUrl);
return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CLAUDE_CUSTOM, this.baseUrl);
}
/**

View file

@ -494,7 +494,7 @@ export class KiroApiService {
}
// 配置自定义代理
configureAxiosProxy(axiosConfig, this.config, 'claude-kiro-oauth');
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API);
this.axiosInstance = axios.create(axiosConfig);
@ -505,7 +505,7 @@ export class KiroApiService {
}
_applySidecar(axiosConfig) {
return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.KIRO_API);
return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API);
}
/**
@ -744,7 +744,7 @@ async saveCredentialsToFile(filePath, newData) {
// 刷新成功,重置 PoolManager 中的刷新状态并标记为健康
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.KIRO_API, this.uuid);
poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API, this.uuid);
}
} else {
throw new Error('Invalid refresh response: Missing accessToken');
@ -1627,7 +1627,7 @@ async saveCredentialsToFile(filePath, newData) {
_refreshUuid() {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
const newUuid = poolManager.refreshProviderUuid(MODEL_PROVIDER.KIRO_API, {
const newUuid = poolManager.refreshProviderUuid(this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API, {
uuid: this.uuid
});
return newUuid;
@ -1649,7 +1649,7 @@ async saveCredentialsToFile(filePath, newData) {
if (poolManager && this.uuid) {
logger.info(`[Kiro] Marking credential ${this.uuid} as needs refresh. Reason: ${reason}`);
// 使用新的 markProviderNeedRefresh 方法代替 markProviderUnhealthyImmediately
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.KIRO_API, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API, {
uuid: this.uuid
});
// Attach marker to error object to prevent duplicate marking in upper layers
@ -1674,7 +1674,7 @@ async saveCredentialsToFile(filePath, newData) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Kiro] Marking credential ${this.uuid} as unhealthy. Reason: ${reason}`);
poolManager.markProviderUnhealthyImmediately(MODEL_PROVIDER.KIRO_API, {
poolManager.markProviderUnhealthyImmediately(this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API, {
uuid: this.uuid
}, reason);
// Attach marker to error object to prevent duplicate marking in upper layers
@ -1701,7 +1701,7 @@ async saveCredentialsToFile(filePath, newData) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Kiro] Marking credential ${this.uuid} as unhealthy with recovery time. Reason: ${reason}, Recovery: ${recoveryTime?.toISOString()}`);
poolManager.markProviderUnhealthyWithRecoveryTime(MODEL_PROVIDER.KIRO_API, {
poolManager.markProviderUnhealthyWithRecoveryTime(this.config.MODEL_PROVIDER || MODEL_PROVIDER.KIRO_API, {
uuid: this.uuid
}, reason, recoveryTime);
// Attach marker to error object to prevent duplicate marking in upper layers

View file

@ -56,13 +56,13 @@ export class ForwardApiService {
axiosConfig.proxy = false;
}
configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.FORWARD_API);
configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.FORWARD_API);
this.axiosInstance = axios.create(axiosConfig);
}
_applySidecar(axiosConfig) {
return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.FORWARD_API, this.baseUrl);
return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.FORWARD_API, this.baseUrl);
}
async callApi(endpoint, body, isRetry = false, retryCount = 0) {

View file

@ -697,7 +697,7 @@ export class AntigravityApiService {
});
// 检查是否需要使用代理
const proxyConfig = getGoogleAuthProxyConfig(config, 'gemini-antigravity');
const proxyConfig = getGoogleAuthProxyConfig(config, config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY);
// 配置 OAuth2Client 使用自定义的 HTTP agent
const oauth2Options = {
@ -729,11 +729,11 @@ export class AntigravityApiService {
this.baseURLs = this.getBaseURLFallbackOrder(config);
// 保存代理配置供后续使用
this.proxyConfig = getProxyConfigForProvider(config, 'gemini-antigravity');
this.proxyConfig = getProxyConfigForProvider(config, config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY);
}
_applySidecar(requestOptions) {
return configureTLSSidecar(requestOptions, this.config, MODEL_PROVIDER.ANTIGRAVITY);
return configureTLSSidecar(requestOptions, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY);
}
/**
@ -821,7 +821,7 @@ export class AntigravityApiService {
// 刷新成功,重置 PoolManager 中的刷新状态并标记为健康
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.ANTIGRAVITY, this.uuid);
poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, this.uuid);
}
} else {
logger.info(`[Antigravity Auth] No access token or refresh token. Starting new authentication flow...`);
@ -832,7 +832,7 @@ export class AntigravityApiService {
// 认证成功,重置状态
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.ANTIGRAVITY, this.uuid);
poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, this.uuid);
}
}
} catch (error) {
@ -1109,7 +1109,7 @@ export class AntigravityApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Antigravity] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, {
uuid: this.uuid
});
error.credentialMarkedUnhealthy = true;
@ -1212,7 +1212,7 @@ export class AntigravityApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Antigravity] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized in stream`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, {
uuid: this.uuid
});
error.credentialMarkedUnhealthy = true;
@ -1315,7 +1315,7 @@ export class AntigravityApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Antigravity] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, {
uuid: this.uuid
});
}
@ -1393,7 +1393,7 @@ export class AntigravityApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Antigravity] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.ANTIGRAVITY, {
uuid: this.uuid
});
}
@ -1442,13 +1442,6 @@ export class AntigravityApiService {
async getUsageLimits() {
if (!this.isInitialized) await this.initialize();
// 注意V2 架构下不再在 getUsageLimits 中同步刷新 token
// 如果 token 过期PoolManager 后台会自动处理
// if (this.isExpiryDateNear()) {
// logger.info('[Antigravity] Token is near expiry, refreshing before getUsageLimits request...');
// await this.initializeAuth(true);
// }
try {
const modelsWithQuotas = await this.getModelsWithQuotas();
return modelsWithQuotas;
@ -1487,15 +1480,12 @@ export class AntigravityApiService {
this._applySidecar(requestOptions);
const res = await this.authClient.request(requestOptions);
// logger.info(`[Antigravity] fetchAvailableModels success: ${JSON.stringify(res.data)}`);
if (res.data) {
if (res.data.models) {
const modelsData = res.data.models;
// 遍历模型数据,提取配额信息
for (const [modelId, modelData] of Object.entries(modelsData)) {
// 参考 fetchAvailableModels 的逻辑修复 modelName2Alias 不存在的问题
if (!modelId || (!ANTIGRAVITY_MODELS.includes(modelId) && !modelId.startsWith('claude-'))) {
continue;
}

View file

@ -289,7 +289,7 @@ export class GeminiApiService {
});
// 检查是否需要使用代理
const proxyConfig = getGoogleAuthProxyConfig(config, 'gemini-cli-oauth');
const proxyConfig = getGoogleAuthProxyConfig(config, config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI);
// 配置 OAuth2Client 使用自定义的 HTTP agent
const oauth2Options = {
@ -312,6 +312,7 @@ export class GeminiApiService {
this.config = config;
this.host = config.HOST;
this.uuid = config.uuid;
this.oauthCredsBase64 = config.GEMINI_OAUTH_CREDS_BASE64;
this.oauthCredsFilePath = config.GEMINI_OAUTH_CREDS_FILE_PATH;
this.projectId = config.PROJECT_ID;
@ -320,7 +321,7 @@ export class GeminiApiService {
this.apiVersion = DEFAULT_CODE_ASSIST_API_VERSION;
// 保存代理配置供后续使用
this.proxyConfig = getProxyConfigForProvider(config, 'gemini-cli-oauth');
this.proxyConfig = getProxyConfigForProvider(config, config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI);
}
async initialize() {
@ -345,7 +346,7 @@ export class GeminiApiService {
}
_applySidecar(requestOptions) {
return configureTLSSidecar(requestOptions, this.config, MODEL_PROVIDER.GEMINI_CLI);
return configureTLSSidecar(requestOptions, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI);
}
/**
@ -412,7 +413,7 @@ export class GeminiApiService {
// 刷新成功,重置 PoolManager 中的刷新状态并标记为健康
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.GEMINI_CLI, this.uuid);
poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, this.uuid);
}
} else {
logger.info(`[Gemini Auth] No access token or refresh token. Starting new authentication flow...`);
@ -423,7 +424,7 @@ export class GeminiApiService {
// 认证成功,重置状态
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.GEMINI_CLI, this.uuid);
poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, this.uuid);
}
}
} catch (error) {
@ -598,7 +599,7 @@ export class GeminiApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Gemini] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, {
uuid: this.uuid
});
error.credentialMarkedUnhealthy = true;
@ -681,7 +682,7 @@ export class GeminiApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Gemini] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized in stream`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, {
uuid: this.uuid
});
error.credentialMarkedUnhealthy = true;
@ -757,7 +758,7 @@ export class GeminiApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Gemini] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, {
uuid: this.uuid
});
}
@ -796,7 +797,7 @@ export class GeminiApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Gemini] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GEMINI_CLI, {
uuid: this.uuid
});
}

View file

@ -129,14 +129,14 @@ export class GrokApiService {
async acceptTos() {
const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/accept-tos`, headers: this.buildHeaders(), data: {}, httpAgent, httpsAgent, timeout: 15000 };
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM);
this._applySidecar(axiosConfig);
try { await axios(axiosConfig); } catch (e) { logger.debug(`[Grok TOS] ${e.message}`); }
}
async setBirthDate() {
const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/set-birth-date`, headers: this.buildHeaders(), data: { "birthDate": "1990-01-01" }, httpAgent, httpsAgent, timeout: 15000 };
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM);
this._applySidecar(axiosConfig);
try { await axios(axiosConfig); } catch (e) { logger.debug(`[Grok Birth] ${e.message}`); }
}
@ -167,13 +167,13 @@ export class GrokApiService {
timeout: 15000,
responseType: 'arraybuffer'
};
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM);
this._applySidecar(axiosConfig);
try { await axios(axiosConfig); } catch (e) { throw e; }
}
_applySidecar(axiosConfig) {
return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM);
}
async initialize() {
@ -190,7 +190,7 @@ export class GrokApiService {
// await this.getUsageLimits(); return Promise.resolve();
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.GROK_CUSTOM, this.uuid);
poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM, this.uuid);
}
} catch (error) {
logger.error('[Grok] Failed to initialize authentication:', error);
@ -202,7 +202,7 @@ export class GrokApiService {
const headers = this.buildHeaders();
const payload = { "requestKind": "DEFAULT", "modelName": "grok-3" };
const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/rate-limits`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 };
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM);
this._applySidecar(axiosConfig);
try {
const response = await axios(axiosConfig);
@ -282,7 +282,7 @@ export class GrokApiService {
if (mediaUrl && mediaUrl.trim()) payload.mediaUrl = mediaUrl;
const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/post/create`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 };
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM);
this._applySidecar(axiosConfig);
try {
const response = await axios(axiosConfig);
@ -302,7 +302,7 @@ export class GrokApiService {
if (!idMatch) return videoUrl;
const videoId = idMatch[1];
const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/video/upscale`, headers: this.buildHeaders(), data: { videoId }, httpAgent, httpsAgent, timeout: 30000 };
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM);
this._applySidecar(axiosConfig);
try {
const response = await axios(axiosConfig);
@ -329,7 +329,7 @@ export class GrokApiService {
httpsAgent,
timeout: 15000
};
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM);
this._applySidecar(axiosConfig);
try {
const response = await axios(axiosConfig);
@ -455,7 +455,7 @@ export class GrokApiService {
rolloutId: "",
modelResponse: null,
cardAttachment: null,
cardAttachments: [], // 收集所有的卡片附件
cardAttachments: [],
streamingImageGenerationResponse: null,
streamingVideoGenerationResponse: null,
finalVideoUrl: null,
@ -743,7 +743,7 @@ export class GrokApiService {
}
if (!b64) return null;
const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/upload-file`, headers: this.buildHeaders(), data: { fileName: `file.${mime.split("/")[1] || "bin"}`, fileMimeType: mime, content: b64 }, httpAgent, httpsAgent, timeout: 30000 };
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM);
this._applySidecar(axiosConfig);
try { return (await axios(axiosConfig)).data; } catch (error) { return null; }
}
@ -763,7 +763,7 @@ export class GrokApiService {
if (requestBody._requestBaseUrl) delete requestBody._requestBaseUrl;
if (this.isExpiryDateNear() && getProviderPoolManager() && this.uuid) {
getProviderPoolManager().markProviderNeedRefresh(MODEL_PROVIDER.GROK_CUSTOM, { uuid: this.uuid });
getProviderPoolManager().markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM, { uuid: this.uuid });
}
const rawModel = typeof model === 'string' ? model : '';
@ -841,7 +841,7 @@ export class GrokApiService {
const payload = this.buildPayload(model, requestBody);
const axiosConfig = { method: 'post', url: this.chatApi, headers: this.buildHeaders(), data: payload, responseType: 'stream', httpAgent, httpsAgent, timeout: 60000, maxRedirects: 0 };
configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM);
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.GROK_CUSTOM);
this._applySidecar(axiosConfig);
try {

View file

@ -40,7 +40,7 @@ export class CodexApiService {
}
_applySidecar(axiosConfig) {
return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.CODEX_API, this.baseUrl);
return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API, this.baseUrl);
}
/**
@ -148,7 +148,7 @@ export class CodexApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Codex] Token is near expiry, marking credential ${this.uuid} for background refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.CODEX_API, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API, {
uuid: this.uuid
});
}
@ -195,7 +195,7 @@ export class CodexApiService {
};
// 配置代理
const proxyConfig = getProxyConfigForProvider(this.config, 'openai-codex-oauth');
const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API);
if (proxyConfig) {
config.httpAgent = proxyConfig.httpAgent;
config.httpsAgent = proxyConfig.httpsAgent;
@ -272,7 +272,7 @@ export class CodexApiService {
};
// 配置代理
const proxyConfig = getProxyConfigForProvider(this.config, 'openai-codex-oauth');
const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API);
if (proxyConfig) {
config.httpAgent = proxyConfig.httpAgent;
config.httpsAgent = proxyConfig.httpsAgent;
@ -454,7 +454,7 @@ export class CodexApiService {
// 刷新成功,重置 PoolManager 中的刷新状态并标记为健康
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.CODEX_API, this.uuid);
poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API, this.uuid);
}
logger.info('[Codex] Token refreshed successfully');
} catch (error) {
@ -688,7 +688,7 @@ export class CodexApiService {
};
// 配置代理
const proxyConfig = getProxyConfigForProvider(this.config, 'openai-codex-oauth');
const proxyConfig = getProxyConfigForProvider(this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.CODEX_API);
if (proxyConfig) {
config.httpAgent = proxyConfig.httpAgent;
config.httpsAgent = proxyConfig.httpsAgent;

View file

@ -47,13 +47,13 @@ export class OpenAIApiService {
}
// 配置自定义代理
configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.OPENAI_CUSTOM);
configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM);
this.axiosInstance = axios.create(axiosConfig);
}
_applySidecar(axiosConfig) {
return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.OPENAI_CUSTOM, this.baseUrl);
return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM, this.baseUrl);
}
async callApi(endpoint, body, isRetry = false, retryCount = 0) {

View file

@ -47,13 +47,13 @@ export class OpenAIResponsesApiService {
}
// 配置自定义代理 (使用 openai-custom 的代理配置)
configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES);
configureAxiosProxy(axiosConfig, config, config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES);
this.axiosInstance = axios.create(axiosConfig);
}
_applySidecar(axiosConfig) {
return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES, this.baseUrl);
return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES, this.baseUrl);
}
async callApi(endpoint, body, isRetry = false, retryCount = 0) {

View file

@ -230,7 +230,7 @@ export class QwenApiService {
}
// 配置自定义代理
configureAxiosProxy(axiosConfig, this.config, 'openai-qwen-oauth');
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API);
this.currentAxiosInstance = axios.create(axiosConfig);
@ -239,7 +239,7 @@ export class QwenApiService {
}
_applySidecar(axiosConfig) {
return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.QWEN_API, this.baseUrl);
return configureTLSSidecar(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, this.baseUrl);
}
/**
@ -278,7 +278,7 @@ export class QwenApiService {
if (forceRefresh || (credentials && credentials.access_token)) {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.QWEN_API, this.uuid);
poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, this.uuid);
}
}
} catch (error) {
@ -331,7 +331,7 @@ export class QwenApiService {
// 认证成功,重置状态
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.QWEN_API, this.uuid);
poolManager.resetProviderRefreshStatus(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, this.uuid);
}
}
}
@ -575,7 +575,7 @@ export class QwenApiService {
}
// 配置自定义代理
configureAxiosProxy(axiosConfig, this.config, 'openai-qwen-oauth');
configureAxiosProxy(axiosConfig, this.config, this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API);
this.currentAxiosInstance = axios.create(axiosConfig);
@ -629,7 +629,7 @@ export class QwenApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Qwen] Marking credential ${this.uuid} as needs refresh. Reason: Auth Error ${status}`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, {
uuid: this.uuid
});
error.credentialMarkedUnhealthy = true;
@ -677,7 +677,7 @@ export class QwenApiService {
const poolManager = getProviderPoolManager();
if (poolManager && this.uuid) {
logger.info(`[Qwen] Token is near expiry, marking credential ${this.uuid} for refresh`);
poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, {
poolManager.markProviderNeedRefresh(this.config.MODEL_PROVIDER || MODEL_PROVIDER.QWEN_API, {
uuid: this.uuid
});
}

View file

@ -116,7 +116,18 @@ export const PROVIDER_MODELS = {
* @returns {Array<string>} 模型列表
*/
export function getProviderModels(providerType) {
return PROVIDER_MODELS[providerType] || [];
if (PROVIDER_MODELS[providerType]) {
return PROVIDER_MODELS[providerType];
}
// 尝试前缀匹配 (例如 openai-custom-1 -> openai-custom)
for (const key of Object.keys(PROVIDER_MODELS)) {
if (providerType.startsWith(key + '-')) {
return PROVIDER_MODELS[key];
}
}
return [];
}
/**

View file

@ -1841,7 +1841,11 @@ export class ProviderPoolManager {
for (const { providerType, provider, uuid, customName } of providersToCheck) {
const providerCheckStart = Date.now();
const checkModelName = provider.config.checkModelName || ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] || 'unknown';
const baseProviderType = this._getBaseProviderType(providerType);
const checkModelName = provider.config.checkModelName ||
ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] ||
ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[baseProviderType] ||
'unknown';
const displayName = customName || uuid.substring(0, 8);
try {
@ -1903,7 +1907,7 @@ export class ProviderPoolManager {
}
// OpenAI Custom Responses 使用特殊格式
if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES) {
if (this._getBaseProviderType(providerType) === MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES) {
requests.push({
input: [baseMessage],
model: modelName
@ -1920,6 +1924,26 @@ export class ProviderPoolManager {
return requests;
}
/**
* 根据提供商类型获取基准提供商类型用于查找配置和模型
* 例如openai-custom-1 -> openai-custom
* @private
*/
_getBaseProviderType(providerType) {
if (ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType]) {
return providerType;
}
// 尝试前缀匹配
for (const key of Object.keys(ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS)) {
if (providerType === key || providerType.startsWith(key + '-')) {
return key;
}
}
return providerType;
}
/**
* Performs an actual health check for a specific provider.
*
@ -1934,8 +1958,10 @@ export class ProviderPoolManager {
*/
async _checkProviderHealth(providerType, providerConfig) {
// 确定健康检查使用的模型名称
const baseProviderType = this._getBaseProviderType(providerType);
const modelName = providerConfig.checkModelName ||
ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType];
ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType] ||
ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[baseProviderType];
if (!modelName) {
this._log('warn', `Unknown provider type for health check: ${providerType}. Please check DEFAULT_HEALTH_CHECK_MODELS.`);

View file

@ -563,7 +563,8 @@ export async function getProviderStatus(config, options = {}) {
'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH',
'openai-iflow': 'IFLOW_TOKEN_FILE_PATH',
'forward-api': 'FORWARD_BASE_URL',
'grok-custom': 'GROK_COOKIE_TOKEN'
'grok-custom': 'GROK_COOKIE_TOKEN',
'openai-codex-oauth': 'CODEX_OAUTH_CREDS_FILE_PATH'
};
let providerPoolsSlim = [];
let unhealthyProvideIdentifyList = [];
@ -575,7 +576,18 @@ export async function getProviderStatus(config, options = {}) {
for (const key of Object.keys(providerPools)) {
if (!Array.isArray(providerPools[key])) continue;
if (filterProvider && key !== filterProvider) continue;
const identifyField = identifyFieldMap[key] || null;
let identifyField = identifyFieldMap[key] || null;
if (!identifyField) {
// 尝试通过前缀查找 identifyField (例如 openai-custom-1 -> openai-custom)
for (const [prefix, field] of Object.entries(identifyFieldMap)) {
if (key.startsWith(prefix + '-')) {
identifyField = field;
break;
}
}
}
const slimArr = providerPools[key]
.filter(item => {
if (item.isDisabled) return false;

View file

@ -125,7 +125,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
// Get supported provider types based on registered adapters
if (method === 'GET' && pathParam === '/api/providers/supported') {
return await providerApi.handleGetSupportedProviders(req, res);
return await providerApi.handleGetSupportedProviders(req, res, currentConfig, providerPoolManager);
}
// Get specific provider type details
@ -137,7 +137,7 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo
// Get available models for all providers or specific provider type
if (method === 'GET' && pathParam === '/api/provider-models') {
return await providerApi.handleGetProviderModels(req, res);
return await providerApi.handleGetProviderModels(req, res, currentConfig, providerPoolManager);
}
// Get available models for a specific provider type

View file

@ -9,38 +9,53 @@ import { getRegisteredProviders } from '../providers/adapter.js';
// 文件级互斥锁:防止并发读写导致数据丢失
// 安全净化移除用户输入字段中的危险内容script、事件处理器、javascript:协议等),
// 存储原始文本。HTML 转义统一由前端 escHtml() 负责,避免双编码问题。
function sanitizeProviderData(provider) {
// 安全净化:移除用户输入字段中的危险内容,并可选地过滤敏感 API 密钥
function sanitizeProviderData(provider, maskSensitive = false) {
if (!provider || typeof provider !== 'object') return provider;
const sanitized = { ...provider };
// 1. 过滤敏感字段API Keys, Tokens 等)
if (maskSensitive) {
const sensitiveKeys = [
'OPENAI_API_KEY', 'CLAUDE_API_KEY', 'FORWARD_API_KEY',
'GROK_COOKIE_TOKEN', 'GROK_CF_CLEARANCE',
'refreshToken', 'accessToken', 'clientSecret'
];
sensitiveKeys.forEach(key => {
if (sanitized[key]) {
// 对密钥进行脱敏显示(只保留前 4 位和后 4 位)
const val = sanitized[key];
if (typeof val === 'string' && val.length > 10) {
sanitized[key] = val.substring(0, 4) + '****' + val.substring(val.length - 4);
} else {
sanitized[key] = '********';
}
}
});
}
// 2. 净化 customName 中的 HTML/脚本
if (typeof sanitized.customName === 'string') {
let name = sanitized.customName;
// 拒绝包含危险协议
if (/(?:data|javascript|vbscript)\s*:/i.test(name)) {
sanitized.customName = '';
return sanitized;
}
// 移除所有 HTML 标签(更安全的方式)
name = name.replace(/<[^>]*>/g, '');
// 移除 HTML 事件处理器属性onclick/onerror 等)
name = name.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
// 移除潜在的 HTML 实体编码攻击
name = name.replace(/&[#\w]+;/g, '');
sanitized.customName = name.trim();
}
return sanitized;
}
function sanitizeProviderPools(pools) {
function sanitizeProviderPools(pools, maskSensitive = false) {
if (!pools || typeof pools !== 'object') return pools;
const sanitized = {};
for (const [type, providers] of Object.entries(pools)) {
sanitized[type] = Array.isArray(providers)
? providers.map(sanitizeProviderData)
? providers.map(p => sanitizeProviderData(p, maskSensitive))
: providers;
}
return sanitized;
@ -70,34 +85,53 @@ function withFileLock(fn) {
return next;
}
/**
* 获取提供商池摘要
* 获取所有提供商的状态包括支持的类型和号池组
*/
export async function handleGetProviders(req, res, currentConfig, providerPoolManager) {
let providerPools = {};
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
try {
if (providerPoolManager && providerPoolManager.providerPools) {
providerPools = providerPoolManager.providerPools;
} else if (filePath && existsSync(filePath)) {
const poolsData = JSON.parse(readFileSync(filePath, 'utf-8'));
providerPools = poolsData;
}
} catch (error) {
logger.warn('[UI API] Failed to load provider pools:', error.message);
if (!providerPoolManager) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } }));
return true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(sanitizeProviderPools(providerPools)));
return true;
}
// 1. 获取支持的基础提供商类型
const registeredProviders = getRegisteredProviders();
let poolTypes = [];
// 2. 从管理器获取当前所有池的状态
const providerStatus = {};
for (const [type, providers] of Object.entries(providerPoolManager.providerStatus)) {
providerStatus[type] = providers.map(p => ({
...p.config,
activeRequests: p.state?.activeCount || 0,
waitingRequests: p.state?.waitingCount || 0
}));
}
// 3. 补全号池配置文件中的所有组
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
try {
if (existsSync(filePath)) {
const poolsData = JSON.parse(readFileSync(filePath, 'utf-8'));
poolTypes = Object.keys(poolsData);
poolTypes.forEach(type => {
if (!providerStatus[type]) {
providerStatus[type] = [];
}
});
}
} catch (error) {
logger.warn('[UI API] Failed to supplement provider status:', error.message);
}
// 合并生成支持的类型列表
const supportedProviders = [...new Set([...registeredProviders, ...poolTypes])];
/**
* 获取支持的提供商类型已注册适配器的
*/
export async function handleGetSupportedProviders(req, res) {
const supportedProviders = getRegisteredProviders();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(supportedProviders));
res.end(JSON.stringify({
providers: sanitizeProviderPools(providerStatus, true), // 列表显示进行打码
supportedProviders: supportedProviders
}));
return true;
}
@ -122,7 +156,7 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
providerType,
providers: providers.map(sanitizeProviderData),
providers: providers.map(p => sanitizeProviderData(p, false)), // 详情页(用于编辑)不打码
totalCount: providers.length,
healthyCount: providers.filter(p => p.isHealthy).length
}));
@ -130,10 +164,62 @@ export async function handleGetProviderType(req, res, currentConfig, providerPoo
}
/**
* 获取所有提供商的可用模型
* 获取支持的提供商类型已注册适配器的以及号池中已存在的自定义类型
*/
export async function handleGetProviderModels(req, res) {
const allModels = getAllProviderModels();
export async function handleGetSupportedProviders(req, res, currentConfig, providerPoolManager) {
const registeredProviders = getRegisteredProviders();
let poolTypes = [];
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
try {
if (providerPoolManager && providerPoolManager.providerPools) {
poolTypes = Object.keys(providerPoolManager.providerPools);
} else if (filePath && existsSync(filePath)) {
const poolsData = JSON.parse(readFileSync(filePath, 'utf-8'));
poolTypes = Object.keys(poolsData);
}
} catch (error) {
logger.warn('[UI API] Failed to load provider pools for supported types:', error.message);
}
// 合并注册的提供商和号池中的类型
const supportedProviders = [...new Set([...registeredProviders, ...poolTypes])];
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(supportedProviders));
return true;
}
/**
* 获取所有提供商的可用模型支持动态配置组
*/
export async function handleGetProviderModels(req, res, currentConfig, providerPoolManager) {
const registeredProviders = getRegisteredProviders();
let poolTypes = [];
// 获取所有存在的类型(基础 + 动态)
const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json';
try {
if (providerPoolManager && providerPoolManager.providerPools) {
poolTypes = Object.keys(providerPoolManager.providerPools);
} else if (existsSync(filePath)) {
const poolsData = JSON.parse(readFileSync(filePath, 'utf-8'));
poolTypes = Object.keys(poolsData);
}
} catch (error) {
logger.warn('[UI API] Failed to load provider pools for models:', error.message);
}
const allTypes = [...new Set([...registeredProviders, ...poolTypes])];
const allModels = {};
allTypes.forEach(type => {
const models = getProviderModels(type);
if (models && models.length > 0) {
allModels[type] = models;
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(allModels));
return true;

View file

@ -961,7 +961,7 @@ export async function handleContentGenerationRequest(req, res, service, endpoint
}
// 为 forward provider 添加原始请求路径作为 endpoint
if (requestPath && toProvider === MODEL_PROVIDER.FORWARD_API) {
if (requestPath && getProtocolPrefix(toProvider) === MODEL_PROTOCOL_PREFIX.FORWARD) {
logger.info(`[Forward API] Request path: ${requestPath}`);
processedRequestBody.endpoint = requestPath;
}

View file

@ -54,7 +54,7 @@ export function parseProxyUrl(proxyUrl) {
}
/**
* 检查指定的提供商是否启用了代理
* 检查指定的提供商是否启用了代理支持前缀匹配
* @param {Object} config - 配置对象
* @param {string} providerType - 提供商类型
* @returns {boolean} 是否启用代理
@ -69,7 +69,13 @@ export function isProxyEnabledForProvider(config, providerType) {
return false;
}
return enabledProviders.includes(providerType);
// 1. 尝试精确匹配
if (enabledProviders.includes(providerType)) {
return true;
}
// 2. 尝试前缀匹配 (例如 openai-custom-prod 继承 openai-custom 的配置)
return enabledProviders.some(p => providerType.startsWith(p + '-'));
}
/**
@ -112,7 +118,7 @@ export function configureAxiosProxy(axiosConfig, config, providerType) {
}
/**
* 检查指定的提供商是否启用了 TLS Sidecar
* 检查指定的提供商是否启用了 TLS Sidecar支持前缀匹配
* @param {Object} config - 配置对象
* @param {string} providerType - 提供商类型
* @returns {boolean} 是否启用 TLS Sidecar
@ -127,7 +133,13 @@ export function isTLSSidecarEnabledForProvider(config, providerType) {
return false;
}
return enabledProviders.includes(providerType);
// 1. 尝试精确匹配
if (enabledProviders.includes(providerType)) {
return true;
}
// 2. 尝试前缀匹配
return enabledProviders.some(p => providerType.startsWith(p + '-'));
}
/**

View file

@ -41,7 +41,8 @@ import {
openProviderManager,
showAuthModal,
executeGenerateAuthUrl,
handleGenerateAuthUrl
handleGenerateAuthUrl,
showAddProviderGroupModal
} from './provider-manager.js';
import {
@ -234,6 +235,7 @@ window.fileUploadHandler = fileUploadHandler;
window.showAuthModal = showAuthModal;
window.executeGenerateAuthUrl = executeGenerateAuthUrl;
window.handleGenerateAuthUrl = handleGenerateAuthUrl;
window.showAddProviderGroupModal = showAddProviderGroupModal;
// 配置管理相关全局函数
window.viewConfig = viewConfig;

View file

@ -219,6 +219,18 @@ function initEventListeners() {
performUpdateBtn.addEventListener('click', performUpdate);
}
// 添加提供商组按钮
const addProviderGroupBtn = document.getElementById('add-provider-group-btn');
if (addProviderGroupBtn) {
addProviderGroupBtn.addEventListener('click', () => {
if (window.showAddProviderGroupModal) {
window.showAddProviderGroupModal();
} else {
console.error('showAddProviderGroupModal function not found');
}
});
}
// 日志容器滚动
if (elements.logsContainer) {
elements.logsContainer.addEventListener('scroll', () => {

View file

@ -452,6 +452,12 @@ const translations = {
// Providers
'providers.title': '提供商池管理',
'providers.addGroup': '新的分组',
'providers.addGroup.title': '添加新分组',
'providers.addGroup.success': '分组创建成功,请添加账号',
'providers.addGroup.error': '创建失败',
'providers.addGroup.suffix': '分组名称 (后缀)',
'providers.addGroup.suffixPlaceholder': '例如: qwen, glm, minimax',
'providers.note': '如使用客户端默认授权配置需使用空节点',
'providers.activeConnections': '活动连接',
'providers.activeProviders': '活跃提供商',
@ -1304,6 +1310,13 @@ const translations = {
// Providers
'providers.title': 'Provider Pool Management',
'providers.addGroup': 'Add Group',
'providers.addGroup.title': 'Add New Configuration Group',
'providers.addGroup.baseType': 'Base Type',
'providers.addGroup.suffix': 'Suffix Name',
'providers.addGroup.suffixPlaceholder': 'e.g., qwen, glm, minimax',
'providers.addGroup.success': 'Configuration group created, please add accounts',
'providers.addGroup.error': 'Creation failed',
'providers.note': 'If using default client authorization config, use an empty node',
'providers.activeConnections': 'Active Connections',
'providers.activeProviders': 'Active Providers',

View file

@ -667,8 +667,13 @@ function renderProviderConfig(provider) {
* @param {Object} provider - 提供商对象
* @returns {Array} 字段键数组
*/
/**
* 获取字段显示顺序
* @param {Object} provider - 提供商对象
* @returns {Array} 字段名数组
*/
function getFieldOrder(provider) {
const orderedFields = ['customName', 'checkModelName', 'checkHealth'];
const orderedFields = ['customName', 'checkModelName', 'checkHealth', 'concurrencyLimit', 'queueLimit'];
// 需要排除的内部状态字段
const excludedFields = [
@ -677,23 +682,10 @@ function getFieldOrder(provider) {
'notSupportedModels', 'refreshCount', 'needsRefresh', '_lastSelectionSeq'
];
// 从 getProviderTypeFields 获取字段顺序映射
const fieldOrderMap = {
'openai-custom': ['OPENAI_API_KEY', 'OPENAI_BASE_URL'],
'openaiResponses-custom': ['OPENAI_API_KEY', 'OPENAI_BASE_URL'],
'claude-custom': ['CLAUDE_API_KEY', 'CLAUDE_BASE_URL'],
'gemini-cli-oauth': ['PROJECT_ID', 'GEMINI_OAUTH_CREDS_FILE_PATH', 'GEMINI_BASE_URL'],
'claude-kiro-oauth': ['KIRO_OAUTH_CREDS_FILE_PATH', 'KIRO_BASE_URL', 'KIRO_REFRESH_URL', 'KIRO_REFRESH_IDC_URL'],
'openai-qwen-oauth': ['QWEN_OAUTH_CREDS_FILE_PATH', 'QWEN_BASE_URL', 'QWEN_OAUTH_BASE_URL'],
'gemini-antigravity': ['PROJECT_ID', 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', 'ANTIGRAVITY_BASE_URL_DAILY', 'ANTIGRAVITY_BASE_URL_AUTOPUSH'],
'openai-iflow': ['IFLOW_OAUTH_CREDS_FILE_PATH', 'IFLOW_BASE_URL'],
'openai-codex-oauth': ['CODEX_OAUTH_CREDS_FILE_PATH', 'CODEX_EMAIL', 'CODEX_BASE_URL'],
'grok-custom': ['GROK_COOKIE_TOKEN', 'GROK_CF_CLEARANCE', 'GROK_USER_AGENT', 'GROK_BASE_URL'],
'forward-api': ['FORWARD_API_KEY', 'FORWARD_BASE_URL', 'FORWARD_HEADER_NAME', 'FORWARD_HEADER_VALUE_PREFIX']
};
// 尝试从全局或当前模态框上下文中推断提供商类型
// 尝试从当前模态框上下文中获取提供商类型
let providerType = currentProviderType;
// 如果没有上下文类型,尝试从对象字段推断(回退逻辑)
if (!providerType) {
if (provider.OPENAI_API_KEY && provider.OPENAI_BASE_URL) {
providerType = 'openai-custom';
@ -718,8 +710,9 @@ function getFieldOrder(provider) {
}
}
// 获取该类型应该具有的所有字段(预定义顺序)
const predefinedOrder = providerType ? (fieldOrderMap[providerType] || []) : [];
// 直接从 utils.js 获取该类型的预定义字段列表(支持前缀匹配)
const predefinedFields = providerType ? getProviderTypeFields(providerType) : [];
const predefinedOrder = predefinedFields.map(f => f.id);
// 获取当前对象中存在且不在预定义列表中的其他字段
const otherFields = Object.keys(provider).filter(key =>
@ -734,12 +727,8 @@ function getFieldOrder(provider) {
// 只有在字段确实存在于 provider 中,或者它是该提供商类型的预定义字段时才显示
return allExpectedFields.filter(key =>
provider.hasOwnProperty(key) || predefinedOrder.includes(key)
Object.prototype.hasOwnProperty.call(provider, key) || predefinedOrder.includes(key)
);
// 如果无法识别提供商类型,按字母顺序排序
otherFields.sort();
return [...orderedFields, ...otherFields].filter(key => provider.hasOwnProperty(key));
}
/**

View file

@ -199,10 +199,23 @@ function getProviderDisplayName(providerType) {
'openaiResponses-custom': 'OpenAI Responses Custom',
'openai-qwen-oauth': 'Qwen (OAuth)',
'openai-iflow': 'iFlow',
'openai-codex-oauth': 'OpenAI Codex (OAuth)'
'openai-codex-oauth': 'OpenAI Codex (OAuth)',
'grok-custom': 'Grok Reverse'
};
return displayNames[providerType] || providerType;
if (displayNames[providerType]) {
return displayNames[providerType];
}
// 尝试前缀匹配
for (const baseType in displayNames) {
if (providerType.startsWith(baseType + '-')) {
const suffix = providerType.substring(baseType.length + 1);
return `${displayNames[baseType]} (${suffix})`;
}
}
return providerType;
}
/**
@ -220,13 +233,22 @@ function getProviderIcon(providerType) {
}
}
if (providerType.includes('gemini')) {
return 'fas fa-gem';
} else if (providerType.includes('claude')) {
return 'fas fa-robot';
} else if (providerType.includes('openai') || providerType.includes('qwen') || providerType.includes('iflow')) {
return 'fas fa-brain';
const iconMap = {
'gemini': 'fas fa-gem',
'claude': 'fas fa-robot',
'openai': 'fas fa-brain',
'qwen': 'fas fa-brain',
'iflow': 'fas fa-brain',
'forward': 'fas fa-share-square',
'grok': 'fas fa-search'
};
for (const key in iconMap) {
if (providerType.includes(key)) {
return iconMap[key];
}
}
return 'fas fa-server';
}

View file

@ -1,7 +1,7 @@
// 提供商管理功能模块
import { providerStats, updateProviderStats } from './constants.js';
import { showToast, formatUptime, getProviderConfigs } from './utils.js';
import { showToast, formatUptime, getProviderConfigs, getBaseProviderConfigs } from './utils.js';
import { fileUploadHandler } from './file-upload.js';
import { t, getCurrentLanguage } from './i18n.js';
import { renderRoutingExamples } from './routing-examples.js';
@ -178,34 +178,36 @@ function updateTimeDisplay() {
}
/**
* 加载提供商列表
* 加载提供商数据
* @param {boolean} forceRefreshSupported - 是否强制刷新支持的提供商列表
*/
async function loadProviders() {
async function loadProviders(forceRefreshSupported = false) {
try {
const providers = await window.apiClient.get('/providers');
// 获取合并后的数据(包括 providers 和 supportedProviders
const data = await window.apiClient.get('/providers');
if (!data || !data.providers) return;
// 动态更新其他模块的提供商信息,只需更新一次
if (!isStaticProviderConfigsUpdated) {
cachedSupportedProviders = await window.apiClient.get('/providers/supported');
const { providers, supportedProviders } = data;
// 检查支持列表是否发生了变化(或者是否尚未初始化)
const isChanged = !cachedSupportedProviders ||
supportedProviders.length !== cachedSupportedProviders.length ||
supportedProviders.some((p, i) => p !== cachedSupportedProviders[i]);
// 如果强制刷新或是对象类型(可能是由事件触发),则也视为需要刷新
const shouldForce = forceRefreshSupported === true || (typeof forceRefreshSupported === 'object');
if (isChanged || shouldForce) {
cachedSupportedProviders = supportedProviders;
const providerConfigs = getProviderConfigs(cachedSupportedProviders);
// 动态更新凭据文件管理的提供商类型筛选项
updateProviderFilterOptions(providerConfigs);
// 动态更新仪表盘页面的路径路由调用示例
renderRoutingExamples(providerConfigs);
// 动态更新仪表盘页面的可用模型列表提供商信息
// 动态更新各个页面的提供商信息
updateModelsProviderConfigs(providerConfigs);
// 动态更新配置教程页面的提供商信息
updateTutorialProviderConfigs(providerConfigs);
// 动态更新用量查询页面的提供商信息
updateUsageProviderConfigs(providerConfigs);
// 动态更新配置管理页面的提供商选择标签
updateConfigProviderConfigs(providerConfigs);
updateProviderFilterOptions(providerConfigs);
renderRoutingExamples(providerConfigs);
isStaticProviderConfigsUpdated = true;
}
@ -319,6 +321,7 @@ function renderProviders(providers, supportedProviders = []) {
<span class="provider-type-text">${displayName}</span>
</div>
<div class="provider-header-right">
${generateAddGroupButton(providerType)}
${generateAuthButton(providerType)}
<div class="provider-status ${statusClass}">
<i class="fas fa-${statusIcon}"></i>
@ -359,6 +362,60 @@ function renderProviders(providers, supportedProviders = []) {
container.appendChild(providerDiv);
// 为添加分组按钮添加事件监听
const addGroupBtn = providerDiv.querySelector('.add-group-btn');
if (addGroupBtn) {
addGroupBtn.addEventListener('click', (e) => {
e.stopPropagation();
// 使用自定义的主题风格 Prompt
showSimplePrompt(
t('providers.addGroup.title'),
t('providers.addGroup.suffixPlaceholder'),
async (suffix) => {
const cleanSuffix = suffix.toLowerCase().replace(/[^a-z0-9]/g, '');
if (!cleanSuffix) {
showToast(t('common.warning'), '请输入有效的后缀(仅限字母和数字)', 'warning');
return;
}
const newProviderType = `${providerType}-${cleanSuffix}`;
// 显示加载状态
addGroupBtn.disabled = true;
const originalHtml = addGroupBtn.innerHTML;
addGroupBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
const response = await window.apiClient.post('/providers', {
providerType: newProviderType,
providerConfig: {
customName: cleanSuffix.toUpperCase(),
isHealthy: true,
isDisabled: false,
usageCount: 0,
errorCount: 0
}
});
if (response.success) {
showToast(t('common.success'), t('providers.addGroup.success'), 'success');
await loadProviders(true);
setTimeout(() => openProviderManager(newProviderType), 500);
} else {
throw new Error(response.error?.message || 'Unknown error');
}
} catch (error) {
console.error('Failed to add provider group:', error);
showToast(t('common.error'), t('providers.addGroup.error') + ': ' + error.message, 'error');
addGroupBtn.disabled = false;
addGroupBtn.innerHTML = originalHtml;
}
}
);
});
}
// 为授权按钮添加事件监听
const authBtn = providerDiv.querySelector('.generate-auth-btn');
if (authBtn) {
@ -486,6 +543,74 @@ function generateAuthButton(providerType) {
`;
}
/**
* 显示一个极简的主题风格输入框
* @param {string} title - 标题
* @param {string} placeholder - 占位符
* @param {function} callback - 确认回调
*/
function showSimplePrompt(title, placeholder, callback) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.style.display = 'flex';
overlay.style.zIndex = '3000';
overlay.style.background = 'rgba(0, 0, 0, 0.2)';
overlay.style.backdropFilter = 'blur(2px)';
overlay.innerHTML = `
<div class="modal-content" style="max-width: 320px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.1); border: 1px solid var(--border-color); padding: 20px;">
<div style="margin-bottom: 12px; font-weight: 600; font-size: 14px; color: var(--text-primary);">${title}</div>
<div style="display: flex; gap: 8px;">
<input type="text" id="simple-prompt-input" placeholder="${placeholder}" style="flex: 1; padding: 8px 12px; border: 1.5px solid var(--border-color); border-radius: 6px; font-size: 13px; outline: none;">
<button id="simple-prompt-submit" class="btn btn-primary btn-sm" style="padding: 0 12px; height: 34px; border-radius: 6px; font-size: 13px;">${t('common.confirm')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#simple-prompt-input');
const submitBtn = overlay.querySelector('#simple-prompt-submit');
input.focus();
const finish = () => {
const val = input.value.trim();
if (val) {
overlay.remove();
callback(val);
}
};
submitBtn.onclick = finish;
input.onkeydown = (e) => {
if (e.key === 'Enter') finish();
if (e.key === 'Escape') overlay.remove();
};
overlay.onclick = (e) => {
if (e.target === overlay) overlay.remove();
};
}
/**
* 生成添加分组按钮HTML
* @param {string} providerType - 提供商类型
* @returns {string} 按钮HTML
*/
function generateAddGroupButton(providerType) {
const allowedTypes = ['claude-custom', 'openai-custom', 'openaiResponses-custom'];
if (!allowedTypes.includes(providerType)) {
return '';
}
return `
<button class="add-group-btn" title="${t('providers.addGroup.title')}">
<i class="fas fa-folder-plus"></i>
<span data-i18n="providers.addGroup">${t('providers.addGroup')}</span>
</button>
`;
}
/**
* 处理生成授权链接
* @param {string} providerType - 提供商类型
@ -3121,16 +3246,148 @@ async function restartServiceAfterUpdate() {
}
}
/**
* 显示添加提供商组模态框
* @param {string} defaultBaseType - 默认的基础类型
*/
function showAddProviderGroupModal(defaultBaseType = null) {
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.style.display = 'flex';
modal.style.zIndex = '2000';
// 获取所有基础母版配置,并过滤掉当前已经存在的“自定义组”
// 确保下拉菜单只显示纯净的基础类型(如 openai-custom而不显示已有的带后缀组
const allBaseConfigs = getBaseProviderConfigs();
const baseTypes = allBaseConfigs.filter(config => {
// 1. 必须在后端支持的列表中
const isSupported = cachedSupportedProviders.includes(config.id);
// 2. 限制只能添加特定类型的配置组 (Claude Custom, OpenAI Custom, OpenAI Responses)
const allowedTypes = ['claude-custom', 'openai-custom', 'openaiResponses-custom'];
const isAllowed = allowedTypes.includes(config.id);
return isSupported && isAllowed;
});
let optionsHtml = baseTypes.map(type => {
const selected = (defaultBaseType && type.id === defaultBaseType) ? 'selected' : '';
return `<option value="${type.id}" ${selected}>${type.name}</option>`;
}).join('');
const selectedConfig = allBaseConfigs.find(c => c.id === defaultBaseType);
const baseTypeSectionHtml = defaultBaseType ? `
<div class="form-group" style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: 600;" data-i18n="providers.addGroup.baseType">${t('providers.addGroup.baseType')}</label>
<div style="padding: 10px 12px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; display: flex; align-items: center; gap: 8px;">
<i class="fas ${selectedConfig?.icon || 'fa-robot'}" style="color: #6b7280;"></i>
<span style="font-weight: 500; color: #374151;">${selectedConfig?.name || defaultBaseType}</span>
</div>
<input type="hidden" id="groupBaseType" value="${defaultBaseType}">
</div>
` : `
<div class="form-group" style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px; font-weight: 600;" data-i18n="providers.addGroup.baseType">${t('providers.addGroup.baseType')}</label>
<select id="groupBaseType" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;">
${optionsHtml}
</select>
</div>
`;
modal.innerHTML = `
<div class="modal-content" style="max-width: 450px;">
<div class="modal-header">
<h3><i class="fas fa-folder-plus"></i> <span data-i18n="providers.addGroup.title">${t('providers.addGroup.title')}</span></h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
${baseTypeSectionHtml}
<div class="form-group">
<label style="display: block; margin-bottom: 5px; font-weight: 600;" data-i18n="providers.addGroup.suffix">${t('providers.addGroup.suffix')}</label>
<input type="text" id="groupSuffix" style="width: 100%; padding: 8px; border: 1px solid #ddd; border-radius: 4px;"
placeholder="${t('providers.addGroup.suffixPlaceholder')}" data-i18n-placeholder="providers.addGroup.suffixPlaceholder">
<small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">
示例: ${selectedConfig?.id || 'openai-custom'} + prod -> ${selectedConfig?.id || 'openai-custom'}-prod
</small>
</div>
</div>
<div class="modal-footer" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;">
<button class="btn btn-secondary modal-cancel" data-i18n="modal.provider.cancel">${t('modal.provider.cancel')}</button>
<button class="btn btn-primary modal-submit">
<i class="fas fa-check"></i> <span data-i18n="common.confirm">${t('common.confirm')}</span>
</button>
</div>
</div>
`;
document.body.appendChild(modal);
const closeBtn = modal.querySelector('.modal-close');
const cancelBtn = modal.querySelector('.modal-cancel');
const submitBtn = modal.querySelector('.modal-submit');
const suffixInput = modal.querySelector('#groupSuffix');
const baseTypeSelect = modal.querySelector('#groupBaseType');
const closeModal = () => modal.remove();
[closeBtn, cancelBtn].forEach(btn => btn.addEventListener('click', closeModal));
submitBtn.addEventListener('click', async () => {
const baseType = baseTypeSelect.value;
const suffix = suffixInput.value.trim().toLowerCase().replace(/[^a-z0-9]/g, '');
if (!suffix) {
showToast(t('common.warning'), '请输入有效的后缀(仅限字母和数字)', 'warning');
return;
}
const newProviderType = `${baseType}-${suffix}`;
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
// 创建一个带后缀的新提供商组,并添加一个初始的空配置(或者让用户在随后的模态框中添加)
// 这里我们先创建一个临时的空配置,这样组就会在 dashboard 中显示出来
const response = await window.apiClient.post('/providers', {
providerType: newProviderType,
providerConfig: {
customName: suffix.toUpperCase(),
isHealthy: true,
isDisabled: false,
usageCount: 0,
errorCount: 0
}
});
if (response.success) {
showToast(t('common.success'), t('providers.addGroup.success'), 'success');
closeModal();
// 重新加载提供商列表,强制刷新支持的类型
await loadProviders(true);
// 自动打开新创建的组的管理界面
setTimeout(() => openProviderManager(newProviderType), 500);
} else {
throw new Error(response.error?.message || 'Unknown error');
}
} catch (error) {
console.error('Failed to add provider group:', error);
showToast(t('common.error'), t('providers.addGroup.error') + ': ' + error.message, 'error');
submitBtn.disabled = false;
submitBtn.innerHTML = `<i class="fas fa-check"></i> <span>${t('common.confirm')}</span>`;
}
});
}
export {
loadSystemInfo,
updateTimeDisplay,
loadProviders,
renderProviders,
updateProviderStatsDisplay,
openProviderManager,
showAuthModal,
executeGenerateAuthUrl,
handleGenerateAuthUrl,
checkUpdate,
performUpdate
performUpdate,
showAddProviderGroupModal
};

View file

@ -453,8 +453,17 @@ function renderRoutingExamples(providerConfigs) {
let routeInfo = routes.find(r => r.provider === config.id);
// 如果没找到,则创建一个默认的
// 如果没找到,则创建一个默认的,并尝试继承基础类型的徽章
if (!routeInfo) {
// 尝试查找基础类型的路由信息以获取徽章
let baseRouteInfo = null;
for (const r of routes) {
if (config.id.startsWith(r.provider + '-')) {
baseRouteInfo = r;
break;
}
}
routeInfo = {
provider: config.id,
name: config.name,
@ -462,14 +471,35 @@ function renderRoutingExamples(providerConfigs) {
openai: `/${config.id}/v1/chat/completions`,
claude: `/${config.id}/v1/messages`
},
description: t('dashboard.routing.oauth'),
badge: t('dashboard.routing.oauth'),
badgeClass: 'oauth'
description: baseRouteInfo ? baseRouteInfo.description : t('dashboard.routing.oauth'),
badge: baseRouteInfo ? baseRouteInfo.badge : t('dashboard.routing.oauth'),
badgeClass: baseRouteInfo ? baseRouteInfo.badgeClass : 'oauth'
};
}
const icon = iconMap[config.id] || 'fa-route';
const defaultModel = modelMap[config.id] || 'default-model';
// 确定图标:尝试精确匹配,然后尝试前缀匹配
let icon = iconMap[config.id];
if (!icon) {
for (const baseId in iconMap) {
if (config.id.startsWith(baseId + '-')) {
icon = iconMap[baseId];
break;
}
}
}
icon = icon || 'fa-route';
// 确定默认模型:尝试精确匹配,然后尝试前缀匹配
let defaultModel = modelMap[config.id];
if (!defaultModel) {
for (const baseId in modelMap) {
if (config.id.startsWith(baseId + '-')) {
defaultModel = modelMap[baseId];
break;
}
}
}
defaultModel = defaultModel || 'default-model';
const hostname = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' ?
`http://${window.location.host}` :
`${window.location.protocol}//${window.location.host}`;

View file

@ -7,83 +7,115 @@ import { apiClient } from './auth.js';
* @param {string[]} supportedProviders - 已注册的提供商类型列表
* @returns {Object[]} 提供商配置对象数组
*/
function getProviderConfigs(supportedProviders = []) {
/**
* 获取所有基础提供商配置母版
* @returns {Object[]} 基础提供商配置数组
*/
function getBaseProviderConfigs() {
return [
{
id: 'forward-api',
name: 'NewAPI',
icon: 'fa-share-square',
visible: supportedProviders.includes('forward-api')
icon: 'fa-share-square'
},
{
id: 'gemini-cli-oauth',
name: t('dashboard.routing.nodeName.gemini'),
icon: 'fa-robot',
defaultPath: 'configs/gemini/',
visible: supportedProviders.includes('gemini-cli-oauth')
defaultPath: 'configs/gemini/'
},
{
id: 'gemini-antigravity',
name: t('dashboard.routing.nodeName.antigravity'),
icon: 'fa-rocket',
defaultPath: 'configs/antigravity/',
visible: supportedProviders.includes('gemini-antigravity')
defaultPath: 'configs/antigravity/'
},
{
id: 'claude-kiro-oauth',
name: t('dashboard.routing.nodeName.kiro'),
icon: 'fa-key',
defaultPath: 'configs/kiro/',
visible: supportedProviders.includes('claude-kiro-oauth')
defaultPath: 'configs/kiro/'
},
{
id: 'openai-codex-oauth',
name: t('dashboard.routing.nodeName.codex'),
icon: 'fa-code',
defaultPath: 'configs/codex/',
visible: supportedProviders.includes('openai-codex-oauth')
defaultPath: 'configs/codex/'
},
{
id: 'openai-qwen-oauth',
name: t('dashboard.routing.nodeName.qwen'),
icon: 'fa-cloud',
defaultPath: 'configs/qwen/',
visible: supportedProviders.includes('openai-qwen-oauth')
defaultPath: 'configs/qwen/'
},
{
id: 'openai-iflow',
name: t('dashboard.routing.nodeName.iflow'),
icon: 'fa-stream',
defaultPath: 'configs/iflow/',
visible: supportedProviders.includes('openai-iflow')
defaultPath: 'configs/iflow/'
},
{
id: 'grok-custom',
name: t('dashboard.routing.nodeName.grok'),
icon: 'fa-user-secret',
visible: supportedProviders.includes('grok-custom')
icon: 'fa-user-secret'
},
{
id: 'openai-custom',
name: t('dashboard.routing.nodeName.openai'),
icon: 'fa-microchip',
visible: supportedProviders.includes('openai-custom')
icon: 'fa-microchip'
},
{
id: 'claude-custom',
name: t('dashboard.routing.nodeName.claude'),
icon: 'fa-brain',
visible: supportedProviders.includes('claude-custom')
icon: 'fa-brain'
},
{
id: 'openaiResponses-custom',
name: 'OpenAI Responses',
icon: 'fa-reply-all',
visible: supportedProviders.includes('openaiResponses-custom')
icon: 'fa-reply-all'
},
];
}
/**
* 获取所有支持的提供商配置列表
* @param {string[]} supportedProviders - 已注册的提供商类型列表
* @returns {Object[]} 提供商配置对象数组
*/
function getProviderConfigs(supportedProviders = []) {
const baseConfigs = getBaseProviderConfigs();
const result = [];
const usedIds = new Set();
// 1. 处理 supportedProviders 中匹配基础配置的类型
baseConfigs.forEach(config => {
const isSupported = supportedProviders.includes(config.id);
result.push({ ...config, visible: isSupported });
usedIds.add(config.id);
});
// 2. 处理带有后缀的自定义类型 (例如 openai-custom-test)
supportedProviders.forEach(providerId => {
if (usedIds.has(providerId)) return;
// 查找匹配的前缀
const baseConfig = baseConfigs.find(bc => providerId.startsWith(bc.id + '-'));
if (baseConfig) {
const suffix = providerId.substring(baseConfig.id.length + 1);
result.push({
...baseConfig,
id: providerId,
name: `${baseConfig.name} (${suffix})`,
visible: true
});
usedIds.add(providerId);
}
});
return result;
}
/**
* 格式化运行时间
* @param {number} seconds - 秒数
@ -197,6 +229,7 @@ function getFieldLabel(key) {
* @returns {Array} 字段配置数组
*/
function getProviderTypeFields(providerType) {
// 基础配置字段定义
const fieldConfigs = {
'openai-custom': [
{
@ -420,7 +453,19 @@ function getProviderTypeFields(providerType) {
]
};
return fieldConfigs[providerType] || [];
// 1. 尝试精确匹配
if (fieldConfigs[providerType]) {
return fieldConfigs[providerType];
}
// 2. 尝试匹配前缀 (例如 openai-custom-test -> openai-custom)
for (const baseType in fieldConfigs) {
if (providerType.startsWith(baseType + '-')) {
return fieldConfigs[baseType];
}
}
return [];
}
/**
@ -461,6 +506,7 @@ export {
getFieldLabel,
getProviderTypeFields,
getProviderConfigs,
getBaseProviderConfigs,
getProviderStats,
apiRequest
};

View file

@ -1138,6 +1138,28 @@
transform: translateY(-1px);
}
/* 添加分组按钮样式 */
.add-group-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
background: #ecfdf5; /* 绿色系背景 */
color: #065f46; /* 绿色系文字 */
border: none;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
}
.add-group-btn:hover {
background: #10b981;
color: white;
transform: translateY(-1px);
}
/* 授权模态框样式 */
.modal-overlay {
position: fixed;

View file

@ -38,6 +38,9 @@
</div>
</div>
</div>
<div id="providers-header-actions" class="header-actions" style="margin-bottom: 15px; display: flex; justify-content: flex-end;">
<!-- 此处的添加分组按钮已移至 providersList 末尾 -->
</div>
<div class="providers-container">
<div id="providersList" class="providers-list">
<!-- Providers will be loaded here -->