From d03b5466da7bccda733a5cfc375cf198d3cddf8e Mon Sep 17 00:00:00 2001 From: Von Date: Thu, 2 Apr 2026 23:19:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=AF=B9Provider=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=90=AF=E7=94=A8=E7=A6=81=E7=94=A8=E7=9A=84=E8=83=BD?= =?UTF-8?q?=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- config.sample.yaml | 1 + config.yaml | 1 + src/config/provider_config.py | 13 + src/config/provider_manager.py | 4 + src/presentation/provider_controller.py | 53 ++++ src/presentation/static/css/providers.css | 33 ++- src/presentation/templates/providers.html | 313 +++++++++++++++++++++- src/services/provider_service.py | 93 ++++++- tests/test_provider_service.py | 169 ++++++++++++ tests/test_proxy_transport.py | 119 ++++++++ 11 files changed, 792 insertions(+), 13 deletions(-) create mode 100644 tests/test_provider_service.py diff --git a/README.md b/README.md index adee25f..d575721 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/config.sample.yaml b/config.sample.yaml index 521f37d..57cf39d 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -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 diff --git a/config.yaml b/config.yaml index e66b6dd..2218eb6 100644 --- a/config.yaml +++ b/config.yaml @@ -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 diff --git a/src/config/provider_config.py b/src/config/provider_config.py index e41efe0..1a236fc 100644 --- a/src/config/provider_config.py +++ b/src/config/provider_config.py @@ -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, diff --git a/src/config/provider_manager.py b/src/config/provider_manager.py index e689bdb..c50f037 100644 --- a/src/config/provider_manager.py +++ b/src/config/provider_manager.py @@ -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 diff --git a/src/presentation/provider_controller.py b/src/presentation/provider_controller.py index a3bda00..fba706b 100644 --- a/src/presentation/provider_controller.py +++ b/src/presentation/provider_controller.py @@ -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/', methods=['GET'])(auth(self.get_provider)) self._app.route('/api/providers/', methods=['PUT'])(auth(self.update_provider)) self._app.route('/api/providers/', methods=['DELETE'])(auth(self.delete_provider)) + self._app.route('/api/providers//disable', methods=['POST'])(auth(self.disable_provider)) + self._app.route('/api/providers//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 ""}') + + 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()) diff --git a/src/presentation/static/css/providers.css b/src/presentation/static/css/providers.css index 202ef46..2e700d7 100644 --- a/src/presentation/static/css/providers.css +++ b/src/presentation/static/css/providers.css @@ -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)); } diff --git a/src/presentation/templates/providers.html b/src/presentation/templates/providers.html index 15406be..a86febe 100644 --- a/src/presentation/templates/providers.html +++ b/src/presentation/templates/providers.html @@ -1,7 +1,7 @@ {% extends "base_admin.html" %} {% block page_title %}Provider 管理{% endblock %} -{% block page_css %}{% endblock %} +{% block page_css %}{% endblock %} {% block body_class %}providers-page{% endblock %} {% block header_title %}Provider 管理{% endblock %} {% block header_subtitle %}维护 providers 配置,修改后自动重载生效。{% endblock %} @@ -47,6 +47,13 @@ +
+ 已选 0 个 + + + + +
正在加载 provider 配置...
@@ -437,6 +444,24 @@ +