新增对Provider支持启用禁用的能力

This commit is contained in:
Von committed 2026-04-02 23:19:59 +08:00
1 parent ea5ca42c2c
commit d03b5466da
11 files changed
+792 -13

No files matched your search

+5 -1
View File
@@ -43,6 +43,7 @@ server:
providers:
- name: openai-chat
# enabled: false # 可选;默认 true;禁用后不会出现在 /v1/models
api: https://api.openai.com/v1/chat/completions
transport: http
source_format: openai_chat
@@ -56,6 +57,7 @@ providers:
也可以直接从 [config.sample.yaml](config.sample.yaml) 开始裁剪。
注意:下游调用时的模型名不是裸模型名,而是 `provider/model`,例如 `openai-chat/gpt-4.1`。
如果未配置 `providers[].enabled`,默认就是启用状态。
### 3. 启动服务
@@ -152,6 +154,7 @@ curl http://127.0.0.1:8080/v1/chat/completions \
### `providers[]` 字段说明
- `name`:Provider 名称,必须唯一;下游模型名会以它作为前缀
- `enabled`:是否启用;默认 `true`。设为 `false` 后该 Provider 不参与运行时注册,也不会出现在 `GET /v1/models`
- `api`:上游接口地址,支持 `http://`、`https://`、`ws://`、`wss://`
- `transport`:上游传输方式,支持 `http` 和 `websocket`
- `source_format`:上游真实协议格式
@@ -292,11 +295,12 @@ providers:
- 按 `target_format` 校验当前路由是否匹配,避免请求打错入口
- 支持流式和非流式响应
- 自动识别 SSE、NDJSON、WebSocket JSON 等上游返回形态
- `GET /v1/models` 会返回模型列表及 `provider_name`、`source_format`、`target_format`、`transport` 等元信息
- `GET /v1/models` 会返回当前已启用 Provider 的模型列表,以及 `provider_name`、`source_format`、`target_format`、`transport` 等元信息
### 2. Provider 与 Auth Group 管理
- Provider 增删改查
- 支持行内启用 / 禁用,以及批量启用 / 禁用 / 删除
- 拉取上游模型列表并辅助填充 `model_list`
- Auth Group 增删改查
- YAML 批量导入 Auth Entries
+1
View File
@@ -32,6 +32,7 @@ auth_groups:
providers:
- name: openai-chat
# enabled: true # optional, defaults to true; disabled providers are hidden from /v1/models
api: https://api.openai.com/v1/chat/completions
transport: http
source_format: openai_chat
+1
View File
@@ -8,6 +8,7 @@ admin:
password: admin
providers:
- name: volcengine
enabled: true
api: https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions
transport: http
source_format: openai_chat
+13
View File
@@ -29,6 +29,7 @@ SUPPORTED_PROVIDER_API_SCHEMES = {"http", "https", "ws", "wss"}
SUPPORTED_AUTH_GROUP_STRATEGIES = {DEFAULT_AUTH_GROUP_STRATEGY}
SUPPORTED_PROVIDER_FIELDS = {
"name",
"enabled",
"api",
"transport",
"source_format",
@@ -367,6 +368,7 @@ class ProviderConfigSchema:
name: str
api: str
enabled: bool = True
transport: str = DEFAULT_PROVIDER_TRANSPORT
source_format: str = DEFAULT_PROVIDER_SOURCE_FORMAT
target_format: str = DEFAULT_PROVIDER_TARGET_FORMAT
@@ -407,6 +409,12 @@ class ProviderConfigSchema:
return cls(
name=name,
enabled=parse_optional_bool(
config.get("enabled"),
default=True,
error_message="Provider enabled must be a boolean value",
)
is not False,
api=api,
transport=resolve_provider_transport(api, config.get("transport")),
source_format=resolve_provider_protocol(
@@ -432,6 +440,7 @@ class ProviderConfigSchema:
def to_mapping(self) -> Dict[str, Any]:
config: Dict[str, Any] = {
"name": self.name,
"enabled": bool(self.enabled),
"api": self.api,
"transport": self.transport,
"source_format": self.source_format,
@@ -463,6 +472,7 @@ class RuntimeProviderSpec:
"""Provider runtime specification."""
name: str
enabled: bool
api: str
transport: str
source_format: str
@@ -484,6 +494,7 @@ class RuntimeProviderSpec:
def from_schema(cls, config: ProviderConfigSchema) -> "RuntimeProviderSpec":
return cls(
name=config.name,
enabled=bool(config.enabled),
api=config.api,
transport=config.transport,
source_format=config.source_format,
@@ -508,6 +519,7 @@ class ProviderRuntimeView:
"""Read-only runtime view exposed by ProviderManager."""
name: str
enabled: bool
api: str
transport: str
source_format: str
@@ -525,6 +537,7 @@ class ProviderRuntimeView:
def from_spec(cls, spec: RuntimeProviderSpec, *, legacy_api_key: bool = False) -> "ProviderRuntimeView":
return cls(
name=spec.name,
enabled=bool(spec.enabled),
api=spec.api,
transport=spec.transport,
source_format=spec.source_format,
+4
View File
@@ -68,6 +68,10 @@ class ProviderManager:
if spec.name in self._provider_by_name:
raise ValueError(f"Duplicate provider name detected: {spec.name}")
if not spec.enabled:
self._logger.info("Provider '%s' disabled: skipped runtime registration", spec.name)
return
if not spec.model_list:
self._logger.warning("Provider '%s' skipped: model_list is empty", spec.name)
return
+53
View File
@@ -41,11 +41,14 @@ class ProviderController:
self._app.route('/api/providers', methods=['GET'])(auth(self.get_providers))
self._app.route('/api/providers', methods=['POST'])(auth(self.create_provider))
self._app.route('/api/providers/batch', methods=['POST'])(auth(self.batch_providers))
self._app.route('/api/providers/fetch-models', methods=['GET'])(auth(self.fetch_models))
self._app.route('/api/providers/chat-whitelist', methods=['PUT'])(auth(self.update_chat_whitelist))
self._app.route('/api/providers/<string:name>', methods=['GET'])(auth(self.get_provider))
self._app.route('/api/providers/<string:name>', methods=['PUT'])(auth(self.update_provider))
self._app.route('/api/providers/<string:name>', methods=['DELETE'])(auth(self.delete_provider))
self._app.route('/api/providers/<string:name>/disable', methods=['POST'])(auth(self.disable_provider))
self._app.route('/api/providers/<string:name>/enable', methods=['POST'])(auth(self.enable_provider))
self._app.route('/api/auth-groups', methods=['GET'])(auth(self.get_auth_groups))
self._app.route('/api/auth-groups', methods=['POST'])(auth(self.create_auth_group))
self._app.route('/api/auth-groups/import-entries', methods=['POST'])(auth(self.import_auth_group_entries))
@@ -128,6 +131,56 @@ class ProviderController:
self._logger.error('Error deleting provider: %s', exc)
return jsonify({'error': str(exc)}), 500
def disable_provider(self, name: str) -> Response:
try:
provider = self._provider_service.set_provider_enabled(name, enabled=False)
self._logger.info('Provider disabled: %s', name)
return jsonify(provider)
except ValueError as exc:
message = str(exc)
status_code = 404 if 'not found' in message.lower() else 400
return jsonify({'error': message}), status_code
except Exception as exc:
self._logger.error('Error disabling provider: %s', exc)
return jsonify({'error': str(exc)}), 500
def enable_provider(self, name: str) -> Response:
try:
provider = self._provider_service.set_provider_enabled(name, enabled=True)
self._logger.info('Provider enabled: %s', name)
return jsonify(provider)
except ValueError as exc:
message = str(exc)
status_code = 404 if 'not found' in message.lower() else 400
return jsonify({'error': message}), status_code
except Exception as exc:
self._logger.error('Error enabling provider: %s', exc)
return jsonify({'error': str(exc)}), 500
def batch_providers(self) -> Response:
try:
payload = request.get_json(silent=True) or {}
action = str(payload.get('action') or '').strip().lower()
names = payload.get('names')
if action == 'enable':
result = self._provider_service.batch_set_provider_enabled(names, enabled=True)
elif action == 'disable':
result = self._provider_service.batch_set_provider_enabled(names, enabled=False)
elif action == 'delete':
result = self._provider_service.batch_delete_providers(names)
else:
raise ValueError(f'Unsupported provider batch action: {action or "<empty>"}')
self._logger.info('Provider batch action completed: action=%s count=%s', action, result.get('count', 0))
return jsonify(result)
except ValueError as exc:
message = str(exc)
status_code = 404 if 'not found' in message.lower() else 400
return jsonify({'error': message}), status_code
except Exception as exc:
self._logger.error('Error applying provider batch action: %s', exc)
return jsonify({'error': str(exc)}), 500
def get_auth_groups(self) -> Response:
try:
return jsonify(self._auth_group_service.list_auth_groups())
+31 -2
View File
@@ -177,6 +177,10 @@
margin-bottom: 14px;
}
.providers-page .provider-batch-summary {
margin-bottom: 14px;
}
.providers-page .providers-table-shell {
overflow: auto;
border: 1px solid var(--line);
@@ -329,6 +333,25 @@
background: var(--surface-hover);
}
.providers-page .providers-table th.provider-select-column,
.providers-page .providers-table td.provider-select-cell {
width: 48px;
min-width: 48px;
text-align: center;
vertical-align: middle;
}
.providers-page .provider-table-checkbox {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
.providers-page .provider-table-checkbox:disabled {
cursor: wait;
}
.providers-page .providers-table td.provider-name-cell {
vertical-align: middle;
color: var(--text-primary);
@@ -764,8 +787,10 @@
transition: background-color var(--transition), border-color var(--transition), color var(--transition);
}
.providers-page .providers-table td:last-child {
width: 124px;
.providers-page .providers-table td.provider-actions-cell,
.providers-page .providers-table th:last-child {
width: 1%;
white-space: nowrap;
vertical-align: middle;
}
@@ -1029,6 +1054,10 @@
max-width: min(1120px, calc(100vw - 32px));
}
.providers-page .provider-batch-delete-modal-dialog {
max-width: min(420px, calc(100vw - 32px));
}
.providers-page .auth-entry-yaml-modal-dialog {
max-width: min(1180px, calc(100vw - 32px));
}
+306 -7
View File
@@ -1,7 +1,7 @@
{% extends "base_admin.html" %}
{% block page_title %}Provider 管理{% endblock %}
{% block page_css %}<link rel="stylesheet" href="/static/css/providers.css?v=20260402-5">{% endblock %}
{% block page_css %}<link rel="stylesheet" href="/static/css/providers.css?v=20260402-6">{% endblock %}
{% block body_class %}providers-page{% endblock %}
{% block header_title %}Provider 管理{% endblock %}
{% block header_subtitle %}维护 providers 配置,修改后自动重载生效。{% endblock %}
@@ -47,6 +47,13 @@
<button type="button" class="btn btn-primary" onclick="openCreateModal()">新增 Provider</button>
</div>
</div>
<div class="toolbar-summary provider-batch-summary">
<span class="toolbar-pill">已选 <strong id="selectedProviderCount">0</strong> 个</span>
<button type="button" class="btn btn-toolbar-secondary" id="enableSelectedProvidersBtn" onclick="runProviderBatchAction('enable')" disabled>批量启用</button>
<button type="button" class="btn btn-toolbar-secondary" id="disableSelectedProvidersBtn" onclick="runProviderBatchAction('disable')" disabled>批量禁用</button>
<button type="button" class="btn btn-toolbar-secondary" id="clearSelectedProvidersBtn" onclick="clearSelectedProviders()" disabled>清空选择</button>
<button type="button" class="btn btn-toolbar-secondary" id="deleteSelectedProvidersBtn" onclick="runProviderBatchAction('delete')" disabled>批量删除</button>
</div>
<div class="providers-table-shell" id="providersContainer">
<div class="loading">正在加载 provider 配置...</div>
</div>
@@ -437,6 +444,24 @@
</div>
</div>
</div>
<div class="modal fade" id="providerBatchDeleteModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered provider-batch-delete-modal-dialog">
<div class="modal-content border-0 shadow">
<div class="modal-header border-bottom-0">
<div>
<h5 class="modal-title fw-semibold">确认批量删除</h5>
</div>
</div>
<div class="modal-body pt-0">
<p class="mb-0 fw-semibold" id="providerBatchDeleteMessage">是否批量删除 0 个 Provider?</p>
</div>
<div class="modal-footer border-top-0">
<button type="button" class="btn btn-secondary" id="cancelProviderBatchDeleteBtn" data-bs-dismiss="modal">取消</button>
<button type="button" class="btn btn-danger" id="confirmProviderBatchDeleteBtn" onclick="confirmProviderBatchDelete()">确认删除</button>
</div>
</div>
</div>
</div>
<div class="inline-confirm-popover floating-delete-popover" id="providerDeletePopover" data-placement="bottom" hidden>
<div class="inline-confirm-text">确认删除?</div>
@@ -473,6 +498,7 @@
let editingProviderName = null;
let editingAuthGroupName = null;
let providerModalInstance = null;
let providerBatchDeleteModalInstance = null;
let authGroupModalInstance = null;
let authEntryImportModalInstance = null;
let authGroupRuntimeModalInstance = null;
@@ -484,6 +510,10 @@
let activeAuthGroupRuntime = null;
let pendingDeleteProviderName = null;
let deletingProviderName = null;
let pendingProviderBatchDeleteNames = [];
let selectedProviderNames = new Set();
let togglingProviderNames = new Set();
let providerBatchActionInFlight = false;
let fetchedModelCandidates = [];
let fetchedModelSelection = new Set();
let fetchedModelExistingSelection = new Set();
@@ -832,6 +862,177 @@
popover.style.setProperty('--popover-arrow-left', `${arrowLeft}px`);
}
function getNormalizedSelectedProviderNames() {
const availableNames = new Set(
providers
.map(provider => String(provider?.name || '').trim())
.filter(name => name !== '')
);
selectedProviderNames = new Set(
Array.from(selectedProviderNames).filter(name => availableNames.has(name))
);
return providers
.map(provider => String(provider?.name || '').trim())
.filter(name => name !== '' && selectedProviderNames.has(name));
}
function updateProviderSelectionUi() {
const selectedNames = getNormalizedSelectedProviderNames();
const selectedCount = selectedNames.length;
const totalCount = providers.length;
const selectedCountElement = document.getElementById('selectedProviderCount');
if (selectedCountElement) {
selectedCountElement.textContent = String(selectedCount);
}
const selectAllCheckbox = document.getElementById('providerSelectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.checked = totalCount > 0 && selectedCount === totalCount;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < totalCount;
selectAllCheckbox.disabled = providerBatchActionInFlight || totalCount === 0;
}
document.querySelectorAll('[data-provider-row-checkbox]').forEach(element => {
if (!(element instanceof HTMLInputElement)) return;
element.disabled = providerBatchActionInFlight;
});
const shouldDisableBatchButtons = providerBatchActionInFlight || selectedCount === 0;
[
'enableSelectedProvidersBtn',
'disableSelectedProvidersBtn',
'deleteSelectedProvidersBtn',
'clearSelectedProvidersBtn',
].forEach(id => {
const button = document.getElementById(id);
if (!button) return;
button.disabled = shouldDisableBatchButtons;
});
}
function toggleProviderSelection(name, checked) {
const normalizedName = String(name || '').trim();
if (!normalizedName) return;
if (checked) {
selectedProviderNames.add(normalizedName);
} else {
selectedProviderNames.delete(normalizedName);
}
updateProviderSelectionUi();
}
function toggleAllProvidersSelection(checked) {
if (checked) {
selectedProviderNames = new Set(
providers
.map(provider => String(provider?.name || '').trim())
.filter(name => name !== '')
);
} else {
selectedProviderNames = new Set();
}
renderProviders();
}
function clearSelectedProviders() {
selectedProviderNames = new Set();
renderProviders();
}
function syncProviderBatchDeleteModal() {
const count = pendingProviderBatchDeleteNames.length;
const messageElement = document.getElementById('providerBatchDeleteMessage');
const cancelButton = document.getElementById('cancelProviderBatchDeleteBtn');
const confirmButton = document.getElementById('confirmProviderBatchDeleteBtn');
if (messageElement) {
messageElement.textContent = `是否批量删除 ${count} 个 Provider?`;
}
if (cancelButton) {
cancelButton.disabled = providerBatchActionInFlight;
}
if (confirmButton) {
confirmButton.disabled = providerBatchActionInFlight || count === 0;
confirmButton.textContent = providerBatchActionInFlight ? '删除中...' : '确认删除';
}
}
function openProviderBatchDeleteModal(names) {
const normalizedNames = Array.isArray(names)
? names.map(name => String(name || '').trim()).filter(name => name !== '')
: [];
if (!normalizedNames.length) {
showMessage('请先选择至少一个 Provider', 'error');
return;
}
pendingProviderBatchDeleteNames = normalizedNames;
syncProviderBatchDeleteModal();
providerBatchDeleteModalInstance?.show();
}
function getProviderBatchActionMeta(action) {
return {
enable: { label: '启用', past: '启用', fallback: '批量启用 Provider 失败' },
disable: { label: '禁用', past: '禁用', fallback: '批量禁用 Provider 失败' },
delete: { label: '删除', past: '删除', fallback: '批量删除 Provider 失败' },
}[action] || null;
}
async function executeProviderBatchAction(action, selectedNames, meta, options = {}) {
const { closeDeleteModal = false } = options || {};
closeDeleteProviderConfirm(false);
providerBatchActionInFlight = true;
syncProviderBatchDeleteModal();
renderProviders();
try {
const response = await fetch('/api/providers/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: action,
names: selectedNames,
}),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || meta.fallback);
}
selectedProviderNames = action === 'delete' ? new Set() : new Set(selectedNames);
if (action === 'delete') {
pendingProviderBatchDeleteNames = [];
}
if (closeDeleteModal) {
providerBatchDeleteModalInstance?.hide();
}
showMessage(`已批量${meta.past} ${result.count || selectedNames.length} 个 Provider,配置已重载`, 'success');
await loadProviders();
} catch (error) {
console.error(`批量${meta.label} provider 失败:`, error);
showActionError(`批量${meta.label} Provider`, error, { fallback: meta.fallback });
} finally {
providerBatchActionInFlight = false;
syncProviderBatchDeleteModal();
renderProviders();
}
}
async function confirmProviderBatchDelete() {
const selectedNames = pendingProviderBatchDeleteNames.slice();
if (!selectedNames.length || providerBatchActionInFlight) {
return;
}
const meta = getProviderBatchActionMeta('delete');
if (!meta) {
return;
}
await executeProviderBatchAction('delete', selectedNames, meta, { closeDeleteModal: true });
}
function getAuthEntryDeletePopoverElement() {
return document.getElementById('authEntryDeletePopover');
}
@@ -1568,11 +1769,13 @@
throw new Error(data.error || '加载 provider 失败');
}
providers = Array.isArray(data) ? data : [];
getNormalizedSelectedProviderNames();
renderProviderAuthGroupOptions(document.getElementById('providerAuthGroup')?.value || '');
renderProviders();
} catch (error) {
console.error('加载 provider 失败:', error);
document.getElementById('providersContainer').innerHTML = '<div class="error">加载 provider 失败</div>';
updateProviderSelectionUi();
}
}
@@ -1673,6 +1876,7 @@
}
if (!providers.length) {
container.innerHTML = '<div class="empty-state">当前没有 provider 配置</div>';
updateProviderSelectionUi();
return;
}
@@ -1680,6 +1884,14 @@
<table class="providers-table">
<thead>
<tr>
<th class="provider-select-column">
<input
type="checkbox"
class="provider-table-checkbox"
id="providerSelectAllCheckbox"
onchange="toggleAllProvidersSelection(this.checked)"
>
</th>
<th>名称</th>
<th>API 地址</th>
<th>模型</th>
@@ -1690,9 +1902,13 @@
`;
providers.forEach(provider => {
const isEnabled = provider?.enabled !== false;
const encodedProviderName = encodeURIComponent(provider.name);
const isSelected = selectedProviderNames.has(provider.name);
const models = Array.isArray(provider.model_list) ? provider.model_list : [];
const preview = getModelPreview(models);
const isDeletePending = pendingDeleteProviderName === provider.name;
const isTogglePending = togglingProviderNames.has(provider.name);
const modelTags = preview.visible.length
? preview.visible.map(model => `<span class="model-tag">${escapeHtml(model)}</span>`).join('')
: '<span class="muted-text">未配置</span>';
@@ -1701,19 +1917,30 @@
<div class="model-summary-bar">
<span class="model-count">共 ${preview.total} 个</span>
${preview.hiddenCount ? `<span class="model-more">+${preview.hiddenCount}</span>` : ''}
<button class="btn-inline" type="button" onclick="openModelDetail(decodeProviderName('${encodeURIComponent(provider.name)}'))">查看全部</button>
<button class="btn-inline" type="button" onclick="openModelDetail(decodeProviderName('${encodedProviderName}'))">查看全部</button>
</div>
`
: '';
html += `
<tr>
<td class="provider-select-cell">
<input
type="checkbox"
class="provider-table-checkbox"
data-provider-row-checkbox="${encodedProviderName}"
onchange="toggleProviderSelection(decodeProviderName('${encodedProviderName}'), this.checked)"
${isSelected ? 'checked' : ''}
${providerBatchActionInFlight ? 'disabled' : ''}
>
</td>
<td class="provider-name-cell">
<span class="provider-name-text">${escapeHtml(provider.name)}</span>
</td>
<td class="provider-api-cell" title="${escapeHtml(provider.api)}">
<span class="provider-api-text">${escapeHtml(provider.api)}</span>
<div class="provider-meta-line">
<span class="provider-chip ${isEnabled ? 'provider-chip-hook' : 'provider-chip-muted'}">${isEnabled ? 'enabled' : 'disabled'}</span>
<span class="provider-chip">${escapeHtml(provider.transport || 'http')}</span>
<span class="provider-chip">${escapeHtml(provider.source_format || 'openai_chat')}</span>
<span class="provider-chip provider-chip-target">${escapeHtml(provider.target_format || 'openai_chat')}</span>
@@ -1726,15 +1953,21 @@
<div class="model-tags model-tags-preview">${modelTags}</div>
${modelSummary}
</td>
<td>
<td class="provider-actions-cell">
<div class="action-buttons">
<button class="btn-action btn-edit" onclick="openEditModal(decodeProviderName('${encodeURIComponent(provider.name)}'))">编辑</button>
<button class="btn-action btn-edit" onclick="openEditModal(decodeProviderName('${encodedProviderName}'))">编辑</button>
<button
class="btn-action ${isEnabled ? 'btn-delete' : 'btn-edit'}"
onclick="setProviderEnabled(decodeProviderName('${encodedProviderName}'), ${isEnabled ? 'false' : 'true'})"
${isTogglePending || providerBatchActionInFlight ? 'disabled' : ''}
>${isTogglePending ? '处理中...' : (isEnabled ? '禁用' : '启用')}</button>
<div class="inline-confirm-wrap">
<button
type="button"
class="btn-action btn-delete ${isDeletePending ? 'is-active' : ''}"
data-provider-delete-trigger="${encodeURIComponent(provider.name)}"
onclick="toggleDeleteProviderConfirm(decodeProviderName('${encodeURIComponent(provider.name)}'), event)"
data-provider-delete-trigger="${encodedProviderName}"
onclick="toggleDeleteProviderConfirm(decodeProviderName('${encodedProviderName}'), event)"
${providerBatchActionInFlight ? 'disabled' : ''}
>删除</button>
</div>
</div>
@@ -1745,6 +1978,7 @@
html += '</tbody></table>';
container.innerHTML = html;
updateProviderSelectionUi();
syncDeleteProviderPopover();
}
@@ -2899,9 +3133,64 @@
}
}
async function setProviderEnabled(name, enabled) {
const normalizedName = String(name || '').trim();
if (!normalizedName || providerBatchActionInFlight || togglingProviderNames.has(normalizedName)) {
return;
}
closeDeleteProviderConfirm(false);
togglingProviderNames.add(normalizedName);
renderProviders();
try {
const response = await fetch(
`/api/providers/${encodeURIComponent(normalizedName)}/${enabled ? 'enable' : 'disable'}`,
{ method: 'POST' }
);
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || `${enabled ? '启用' : '禁用'} provider 失败`);
}
showMessage(`Provider 已${enabled ? '启用' : '禁用'},配置已重载`, 'success');
await loadProviders();
} catch (error) {
console.error(`${enabled ? '启用' : '禁用'} provider 失败:`, error);
showActionError(`${enabled ? '启用' : '禁用'} Provider`, error, {
fallback: `${enabled ? '启用' : '禁用'} Provider 失败`,
});
} finally {
togglingProviderNames.delete(normalizedName);
renderProviders();
}
}
async function runProviderBatchAction(action) {
const selectedNames = getNormalizedSelectedProviderNames();
if (!selectedNames.length || providerBatchActionInFlight) {
if (!selectedNames.length) {
showMessage('请先选择至少一个 Provider', 'error');
}
return;
}
const meta = getProviderBatchActionMeta(action);
if (!meta) {
showMessage(`不支持的批量操作: ${action}`, 'error');
return;
}
if (action === 'delete') {
openProviderBatchDeleteModal(selectedNames);
return;
}
await executeProviderBatchAction(action, selectedNames, meta);
}
function toggleDeleteProviderConfirm(name, event) {
event?.stopPropagation();
if (deletingProviderName !== null) return;
if (deletingProviderName !== null || providerBatchActionInFlight) return;
pendingDeleteProviderName = pendingDeleteProviderName === name ? null : name;
closeDeleteAuthEntryConfirm(false);
@@ -2949,6 +3238,7 @@
if (!response.ok) {
throw new Error(result.error || '删除 provider 失败');
}
selectedProviderNames.delete(name);
pendingDeleteProviderName = null;
deletingProviderName = null;
hideProviderDeletePopover();
@@ -3021,6 +3311,14 @@
editingProviderName = null;
fillForm(null);
});
const providerBatchDeleteModalElement = document.getElementById('providerBatchDeleteModal');
providerBatchDeleteModalInstance = new bootstrap.Modal(providerBatchDeleteModalElement);
providerBatchDeleteModalElement.addEventListener('hidden.bs.modal', function() {
if (!providerBatchActionInFlight) {
pendingProviderBatchDeleteNames = [];
syncProviderBatchDeleteModal();
}
});
const authGroupModalElement = document.getElementById('authGroupModal');
authGroupModalInstance = new bootstrap.Modal(authGroupModalElement);
authGroupModalElement.addEventListener('hidden.bs.modal', function() {
@@ -3134,6 +3432,7 @@
updateModelListCount();
updateProviderAuthModeFields();
updateFetchModelsButtonState();
updateProviderSelectionUi();
loadPageData();
});
</script>
+90 -3
View File
@@ -52,13 +52,17 @@ class ProviderService:
def update_provider(self, current_name: str, payload: Dict[str, Any]) -> Dict[str, Any]:
config = self._config_manager.get_raw_config()
providers = self._extract_providers(config)
provider_config = ProviderConfigSchema.from_payload(payload)
normalized = provider_config.to_mapping()
target = self._find_provider(providers, current_name)
if target is None:
raise ValueError(f'Provider not found: {current_name}')
normalized_payload = dict(payload)
if 'enabled' not in normalized_payload:
normalized_payload['enabled'] = ProviderConfigSchema.from_mapping(target).enabled
provider_config = ProviderConfigSchema.from_payload(normalized_payload)
normalized = provider_config.to_mapping()
duplicate = self._find_provider(providers, provider_config.name)
if duplicate is not None and duplicate is not target:
raise ValueError(f"Provider already exists: {provider_config.name}")
@@ -77,6 +81,56 @@ class ProviderService:
providers.remove(target)
self._save_providers(config, providers)
def set_provider_enabled(self, name: str, enabled: bool) -> Dict[str, Any]:
config = self._config_manager.get_raw_config()
providers = self._extract_providers(config)
target = self._find_provider(providers, name)
if target is None:
raise ValueError(f'Provider not found: {name}')
target_index = providers.index(target)
normalized = ProviderConfigSchema.from_mapping(target).to_mapping()
normalized['enabled'] = bool(enabled)
providers[target_index] = ProviderConfigSchema.from_payload(normalized).to_mapping()
self._save_providers(config, providers)
return providers[target_index]
def batch_set_provider_enabled(self, names: List[str], enabled: bool) -> Dict[str, Any]:
normalized_names = self._normalize_provider_names(names)
config = self._config_manager.get_raw_config()
providers = self._extract_providers(config)
target_indexes = self._find_provider_indexes(providers, normalized_names)
for target_index in target_indexes:
normalized = ProviderConfigSchema.from_mapping(providers[target_index]).to_mapping()
normalized['enabled'] = bool(enabled)
providers[target_index] = ProviderConfigSchema.from_payload(normalized).to_mapping()
self._save_providers(config, providers)
return {
'count': len(normalized_names),
'names': normalized_names,
'enabled': bool(enabled),
}
def batch_delete_providers(self, names: List[str]) -> Dict[str, Any]:
normalized_names = self._normalize_provider_names(names)
config = self._config_manager.get_raw_config()
providers = self._extract_providers(config)
self._find_provider_indexes(providers, normalized_names)
name_set = set(normalized_names)
providers = [
provider
for provider in providers
if str(provider.get('name', '')).strip() not in name_set
]
self._save_providers(config, providers)
return {
'count': len(normalized_names),
'names': normalized_names,
}
def _save_providers(self, config: Dict[str, Any], providers: List[Dict[str, Any]]) -> None:
self._validate_providers(config, providers)
config['providers'] = providers
@@ -121,3 +175,36 @@ class ProviderService:
ProviderService._extract_auth_groups(config),
providers,
)
@staticmethod
def _normalize_provider_names(names: Any) -> List[str]:
if not isinstance(names, list):
raise ValueError('Provider names must be a non-empty list')
normalized_names: List[str] = []
seen_names = set()
for raw_name in names:
name = str(raw_name or '').strip()
if not name:
raise ValueError('Provider names must not be empty')
if name in seen_names:
continue
seen_names.add(name)
normalized_names.append(name)
if not normalized_names:
raise ValueError('Provider names must be a non-empty list')
return normalized_names
@staticmethod
def _find_provider_indexes(providers: List[Dict[str, Any]], names: List[str]) -> List[int]:
indexes_by_name = {
str(provider.get('name', '')).strip(): index
for index, provider in enumerate(providers)
}
missing_names = [name for name in names if name not in indexes_by_name]
if missing_names:
if len(missing_names) == 1:
raise ValueError(f'Provider not found: {missing_names[0]}')
raise ValueError(f"Providers not found: {', '.join(missing_names)}")
return [indexes_by_name[name] for name in names]
+169
View File
@@ -0,0 +1,169 @@
import sys
import tempfile
import unittest
from pathlib import Path
from flask import Flask
import yaml
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.application.app_context import AppContext
from src.config.config_manager import ConfigManager
from src.services.provider_service import ProviderService
class FakeLogger:
def info(self, msg: str, *args) -> None:
del msg, args
def warning(self, msg: str, *args) -> None:
del msg, args
def error(self, msg: str, *args) -> None:
del msg, args
def debug(self, msg: str, *args) -> None:
del msg, args
class ProviderServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory()
self.root_path = Path(self.temp_dir.name)
self.config_path = self.root_path / "config.yaml"
self._write_config({"auth_groups": [], "providers": []})
self.config_manager = ConfigManager(self.config_path, self.root_path)
self.reload_count = 0
def reload_callback() -> None:
self.reload_count += 1
self.config_manager.reload()
ctx = AppContext(
logger=FakeLogger(),
config_manager=self.config_manager,
root_path=self.root_path,
flask_app=Flask(__name__),
)
self.service = ProviderService(ctx, reload_callback)
def tearDown(self) -> None:
self.temp_dir.cleanup()
def _write_config(self, payload) -> None:
with open(self.config_path, "w", encoding="utf-8") as handle:
yaml.safe_dump(payload, handle, allow_unicode=True, sort_keys=False)
def _seed_providers(self, providers) -> None:
self._write_config({"auth_groups": [], "providers": providers})
self.config_manager.reload()
self.reload_count = 0
def test_update_provider_preserves_enabled_when_payload_omits_enabled(self) -> None:
self._seed_providers(
[
{
"name": "demo",
"enabled": False,
"api": "https://example.com/v1/chat/completions",
"api_key": "sk-demo",
"model_list": ["gpt-4.1"],
}
]
)
updated = self.service.update_provider(
"demo",
{
"name": "demo",
"api": "https://example.com/v1/chat/completions",
"api_key": "sk-demo",
"model_list": ["gpt-4.1"],
"hook": "example_hook.py",
},
)
self.assertFalse(updated["enabled"])
self.assertEqual("example_hook.py", updated["hook"])
self.assertEqual(1, self.reload_count)
current_config = self.config_manager.get_raw_config()
self.assertFalse(current_config["providers"][0]["enabled"])
self.assertEqual("example_hook.py", current_config["providers"][0]["hook"])
def test_set_provider_enabled_updates_config(self) -> None:
self._seed_providers(
[
{
"name": "demo",
"api": "https://example.com/v1/chat/completions",
"api_key": "sk-demo",
"model_list": ["gpt-4.1"],
}
]
)
updated = self.service.set_provider_enabled("demo", False)
self.assertFalse(updated["enabled"])
self.assertEqual(1, self.reload_count)
current_config = self.config_manager.get_raw_config()
self.assertFalse(current_config["providers"][0]["enabled"])
def test_batch_set_provider_enabled_updates_multiple_providers(self) -> None:
self._seed_providers(
[
{
"name": "demo-a",
"api": "https://example.com/v1/chat/completions",
"api_key": "sk-a",
"model_list": ["gpt-4.1"],
},
{
"name": "demo-b",
"enabled": False,
"api": "https://example.com/v1/chat/completions",
"api_key": "sk-b",
"model_list": ["gpt-4.1-mini"],
},
]
)
result = self.service.batch_set_provider_enabled(["demo-a", "demo-b"], True)
self.assertEqual(2, result["count"])
self.assertTrue(result["enabled"])
self.assertEqual(["demo-a", "demo-b"], result["names"])
self.assertEqual(1, self.reload_count)
current_config = self.config_manager.get_raw_config()
self.assertEqual([True, True], [item["enabled"] for item in current_config["providers"]])
def test_batch_delete_providers_removes_selected_entries(self) -> None:
self._seed_providers(
[
{
"name": "demo-a",
"api": "https://example.com/v1/chat/completions",
"api_key": "sk-a",
"model_list": ["gpt-4.1"],
},
{
"name": "demo-b",
"api": "https://example.com/v1/chat/completions",
"api_key": "sk-b",
"model_list": ["gpt-4.1-mini"],
},
]
)
result = self.service.batch_delete_providers(["demo-b"])
self.assertEqual(1, result["count"])
self.assertEqual(["demo-b"], result["names"])
self.assertEqual(1, self.reload_count)
current_config = self.config_manager.get_raw_config()
self.assertEqual(["demo-a"], [item["name"] for item in current_config["providers"]])
if __name__ == "__main__":
unittest.main()
+119
View File
@@ -5,10 +5,13 @@ import sys
import unittest
from pathlib import Path
from flask import Flask
from websocket import ABNF
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.application.app_context import AppContext
from src.config.provider_manager import ProviderManager
from src.config.provider_config import ProviderConfigSchema, RuntimeProviderSpec
from src.external.stream_probe import probe_stream_response
from src.external.upstream_websocket import (
@@ -37,7 +40,51 @@ class FakeWebSocketConnection:
self.closed = True
class FakeLogger:
def __init__(self) -> None:
self.records: list[tuple[str, str]] = []
def _log(self, level: str, msg: str, *args) -> None:
rendered = msg % args if args else msg
self.records.append((level, rendered))
def info(self, msg: str, *args) -> None:
self._log("info", msg, *args)
def warning(self, msg: str, *args) -> None:
self._log("warning", msg, *args)
def error(self, msg: str, *args) -> None:
self._log("error", msg, *args)
def debug(self, msg: str, *args) -> None:
self._log("debug", msg, *args)
class FakeAuthGroupManager:
def __init__(self) -> None:
self.registered: list[tuple[str, str]] = []
def register_legacy_provider_group(self, provider_name: str, api_key: str) -> str:
self.registered.append((provider_name, api_key))
return f"__legacy_provider__/{provider_name}"
class ProviderTransportTests(unittest.TestCase):
def test_provider_enabled_defaults_to_true(self) -> None:
schema = ProviderConfigSchema.from_mapping(
{
"name": "codex",
"api": "https://example.com/v1/chat/completions",
"api_key": "demo-key",
"model_list": ["gpt-4.1"],
}
)
self.assertTrue(schema.enabled)
runtime = RuntimeProviderSpec.from_schema(schema)
self.assertTrue(runtime.enabled)
def test_provider_transport_defaults_to_websocket_for_ws_scheme(self) -> None:
schema = ProviderConfigSchema.from_mapping(
{
@@ -163,6 +210,49 @@ class ProviderTransportTests(unittest.TestCase):
)
class ProviderManagerEnabledTests(unittest.TestCase):
def test_disabled_provider_is_not_registered_at_runtime(self) -> None:
logger = FakeLogger()
auth_group_manager = FakeAuthGroupManager()
ctx = AppContext(
logger=logger,
config_manager=None, # type: ignore[arg-type]
root_path=Path(__file__).resolve().parents[1],
flask_app=Flask(__name__),
)
manager = ProviderManager(ctx, auth_group_manager)
manager.load_providers(
(
ProviderConfigSchema.from_mapping(
{
"name": "disabled-provider",
"enabled": False,
"api": "https://example.com/v1/chat/completions",
"api_key": "disabled-key",
"model_list": ["gpt-4.1"],
}
),
ProviderConfigSchema.from_mapping(
{
"name": "enabled-provider",
"api": "https://example.com/v1/chat/completions",
"api_key": "enabled-key",
"model_list": ["gpt-4.1-mini"],
}
),
)
)
self.assertEqual(("enabled-provider/gpt-4.1-mini",), manager.list_model_names())
self.assertIsNone(manager.get_provider_view("disabled-provider"))
self.assertIsNotNone(manager.get_provider_view("enabled-provider"))
self.assertEqual([("enabled-provider", "enabled-key")], auth_group_manager.registered)
self.assertTrue(
any("disabled-provider" in message and "skipped runtime registration" in message for _, message in logger.records)
)
class WebSocketProxyBridgeTests(unittest.TestCase):
def test_normalize_websocket_message_preserves_raw_payload(self) -> None:
chunk = normalize_websocket_message(b'{"id":"evt_1"}')
@@ -316,6 +406,32 @@ class ProviderTemplateTransportTests(unittest.TestCase):
self.assertIn("setupCustomSelect('providerSourceFormat');", html)
self.assertIn("setupCustomSelect('providerTargetFormat');", html)
self.assertIn("setupCustomSelect('providerVerifySsl');", html)
self.assertIn("id=\"selectedProviderCount\"", html)
self.assertIn("id=\"enableSelectedProvidersBtn\"", html)
self.assertIn("id=\"disableSelectedProvidersBtn\"", html)
self.assertIn("id=\"deleteSelectedProvidersBtn\"", html)
self.assertIn("id=\"clearSelectedProvidersBtn\"", html)
self.assertIn("function updateProviderSelectionUi()", html)
self.assertIn("function toggleProviderSelection(name, checked)", html)
self.assertIn("function toggleAllProvidersSelection(checked)", html)
self.assertIn("function clearSelectedProviders()", html)
self.assertIn("function setProviderEnabled(name, enabled)", html)
self.assertIn("function runProviderBatchAction(action)", html)
self.assertIn("function openProviderBatchDeleteModal(names)", html)
self.assertIn("function confirmProviderBatchDelete()", html)
self.assertIn("function syncProviderBatchDeleteModal()", html)
self.assertIn("/api/providers/batch", html)
self.assertIn("/api/providers/${encodeURIComponent(normalizedName)}/${enabled ? 'enable' : 'disable'}", html)
self.assertIn('id="providerSelectAllCheckbox"', html)
self.assertIn('id="providerBatchDeleteModal"', html)
self.assertIn('id="providerBatchDeleteMessage"', html)
self.assertIn('id="confirmProviderBatchDeleteBtn"', html)
self.assertIn('class="modal-dialog modal-dialog-centered provider-batch-delete-modal-dialog"', html)
self.assertIn('data-provider-row-checkbox="${encodedProviderName}"', html)
self.assertIn("class=\"btn-action ${isEnabled ? 'btn-delete' : 'btn-edit'}\"", html)
self.assertIn("providerBatchDeleteModalInstance = new bootstrap.Modal(providerBatchDeleteModalElement);", html)
self.assertNotIn('id="providerBatchDeleteModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false"', html)
self.assertNotIn("window.confirm(`确认批量删除已选中的 ${selectedNames.length} 个 Provider?`)", html)
self.assertNotIn("setupCustomSelect('providerFormat');", html)
self.assertNotIn("setupCustomSelect('providerStreamFormat');", html)
self.assertNotIn('id="providerFormatMatrix"', html)
@@ -394,6 +510,9 @@ class ProviderTemplateTransportTests(unittest.TestCase):
self.assertIn("showActionError('拉取模型'", html)
self.assertIn(".providers-page .provider-help-popover {", css)
self.assertIn(".providers-page .provider-batch-summary {", css)
self.assertIn(".providers-page .provider-batch-delete-modal-dialog {", css)
self.assertIn(".providers-page .provider-table-checkbox {", css)
self.assertIn(".providers-page .field-label-with-help {", css)
self.assertIn(".providers-page .entry-import-editor {", css)
self.assertIn(".providers-page .entry-import-editor .ace_gutter-cell,", css)