新增对Provider支持启用禁用的能力
This commit is contained in:
1 parent
ea5ca42c2c
commit
d03b5466da
11 files changed
+792
-13
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user