ソースを参照

feat: 添加平台 API Key 管理和 OpenAI 兼容私域模型网关

后端:
- 新增 PlatformApiKey 模型(SHA256 哈希存储 sk-aigc- 前缀密钥)
- 新增 API Key CRUD 服务和视图(/platform/api-keys)
- 新增 OpenAI 兼容网关(/api/v1/chat/completions、/api/v1/models)
- 新增 OpenAI Compatible Provider(支持 API URL + API Key 添加私域模型)
- 配置 nginx 代理 /api/v1/ 路径支持流式响应

前端:
- 新增 API Key 管理页面(创建/启禁/删除)
- 模型创建对话框支持 OpenAI Compatible 凭证字段直接显示
- Provider 侧边栏添加 API Key 管理入口
- 新增前端路由 /model/apikey

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
mengboxin137-blip 1 週間 前
コミット
9d6e2bdc77
27 ファイル変更1113 行追加9 行削除
  1. 4 0
      apps/maxkb/urls/model.py
  2. 9 0
      apps/maxkb/urls/web.py
  3. 2 0
      apps/models_provider/constants/model_provider_constants.py
  4. 0 0
      apps/models_provider/impl/openai_compatible_provider/__init__.py
  5. 0 0
      apps/models_provider/impl/openai_compatible_provider/credential/__init__.py
  6. 59 0
      apps/models_provider/impl/openai_compatible_provider/credential/llm.py
  7. 5 0
      apps/models_provider/impl/openai_compatible_provider/icon/openai_compatible_icon_svg
  8. 0 0
      apps/models_provider/impl/openai_compatible_provider/model/__init__.py
  9. 80 0
      apps/models_provider/impl/openai_compatible_provider/model/llm.py
  10. 78 0
      apps/models_provider/impl/openai_compatible_provider/openai_compatible_provider.py
  11. 36 0
      apps/models_provider/migrations/0002_platformapikey.py
  12. 1 0
      apps/models_provider/models/__init__.py
  13. 52 0
      apps/models_provider/models/platform_api_key.py
  14. 1 0
      apps/models_provider/services/__init__.py
  15. 49 0
      apps/models_provider/services/crypto_utils.py
  16. 100 0
      apps/models_provider/services/platform_api_key_service.py
  17. 3 0
      apps/models_provider/urls.py
  18. 3 1
      apps/models_provider/views/__init__.py
  19. 65 0
      apps/models_provider/views/api_key_view.py
  20. 209 0
      apps/models_provider/views/openai_gateway_view.py
  21. 12 0
      nginx.conf
  22. 63 0
      ui/src/api/model/apikey.ts
  23. 9 0
      ui/src/router/modules/model.ts
  24. 186 0
      ui/src/views/model/apikey/index.vue
  25. 41 6
      ui/src/views/model/component/CreateModelDialog.vue
  26. 40 1
      ui/src/views/model/component/Provider.vue
  27. 6 1
      ui/src/views/model/index.vue

+ 4 - 0
apps/maxkb/urls/model.py

@@ -18,6 +18,7 @@ Including another URLconf
 from django.urls import path, include
 
 from maxkb.const import CONFIG
+from models_provider.views.openai_gateway_view import OpenAIGatewayView
 
 admin_api_prefix = CONFIG.get_admin_path()[1:] + '/api/'
 admin_ui_prefix = CONFIG.get_admin_path()
@@ -25,4 +26,7 @@ chat_api_prefix = CONFIG.get_chat_path()[1:] + '/api/'
 chat_ui_prefix = CONFIG.get_chat_path()
 urlpatterns = [
     path(admin_api_prefix, include("local_model.urls")),
+    # OpenAI 兼容网关
+    path('api/v1/chat/completions', OpenAIGatewayView.as_view()),
+    path('api/v1/models', OpenAIGatewayView.as_view()),
 ]

+ 9 - 0
apps/maxkb/urls/web.py

@@ -28,6 +28,7 @@ from common.result import Result
 from maxkb import settings
 from maxkb.conf import PROJECT_DIR
 from maxkb.const import CONFIG
+from models_provider.views.openai_gateway_view import OpenAIGatewayView
 
 admin_api_prefix = CONFIG.get_admin_path()[1:] + '/api/'
 admin_ui_prefix = CONFIG.get_admin_path()
@@ -39,6 +40,11 @@ urlpatterns = [
     path(admin_api_prefix, include("users.urls")),
     path(admin_api_prefix, include("system_manage.urls")),
     path(admin_api_prefix, include("oss.urls")),
+    path(admin_api_prefix, include("models_provider.urls")),
+    path(admin_api_prefix, include("folders.urls")),
+    path(admin_api_prefix, include("application.urls")),
+    path(admin_api_prefix, include("knowledge.urls")),
+    path(admin_api_prefix, include("tools.urls")),
     path(builder_api_prefix, include("tools.urls")),
     path(builder_api_prefix, include("models_provider.urls")),
     path(builder_api_prefix, include("folders.urls")),
@@ -51,6 +57,9 @@ urlpatterns = [
     path(f'{admin_ui_prefix[1:]}/', include('oss.retrieval_urls')),
     path(f'{builder_ui_prefix[1:]}/', include('oss.retrieval_urls')),
     path(f'{chat_ui_prefix[1:]}/', include('oss.retrieval_urls')),
+    # OpenAI 兼容网关(根路径,外部客户端调用)
+    path('api/v1/chat/completions', OpenAIGatewayView.as_view()),
+    path('api/v1/models', OpenAIGatewayView.as_view()),
 ]
 init_doc(urlpatterns, chat_urlpatterns)
 

+ 2 - 0
apps/models_provider/constants/model_provider_constants.py

@@ -13,6 +13,7 @@ from models_provider.impl.kimi_model_provider.kimi_model_provider import KimiMod
 from models_provider.impl.local_model_provider.local_model_provider import LocalModelProvider
 from models_provider.impl.ollama_model_provider.ollama_model_provider import OllamaModelProvider
 from models_provider.impl.openai_model_provider.openai_model_provider import OpenAIModelProvider
+from models_provider.impl.openai_compatible_provider.openai_compatible_provider import OpenAICompatibleProvider
 from models_provider.impl.regolo_model_provider.regolo_model_provider import RegoloModelProvider
 from models_provider.impl.siliconCloud_model_provider.siliconCloud_model_provider import SiliconCloudModelProvider
 from models_provider.impl.tencent_cloud_model_provider.tencent_cloud_model_provider import TencentCloudModelProvider
@@ -48,3 +49,4 @@ class ModelProvideConstants(Enum):
     model_anthropic_provider = AnthropicModelProvider()
     model_siliconCloud_provider = SiliconCloudModelProvider()
     model_regolo_provider = RegoloModelProvider()
+    model_openai_compatible_provider = OpenAICompatibleProvider()

+ 0 - 0
apps/models_provider/impl/openai_compatible_provider/__init__.py


+ 0 - 0
apps/models_provider/impl/openai_compatible_provider/credential/__init__.py


+ 59 - 0
apps/models_provider/impl/openai_compatible_provider/credential/llm.py

@@ -0,0 +1,59 @@
+# coding=utf-8
+"""
+OpenAI 兼容私域模型凭证
+用于添加自定义 OpenAI 兼容 API 的私域模型
+"""
+from typing import Dict
+
+from django.utils.translation import gettext_lazy as _, gettext
+
+from common import forms
+from common.exception.app_exception import AppApiException
+from common.forms import BaseForm, TooltipLabel
+from models_provider.base_model_provider import BaseModelCredential, ValidCode
+
+
+class OpenAICompatibleLLMModelParams(BaseForm):
+    temperature = forms.SliderField(TooltipLabel(_('Temperature'),
+                                                 _('Higher values make the output more random, while lower makes it more focused')),
+                                    required=True, default_value=0.7,
+                                    _min=0.1,
+                                    _max=1.0,
+                                    _step=0.01,
+                                    precision=2)
+
+    max_tokens = forms.SliderField(
+        TooltipLabel(_('Max Tokens'),
+                     _('Maximum number of tokens to generate')),
+        required=True, default_value=2048,
+        _min=1,
+        _max=128000,
+        _step=1,
+        precision=0)
+
+
+class OpenAICompatibleLLMModelCredential(BaseForm, BaseModelCredential):
+    """
+    OpenAI 兼容 API 凭证
+    用户只需填写 API URL 和 API Key 即可添加私域模型
+    """
+
+    def is_valid(self, model_type: str, model_name, model_credential: Dict[str, object], model_params, provider,
+                 raise_exception=False):
+        for key in ['api_base', 'api_key']:
+            if key not in model_credential or not model_credential.get(key):
+                if raise_exception:
+                    raise AppApiException(ValidCode.valid_error.value,
+                                          gettext('{key} is required').format(key=key))
+                else:
+                    return False
+        return True
+
+    def encryption_dict(self, model_info: Dict[str, object]):
+        return {**model_info, 'api_key': super().encryption(model_info.get('api_key', ''))}
+
+    api_base = forms.TextInputField('API URL', required=True)
+    api_key = forms.PasswordInputField('API Key', required=True)
+
+    def get_model_params_setting_form(self, model_name):
+        return OpenAICompatibleLLMModelParams()

+ 5 - 0
apps/models_provider/impl/openai_compatible_provider/icon/openai_compatible_icon_svg

@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+  <path d="M12 2L2 7l10 5 10-5-10-5z"/>
+  <path d="M2 17l10 5 10-5"/>
+  <path d="M2 12l10 5 10-5"/>
+</svg>

+ 0 - 0
apps/models_provider/impl/openai_compatible_provider/model/__init__.py


+ 80 - 0
apps/models_provider/impl/openai_compatible_provider/model/llm.py

@@ -0,0 +1,80 @@
+# coding=utf-8
+"""
+OpenAI 兼容私域模型调用
+"""
+import json
+from typing import Iterator, Dict
+
+import requests
+from django.utils.translation import gettext as _
+
+from common.exception.app_exception import AppApiException
+from common.utils.logger import maxkb_logger
+from models_provider.base_model_provider import ModelInfo, ModelTypeConst
+
+
+class OpenAICompatibleChatModel:
+    """OpenAI 兼容模型调用"""
+
+    def __init__(self, model_info: ModelInfo, model_credential: Dict[str, object], **kwargs):
+        self.model_info = model_info
+        self.model_credential = model_credential
+        self.api_base = model_credential.get('api_base', '').rstrip('/')
+        self.api_key = model_credential.get('api_key', '')
+        self.model_name = model_credential.get('model_name', model_info.model_name)
+
+    def _build_headers(self):
+        return {
+            'Content-Type': 'application/json',
+            'Authorization': f'Bearer {self.api_key}',
+        }
+
+    def chat(self, messages, stream=False, **kwargs):
+        """聊天补全"""
+        url = f"{self.api_base}/v1/chat/completions"
+        body = {
+            'model': self.model_name,
+            'messages': messages,
+            'stream': stream,
+            **kwargs
+        }
+
+        try:
+            if stream:
+                return self._stream_chat(url, body)
+            else:
+                response = requests.post(url, json=body, headers=self._build_headers(), timeout=120)
+                response.raise_for_status()
+                return response.json()
+        except requests.exceptions.Timeout:
+            raise AppApiException(504, _('Request timeout'))
+        except requests.exceptions.ConnectionError:
+            raise AppApiException(502, _('Failed to connect to API server'))
+        except Exception as e:
+            maxkb_logger.error(f'OpenAI compatible API error: {e}')
+            raise AppApiException(500, str(e))
+
+    def _stream_chat(self, url, body):
+        """流式聊天"""
+        response = requests.post(url, json=body, headers=self._build_headers(), stream=True, timeout=120)
+        response.raise_for_status()
+
+        def generate():
+            try:
+                for line in response.iter_lines():
+                    if line:
+                        yield line.decode('utf-8') + '\n'
+            finally:
+                response.close()
+
+        return generate()
+
+    def verify_connection(self):
+        """验证连接"""
+        try:
+            url = f"{self.api_base}/v1/models"
+            response = requests.get(url, headers=self._build_headers(), timeout=10)
+            response.raise_for_status()
+            return True
+        except Exception as e:
+            raise AppApiException(500, _('Connection verification failed: {error}').format(error=str(e)))

+ 78 - 0
apps/models_provider/impl/openai_compatible_provider/openai_compatible_provider.py

@@ -0,0 +1,78 @@
+# coding=utf-8
+"""
+OpenAI 兼容私域模型提供商
+用于添加自定义 OpenAI 兼容 API 的私域模型
+"""
+import os
+
+from django.utils.translation import gettext as _
+
+from common.utils.common import get_file_content
+from maxkb.conf import PROJECT_DIR
+from models_provider.base_model_provider import (
+    IModelProvider, ModelProvideInfo, ModelInfo, ModelTypeConst, ModelInfoManage
+)
+from models_provider.impl.openai_compatible_provider.credential.llm import (
+    OpenAICompatibleLLMModelCredential
+)
+from models_provider.impl.openai_compatible_provider.model.llm import (
+    OpenAICompatibleChatModel
+)
+
+# 默认凭证实例
+openai_compatible_credential = OpenAICompatibleLLMModelCredential()
+
+# 预置模型列表(用户也可自定义)
+model_info_list = [
+    ModelInfo(
+        'gpt-4',
+        _('GPT-4 via custom API'),
+        ModelTypeConst.LLM,
+        openai_compatible_credential,
+        OpenAICompatibleChatModel
+    ),
+    ModelInfo(
+        'gpt-3.5-turbo',
+        _('GPT-3.5 Turbo via custom API'),
+        ModelTypeConst.LLM,
+        openai_compatible_credential,
+        OpenAICompatibleChatModel
+    ),
+    ModelInfo(
+        'deepseek-chat',
+        _('DeepSeek Chat via custom API'),
+        ModelTypeConst.LLM,
+        openai_compatible_credential,
+        OpenAICompatibleChatModel
+    ),
+]
+
+model_info_manage = (
+    ModelInfoManage.builder()
+    .append_model_info_list(model_info_list)
+    .append_default_model_info(model_info_list[0])
+    .build()
+)
+
+
+class OpenAICompatibleProvider(IModelProvider):
+    """OpenAI 兼容私域模型提供商"""
+
+    def get_model_info_manage(self):
+        return model_info_manage
+
+    def get_model_provide_info(self):
+        return ModelProvideInfo(
+            provider='model_openai_compatible_provider',
+            name=_('OpenAI Compatible'),
+            icon=get_file_content(
+                os.path.join(
+                    PROJECT_DIR, "apps", 'models_provider', 'impl',
+                    'openai_compatible_provider', 'icon', 'openai_compatible_icon_svg'
+                )
+            )
+        )
+
+    def get_base_model_list(self, model_credential):
+        """获取可用模型列表 - 直接返回预置列表,用户也可自定义输入"""
+        return [{'name': m.name, 'model_type': ModelTypeConst.LLM.name} for m in model_info_list]

+ 36 - 0
apps/models_provider/migrations/0002_platformapikey.py

@@ -0,0 +1,36 @@
+# Generated by Django 5.2.13 on 2026-05-18 01:28
+
+import django.db.models.deletion
+import uuid_utils.compat
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('models_provider', '0001_initial'),
+        ('users', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PlatformApiKey',
+            fields=[
+                ('create_time', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='创建时间')),
+                ('update_time', models.DateTimeField(auto_now=True, db_index=True, verbose_name='修改时间')),
+                ('id', models.UUIDField(default=uuid_utils.compat.uuid7, editable=False, primary_key=True, serialize=False, verbose_name='主键id')),
+                ('api_key_hash', models.CharField(db_index=True, max_length=64, verbose_name='API Key哈希值')),
+                ('api_key_prefix', models.CharField(max_length=20, verbose_name='API Key显示前缀')),
+                ('name', models.CharField(blank=True, max_length=100, null=True, verbose_name='备注名称')),
+                ('status', models.CharField(choices=[('active', '启用'), ('disabled', '禁用')], db_index=True, default='active', max_length=20, verbose_name='状态')),
+                ('last_used_at', models.DateTimeField(blank=True, null=True, verbose_name='最后使用时间')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.user', verbose_name='用户')),
+            ],
+            options={
+                'verbose_name': '平台API Key',
+                'verbose_name_plural': '平台API Key',
+                'db_table': 'platform_api_key',
+                'ordering': ['-create_time'],
+            },
+        ),
+    ]

+ 1 - 0
apps/models_provider/models/__init__.py

@@ -8,3 +8,4 @@
 """
 
 from .model_management import *
+from .platform_api_key import *

+ 52 - 0
apps/models_provider/models/platform_api_key.py

@@ -0,0 +1,52 @@
+# coding=utf-8
+"""
+平台API Key模型
+存储用户创建的平台API密钥信息
+用于调用OpenAI兼容网关的密钥管理
+"""
+import uuid_utils.compat as uuid
+
+from django.db import models
+
+from common.mixins.app_model_mixin import AppModelMixin
+from users.models import User
+
+
+class PlatformApiKeyStatus(models.TextChoices):
+    ACTIVE = "active", "启用"
+    DISABLED = "disabled", "禁用"
+
+
+class PlatformApiKey(AppModelMixin):
+    """
+    平台API Key
+
+    存储用户创建的平台API密钥信息
+    - API Key使用SHA256哈希存储,仅在创建时返回完整密钥
+    - 每个用户最多可创建5个有效API Key
+    - 支持启用/禁用状态管理
+    """
+    id = models.UUIDField(primary_key=True, max_length=128, default=uuid.uuid7, editable=False, verbose_name="主键id")
+
+    user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name="用户")
+
+    api_key_hash = models.CharField(max_length=64, verbose_name="API Key哈希值", db_index=True)
+
+    api_key_prefix = models.CharField(max_length=20, verbose_name="API Key显示前缀")
+
+    name = models.CharField(max_length=100, verbose_name="备注名称", null=True, blank=True)
+
+    status = models.CharField(max_length=20, verbose_name="状态",
+                              choices=PlatformApiKeyStatus.choices,
+                              default=PlatformApiKeyStatus.ACTIVE, db_index=True)
+
+    last_used_at = models.DateTimeField(verbose_name="最后使用时间", null=True, blank=True)
+
+    class Meta:
+        db_table = "platform_api_key"
+        verbose_name = "平台API Key"
+        verbose_name_plural = "平台API Key"
+        ordering = ["-create_time"]
+
+    def __str__(self):
+        return f"{self.api_key_prefix} ({self.name or '未命名'})"

+ 1 - 0
apps/models_provider/services/__init__.py

@@ -0,0 +1 @@
+# coding=utf-8

+ 49 - 0
apps/models_provider/services/crypto_utils.py

@@ -0,0 +1,49 @@
+# coding=utf-8
+"""
+加密工具模块
+用于平台API Key的SHA256哈希存储
+"""
+import hashlib
+import secrets
+
+# API Key前缀常量
+API_KEY_PREFIX = "sk-aigc-"
+# API Key总长度(包含前缀)
+API_KEY_TOTAL_LENGTH = 48
+
+
+def hash_api_key(api_key: str) -> str:
+    """
+    使用SHA256算法对API Key进行哈希
+    用于平台API Key的安全存储(单向哈希,不可逆)
+    """
+    if not api_key:
+        return ""
+    return hashlib.sha256(api_key.encode('utf-8')).hexdigest()
+
+
+def verify_api_key_hash(api_key: str, hashed: str) -> bool:
+    """
+    验证API Key是否与哈希值匹配
+    """
+    if not api_key or not hashed:
+        return False
+    return hash_api_key(api_key) == hashed
+
+
+def generate_platform_api_key() -> tuple:
+    """
+    生成平台API Key
+
+    生成以 "sk-aigc-" 为前缀、总长度48字符的安全随机密钥
+    Returns:
+        (full_key, display_prefix)
+    """
+    random_length = API_KEY_TOTAL_LENGTH - len(API_KEY_PREFIX)
+    random_part = secrets.token_hex(random_length // 2)
+    random_part = random_part[:random_length]
+
+    full_key = f"{API_KEY_PREFIX}{random_part}"
+    display_prefix = f"{API_KEY_PREFIX}{random_part[:4]}...{random_part[-4:]}"
+
+    return full_key, display_prefix

+ 100 - 0
apps/models_provider/services/platform_api_key_service.py

@@ -0,0 +1,100 @@
+# coding=utf-8
+"""
+平台API Key服务层
+提供平台API Key的CRUD业务逻辑处理
+"""
+from datetime import datetime
+from typing import Optional
+
+from django.utils import timezone
+
+from models_provider.models.platform_api_key import PlatformApiKey, PlatformApiKeyStatus
+from models_provider.services.crypto_utils import (
+    generate_platform_api_key,
+    hash_api_key,
+)
+
+# 每用户最大API Key数量限制
+MAX_API_KEYS_PER_USER = 5
+
+
+def create_api_key(user, name=None):
+    """
+    创建API Key
+    生成以 "sk-aigc-" 为前缀、总长度48字符的密钥
+    返回完整密钥(仅此一次)
+    """
+    active_count = PlatformApiKey.objects.filter(
+        user=user, status=PlatformApiKeyStatus.ACTIVE
+    ).count()
+    if active_count >= MAX_API_KEYS_PER_USER:
+        raise ValueError(f"已达到API Key数量上限(最多{MAX_API_KEYS_PER_USER}个有效密钥)")
+
+    full_key, display_prefix = generate_platform_api_key()
+    hashed_key = hash_api_key(full_key)
+
+    api_key_record = PlatformApiKey.objects.create(
+        user=user,
+        api_key_hash=hashed_key,
+        api_key_prefix=display_prefix,
+        name=name,
+        status=PlatformApiKeyStatus.ACTIVE,
+    )
+
+    return {
+        "id": str(api_key_record.id),
+        "api_key": full_key,
+        "api_key_prefix": display_prefix,
+        "name": api_key_record.name,
+        "status": api_key_record.status,
+        "create_time": api_key_record.create_time,
+    }
+
+
+def get_user_api_keys(user):
+    """获取用户的API Key列表(脱敏)"""
+    return PlatformApiKey.objects.filter(user=user).order_by("-create_time")
+
+
+def update_api_key_status(key_id, user, status):
+    """更新API Key状态(启用/禁用)"""
+    if status not in ("active", "disabled"):
+        raise ValueError("状态值无效,必须是 'active' 或 'disabled'")
+
+    api_key_record = PlatformApiKey.objects.filter(id=key_id, user=user).first()
+    if not api_key_record:
+        raise ValueError("API Key不存在或无权限访问")
+
+    api_key_record.status = status
+    api_key_record.save(update_fields=["status", "update_time"])
+    return api_key_record
+
+
+def delete_api_key(key_id, user):
+    """删除API Key"""
+    api_key_record = PlatformApiKey.objects.filter(id=key_id, user=user).first()
+    if not api_key_record:
+        raise ValueError("API Key不存在或无权限访问")
+    api_key_record.delete()
+    return True
+
+
+def verify_api_key(api_key_str):
+    """
+    验证API Key,返回 (user_id, key_id) 或 None
+    """
+    if not api_key_str:
+        return None
+
+    hashed_key = hash_api_key(api_key_str)
+    api_key_record = PlatformApiKey.objects.filter(api_key_hash=hashed_key).first()
+
+    if not api_key_record:
+        return None
+    if api_key_record.status != PlatformApiKeyStatus.ACTIVE:
+        return None
+
+    api_key_record.last_used_at = timezone.now()
+    api_key_record.save(update_fields=["last_used_at"])
+
+    return (str(api_key_record.user_id), str(api_key_record.id))

+ 3 - 0
apps/models_provider/urls.py

@@ -19,6 +19,9 @@ urlpatterns = [
     path('workspace/<str:workspace_id>/model/<str:model_id>/pause_download', views.ModelSetting.PauseDownload.as_view()),
     path('workspace/<str:workspace_id>/model/<str:model_id>/meta', views.ModelSetting.ModelMeta.as_view()),
     path('system/shared/workspace/<str:workspace_id>/model', views.WorkspaceSharedModelSetting.as_view()),
+    # 平台 API Key 管理(已包含在 builder/api/ 前缀下)
+    path('platform/api-keys', views.ApiKeyView.as_view()),
+    path('platform/api-keys/<uuid:key_id>', views.ApiKeyOperateView.as_view()),
 ]
 
 if os.environ.get('SERVER_NAME', 'web') == 'local_model':

+ 3 - 1
apps/models_provider/views/__init__.py

@@ -2,4 +2,6 @@
 
 from .model import *
 from .provide import *
-from .model_apply import *
+from .model_apply import *
+from .api_key_view import *
+from .openai_gateway_view import *

+ 65 - 0
apps/models_provider/views/api_key_view.py

@@ -0,0 +1,65 @@
+# coding=utf-8
+"""
+平台API Key管理视图
+提供API Key的CRUD操作
+"""
+from rest_framework.views import APIView
+from django.utils.translation import gettext_lazy as _
+
+from common.auth import TokenAuth
+from common.log.log import log
+from common.result import result
+from models_provider.services import platform_api_key_service
+
+
+class ApiKeyView(APIView):
+    """平台API Key管理"""
+    authentication_classes = [TokenAuth]
+
+    def get(self, request):
+        """获取用户的API Key列表"""
+        keys = platform_api_key_service.get_user_api_keys(request.user)
+        data = []
+        for k in keys:
+            data.append({
+                "id": str(k.id),
+                "api_key_prefix": k.api_key_prefix,
+                "name": k.name or "未命名",
+                "status": k.status,
+                "last_used_at": k.last_used_at,
+                "create_time": k.create_time,
+            })
+        return result.success(data)
+
+    @log(menu='api_key', operate='Create API Key',
+         get_operation_object=lambda r, k: {'name': r.data.get('name', '未命名')})
+    def post(self, request):
+        """创建API Key(完整密钥仅返回一次)"""
+        name = request.data.get('name')
+        try:
+            key_data = platform_api_key_service.create_api_key(request.user, name=name)
+            return result.success(key_data)
+        except ValueError as e:
+            return result.error(str(e))
+
+
+class ApiKeyOperateView(APIView):
+    """单个API Key操作"""
+    authentication_classes = [TokenAuth]
+
+    def put(self, request, key_id):
+        """更新API Key状态"""
+        status_val = request.data.get('status')
+        try:
+            platform_api_key_service.update_api_key_status(key_id, request.user, status_val)
+            return result.success(None)
+        except ValueError as e:
+            return result.error(str(e))
+
+    def delete(self, request, key_id):
+        """删除API Key"""
+        try:
+            platform_api_key_service.delete_api_key(key_id, request.user)
+            return result.success(None)
+        except ValueError as e:
+            return result.error(str(e))

+ 209 - 0
apps/models_provider/views/openai_gateway_view.py

@@ -0,0 +1,209 @@
+# coding=utf-8
+"""
+OpenAI 兼容网关视图
+提供 /api/v1/chat/completions 和 /api/v1/models 接口
+用于外部客户端通过平台API Key访问私域模型
+"""
+import json
+import time
+import uuid
+import requests
+from typing import Optional
+
+from django.http import StreamingHttpResponse
+from rest_framework.views import APIView
+from rest_framework.request import Request
+
+from common.result import result
+from models_provider.models.platform_api_key import PlatformApiKey, PlatformApiKeyStatus
+from models_provider.services.crypto_utils import hash_api_key
+from models_provider.models import Model
+
+
+def _verify_bearer_token(request):
+    """
+    验证 Bearer Token,返回 (user_id, api_key_id) 或 None
+    """
+    auth_header = request.META.get('HTTP_AUTHORIZATION', '')
+    if not auth_header.startswith('Bearer '):
+        return None
+
+    api_key = auth_header[7:]
+    hashed_key = hash_api_key(api_key)
+
+    api_key_record = PlatformApiKey.objects.filter(api_key_hash=hashed_key).first()
+    if not api_key_record or api_key_record.status != PlatformApiKeyStatus.ACTIVE:
+        return None
+
+    # 更新最后使用时间
+    from django.utils import timezone
+    api_key_record.last_used_at = timezone.now()
+    api_key_record.save(update_fields=["last_used_at"])
+
+    return (str(api_key_record.user_id), str(api_key_record.id))
+
+
+def _get_model_by_name(model_name, user_id):
+    """
+    根据模型名称查找模型
+    支持模糊匹配:优先精确匹配 model_name,再匹配 name
+    """
+    # 精确匹配 model_name
+    model = Model.objects.filter(model_name=model_name, status='SUCCESS').first()
+    if model:
+        return model
+    # 精确匹配 name
+    model = Model.objects.filter(name=model_name, status='SUCCESS').first()
+    if model:
+        return model
+    # 模糊匹配
+    model = Model.objects.filter(model_name__icontains=model_name, status='SUCCESS').first()
+    if model:
+        return model
+    return None
+
+
+def _get_model_credential(model):
+    """解密模型凭证"""
+    try:
+        credential = model.credential
+        if isinstance(credential, str):
+            credential = json.loads(credential)
+        return credential
+    except Exception:
+        return {}
+
+
+def _call_openai_compatible(base_url, api_key, model_name, request_body, stream=False):
+    """
+    调用 OpenAI 兼容接口
+    """
+    url = f"{base_url.rstrip('/')}/v1/chat/completions"
+    headers = {
+        'Content-Type': 'application/json',
+        'Authorization': f'Bearer {api_key}',
+    }
+    body = {**request_body, 'model': model_name}
+
+    if stream:
+        return requests.post(url, json=body, headers=headers, stream=True, timeout=120)
+    else:
+        response = requests.post(url, json=body, headers=headers, timeout=120)
+        return response.json()
+
+
+class OpenAIGatewayView(APIView):
+    """
+    OpenAI 兼容网关
+    POST /api/v1/chat/completions - 聊天补全
+    GET /api/v1/models - 模型列表
+    """
+
+    def post(self, request: Request):
+        """聊天补全接口"""
+        auth_result = _verify_bearer_token(request)
+        if not auth_result:
+            return self._openai_error(401, "Incorrect API key provided", "authentication_error")
+
+        user_id, api_key_id = auth_result
+        body = request.data
+        model_name = body.get('model')
+        stream = body.get('stream', False)
+
+        if not model_name:
+            return self._openai_error(400, "model is required", "invalid_request_error")
+
+        # 查找模型
+        model = _get_model_by_name(model_name, user_id)
+        if not model:
+            return self._openai_error(404, f"The model '{model_name}' does not exist", "model_not_found")
+
+        # 获取凭证(兼容不同提供商的字段名)
+        credential = _get_model_credential(model)
+        api_key = credential.get('api_key', '')
+        # 兼容 api_base_url (OpenAI) 和 api_base (Docker AI/Ollama)
+        base_url = credential.get('api_base_url', '') or credential.get('api_base', '')
+
+        if not api_key or not base_url:
+            return self._openai_error(500, "Model credential not configured", "server_error")
+
+        try:
+            if stream:
+                return self._stream_response(base_url, api_key, model_name, body)
+            else:
+                response_data = _call_openai_compatible(base_url, api_key, model_name, body, stream=False)
+                return result.success(response_data)
+        except requests.exceptions.Timeout:
+            return self._openai_error(504, "Gateway timeout", "server_error")
+        except requests.exceptions.ConnectionError:
+            return self._openai_error(502, "Failed to connect to upstream model", "server_error")
+        except Exception as e:
+            return self._openai_error(500, str(e), "server_error")
+
+    def get(self, request: Request):
+        """获取可用模型列表"""
+        auth_result = _verify_bearer_token(request)
+        if not auth_result:
+            return self._openai_error(401, "Incorrect API key provided", "authentication_error")
+
+        # 返回所有可用模型
+        models = Model.objects.filter(status='SUCCESS').values('model_name', 'name')
+        model_list = []
+        seen = set()
+        for m in models:
+            name = m['model_name'] or m['name']
+            if name and name not in seen:
+                seen.add(name)
+                model_list.append({
+                    "id": name,
+                    "object": "model",
+                    "owned_by": "zhagent",
+                })
+
+        return result.success({
+            "object": "list",
+            "data": model_list,
+        })
+
+    def _stream_response(self, base_url, api_key, model_name, body):
+        """流式响应"""
+        url = f"{base_url.rstrip('/')}/v1/chat/completions"
+        headers = {
+            'Content-Type': 'application/json',
+            'Authorization': f'Bearer {api_key}',
+        }
+        body = {**body, 'model': model_name, 'stream': True}
+
+        try:
+            upstream = requests.post(url, json=body, headers=headers, stream=True, timeout=120)
+            upstream.raise_for_status()
+
+            def generate():
+                try:
+                    for line in upstream.iter_lines():
+                        if line:
+                            yield line.decode('utf-8') + '\n'
+                finally:
+                    upstream.close()
+
+            response = StreamingHttpResponse(
+                generate(),
+                content_type='text/event-stream',
+                headers={
+                    'Cache-Control': 'no-cache',
+                    'Connection': 'keep-alive',
+                    'X-Accel-Buffering': 'no',
+                }
+            )
+            return response
+        except Exception as e:
+            return self._openai_error(502, f"Failed to stream from upstream: {e}", "server_error")
+
+    @staticmethod
+    def _openai_error(status_code, message, error_type):
+        """返回 OpenAI 格式的错误响应"""
+        from django.http import JsonResponse
+        return JsonResponse(
+            {"error": {"message": message, "type": error_type}},
+            status=status_code,
+        )

+ 12 - 0
nginx.conf

@@ -58,6 +58,18 @@ server {
         proxy_pass http://web:8080;
     }
 
+    # OpenAI 兼容网关
+    location /api/v1/ {
+        proxy_pass http://web:8080;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_buffering off;
+        proxy_cache off;
+        chunked_transfer_encoding off;
+    }
+
     # OSS files
     location ~ ^/(admin|builder|chat)/oss/ {
         proxy_pass http://web:8080;

+ 63 - 0
ui/src/api/model/apikey.ts

@@ -0,0 +1,63 @@
+import { Result } from '@/request/Result'
+import { get, post, del, put } from '@/request/index'
+import { type Ref } from 'vue'
+
+export interface ApiKey {
+  id: string
+  api_key_prefix: string
+  name: string
+  status: 'active' | 'disabled'
+  last_used_at: string | null
+  create_time: string
+}
+
+export interface CreateApiKeyResponse {
+  id: string
+  api_key: string
+  api_key_prefix: string
+  name: string
+  status: string
+  create_time: string
+}
+
+const api_key_prefix = '/platform/api-keys'
+
+/**
+ * 获取 API Key 列表
+ */
+const getApiKeyList: (loading?: Ref<boolean>) => Promise<Result<Array<ApiKey>>> = (loading) => {
+  return get(api_key_prefix, undefined, loading)
+}
+
+/**
+ * 创建 API Key(完整密钥仅返回一次)
+ */
+const createApiKey: (
+  data: { name?: string },
+  loading?: Ref<boolean>,
+) => Promise<Result<CreateApiKeyResponse>> = (data, loading) => {
+  return post(api_key_prefix, data, loading)
+}
+
+/**
+ * 更新 API Key 状态
+ */
+const updateApiKeyStatus: (
+  keyId: string,
+  data: { status: string },
+  loading?: Ref<boolean>,
+) => Promise<Result<null>> = (keyId, data, loading) => {
+  return put(`${api_key_prefix}/${keyId}`, data, loading)
+}
+
+/**
+ * 删除 API Key
+ */
+const deleteApiKey: (
+  keyId: string,
+  loading?: Ref<boolean>,
+) => Promise<Result<null>> = (keyId, loading) => {
+  return del(`${api_key_prefix}/${keyId}`, undefined, loading)
+}
+
+export { getApiKeyList, createApiKey, updateApiKeyStatus, deleteApiKey }

+ 9 - 0
ui/src/router/modules/model.ts

@@ -29,6 +29,15 @@ const ModelRouter = {
       },
       component: () => import('@/views/model/index.vue'),
     },
+    {
+      path: '/model/apikey',
+      name: 'model-apikey',
+      meta: {
+        title: 'API Key 管理',
+        activeMenu: '/model',
+      },
+      component: () => import('@/views/model/apikey/index.vue'),
+    },
   ],
 }
 

+ 186 - 0
ui/src/views/model/apikey/index.vue

@@ -0,0 +1,186 @@
+<template>
+  <LayoutContainer>
+    <ContentContainer header="API Key 管理" backTo="/model" v-loading="loading">
+      <template #search>
+        <div class="flex">
+          <el-button type="primary" @click="openCreateDialog">创建 API Key</el-button>
+        </div>
+      </template>
+
+      <div v-if="keyList.length > 0">
+        <el-table :data="keyList" style="width: 100%">
+          <el-table-column prop="api_key_prefix" label="API Key" min-width="200">
+            <template #default="{ row }">
+              <code class="api-key-code">{{ row.api_key_prefix }}</code>
+            </template>
+          </el-table-column>
+          <el-table-column prop="name" label="名称" min-width="150">
+            <template #default="{ row }">
+              {{ row.name || '未命名' }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="status" label="状态" width="100">
+            <template #default="{ row }">
+              <el-tag :type="row.status === 'active' ? 'success' : 'info'" size="small">
+                {{ row.status === 'active' ? '启用' : '禁用' }}
+              </el-tag>
+            </template>
+          </el-table-column>
+          <el-table-column prop="last_used_at" label="最后使用" width="180">
+            <template #default="{ row }">
+              {{ row.last_used_at || '从未使用' }}
+            </template>
+          </el-table-column>
+          <el-table-column prop="create_time" label="创建时间" width="180" />
+          <el-table-column label="操作" width="180" fixed="right">
+            <template #default="{ row }">
+              <el-button
+                size="small"
+                @click="toggleStatus(row)"
+              >
+                {{ row.status === 'active' ? '禁用' : '启用' }}
+              </el-button>
+              <el-popconfirm
+                title="确定要删除此 API Key 吗?"
+                @confirm="handleDelete(row.id)"
+              >
+                <template #reference>
+                  <el-button size="small" type="danger">删除</el-button>
+                </template>
+              </el-popconfirm>
+            </template>
+          </el-table-column>
+        </el-table>
+      </div>
+      <el-empty v-else description="暂无 API Key" />
+    </ContentContainer>
+
+    <!-- 创建对话框 -->
+    <el-dialog v-model="createDialogVisible" title="创建 API Key" width="480px">
+      <el-form :model="createForm" label-width="80px">
+        <el-form-item label="名称">
+          <el-input v-model="createForm.name" placeholder="输入 Key 名称(可选)" />
+        </el-form-item>
+      </el-form>
+
+      <!-- 创建成功后显示完整密钥 -->
+      <div v-if="createdKey" class="created-key-box">
+        <el-alert
+          title="API Key 已创建"
+          description="请立即复制保存,此密钥仅显示一次!"
+          type="warning"
+          show-icon
+          :closable="false"
+          class="mb-16"
+        />
+        <el-input v-model="createdKey" readonly>
+          <template #append>
+            <el-button @click="copyKey">复制</el-button>
+          </template>
+        </el-input>
+      </div>
+
+      <template #footer>
+        <el-button v-if="!createdKey" @click="createDialogVisible = false">取消</el-button>
+        <el-button v-if="!createdKey" type="primary" @click="handleCreate" :loading="creating">
+          创建
+        </el-button>
+        <el-button v-if="createdKey" type="primary" @click="createDialogVisible = false; createdKey = ''">
+          我已保存
+        </el-button>
+      </template>
+    </el-dialog>
+  </LayoutContainer>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted } from 'vue'
+import { ElMessage } from 'element-plus'
+import LayoutContainer from '@/components/layout-container/index.vue'
+import ContentContainer from '@/components/layout-container/ContentContainer.vue'
+import { getApiKeyList, createApiKey, updateApiKeyStatus, deleteApiKey } from '@/api/model/apikey'
+import type { ApiKey } from '@/api/model/apikey'
+
+const loading = ref(false)
+const creating = ref(false)
+const keyList = ref<Array<ApiKey>>([])
+const createDialogVisible = ref(false)
+const createdKey = ref('')
+const createForm = ref({ name: '' })
+
+const loadKeys = async () => {
+  loading.value = true
+  try {
+    const res = await getApiKeyList()
+    keyList.value = res.data || []
+  } finally {
+    loading.value = false
+  }
+}
+
+const openCreateDialog = () => {
+  createForm.value = { name: '' }
+  createdKey.value = ''
+  createDialogVisible.value = true
+}
+
+const handleCreate = async () => {
+  creating.value = true
+  try {
+    const res = await createApiKey(createForm.value)
+    createdKey.value = res.data.api_key
+    ElMessage.success('API Key 创建成功,请立即保存')
+    await loadKeys()
+  } catch (e: any) {
+    ElMessage.error(e?.message || '创建失败')
+  } finally {
+    creating.value = false
+  }
+}
+
+const toggleStatus = async (row: ApiKey) => {
+  const newStatus = row.status === 'active' ? 'disabled' : 'active'
+  try {
+    await updateApiKeyStatus(row.id, { status: newStatus })
+    ElMessage.success('状态已更新')
+    await loadKeys()
+  } catch (e: any) {
+    ElMessage.error(e?.message || '操作失败')
+  }
+}
+
+const handleDelete = async (id: string) => {
+  try {
+    await deleteApiKey(id)
+    ElMessage.success('已删除')
+    await loadKeys()
+  } catch (e: any) {
+    ElMessage.error(e?.message || '删除失败')
+  }
+}
+
+const copyKey = () => {
+  navigator.clipboard.writeText(createdKey.value)
+  ElMessage.success('已复制到剪贴板')
+}
+
+onMounted(() => {
+  loadKeys()
+})
+</script>
+
+<style scoped>
+.api-key-code {
+  font-family: monospace;
+  background: var(--el-fill-color-light);
+  padding: 2px 8px;
+  border-radius: 4px;
+  font-size: 13px;
+}
+.created-key-box {
+  margin-top: 16px;
+}
+.mb-16 {
+  margin-bottom: 16px;
+}
+</style>

+ 41 - 6
ui/src/views/model/component/CreateModelDialog.vue

@@ -132,6 +132,20 @@
             </el-form-item>
           </template>
         </DynamicsForm>
+        <!-- OpenAI Compatible: 直接显示凭证字段 -->
+        <div v-if="is_openai_compatible && base_form_data.model_name">
+          <el-divider content-position="left">API 凭证</el-divider>
+          <DynamicsForm
+            v-model="credential_form_data"
+            :render_data="credential_form_field"
+            :model="credential_form_data"
+            ref="credentialFormRef"
+            label-position="top"
+            require-asterisk-position="right"
+            class="mb-24"
+            label-width="auto"
+          />
+        </div>
       </el-tab-pane>
       <el-tab-pane :label="$t('views.model.modelForm.title.advancedInfo')" name="advanced-info">
         <el-empty
@@ -267,10 +281,16 @@ const model_type_list = ref<Array<KeyValue<string, string>>>([])
 
 const base_model_list = ref<Array<BaseModel>>()
 const model_form_field = ref<Array<FormField>>([])
+const credential_form_field = ref<Array<FormField>>([])
+const credentialFormRef = ref<InstanceType<typeof DynamicsForm>>()
 const dialogVisible = ref<boolean>(false)
 const activeName = ref('base-info')
 const AddParamRef = ref()
 
+const is_openai_compatible = computed(() => {
+  return providerValue.value?.provider === 'model_openai_compatible_provider'
+})
+
 const base_form_data_rule = ref<FormRules>({
   name: {
     required: true,
@@ -325,9 +345,14 @@ const getModelForm = (model_name: string) => {
       form_data.value.model_type,
       model_name,
     ).then((ok) => {
-      model_form_field.value = ok.data
-      // 渲染动态表单
-      dynamicsFormRef.value?.render(model_form_field.value, undefined)
+      // OpenAI Compatible: 凭证字段直接显示在基础信息标签页
+      if (is_openai_compatible.value) {
+        credential_form_field.value = ok.data
+        credentialFormRef.value?.render(credential_form_field.value, undefined)
+      } else {
+        model_form_field.value = ok.data
+        dynamicsFormRef.value?.render(model_form_field.value, undefined)
+      }
     })
 
     ProviderApi.listBaseModelParamsForm(
@@ -363,6 +388,11 @@ const list_base_model = (model_type: any, change?: boolean) => {
     ProviderApi.listBaseModel(providerValue.value.provider, model_type, base_model_loading).then(
       (ok) => {
         base_model_list.value = ok.data
+        // OpenAI Compatible: 加载模型后自动加载凭证字段
+        if (is_openai_compatible.value && ok.data && ok.data.length > 0) {
+          base_form_data.value.model_name = ok.data[0].name
+          getModelForm(ok.data[0].name)
+        }
       },
     )
   }
@@ -377,14 +407,19 @@ const close = () => {
   }
   credential_form_data.value = {}
   model_form_field.value = []
+  credential_form_field.value = []
   base_model_list.value = []
   loading.value = false
   dialogVisible.value = false
 }
 const submit = () => {
-  dynamicsFormRef.value
-    ?.validate()
-    .then(() => {
+  // OpenAI Compatible: 需要验证凭证表单
+  const validatePromise = is_openai_compatible.value
+    ? credentialFormRef.value?.validate()
+    : dynamicsFormRef.value?.validate()
+
+  validatePromise
+    ?.then(() => {
       if (providerValue.value) {
         loadSharedApi({ type: 'model', systemType: apiType.value })
           .createModel(

+ 40 - 1
ui/src/views/model/component/Provider.vue

@@ -99,12 +99,26 @@
             </common-list>
           </el-collapse-item>
         </el-collapse>
+
+        <!-- API Key 管理入口 -->
+        <div class="border-t mt-8 pt-8">
+          <div
+            class="flex cursor apikey-button"
+            :class="active?.provider === 'apikey' && 'active'"
+            @click="handleApiKeyClick"
+          >
+            <el-icon style="font-size: 18px" class="color-primary"><Key /></el-icon>
+            <span class="ml-8">API Key 管理</span>
+          </div>
+        </div>
       </div>
     </el-scrollbar>
   </div>
 </template>
 <script lang="ts" setup>
 import { watch, ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { Key } from '@element-plus/icons-vue'
 import type { Provider, Model } from '@/api/type/model'
 import { modelTypeList, allObj } from '@/views/model/component/data'
 import { EditionConst } from '@/utils/permission/data'
@@ -129,7 +143,8 @@ watch(
       'model_local_provider',
       'model_xinference_provider',
       'model_vllm_provider',
-      'model_docker_ai_provider'
+      'model_docker_ai_provider',
+      'model_openai_compatible_provider'
     ]
     list
       .filter((v) => v.provider)
@@ -153,6 +168,11 @@ const clickListHandle = (item: Provider) => {
 const handleSharedNodeClick = () => {
   emit('click', { provider: 'share', name: t('views.shared.shared_model') })
 }
+
+const router = useRouter()
+const handleApiKeyClick = () => {
+  router.push('/model/apikey')
+}
 </script>
 <style lang="scss" scoped>
 .provider-list {
@@ -230,5 +250,24 @@ const handleSharedNodeClick = () => {
       }
     }
   }
+  .apikey-button {
+    padding: 10px 8px;
+    font-weight: 400;
+    font-size: 14px;
+    margin-top: 4px;
+    &.active {
+      background: var(--el-color-primary-light-9);
+      border-radius: var(--app-border-radius-small);
+      color: var(--el-color-primary);
+      font-weight: 500;
+      &:hover {
+        background: var(--el-color-primary-light-9);
+      }
+    }
+    &:hover {
+      background: rgba(var(--el-text-color-primary-rgb), 0.1);
+      border-radius: var(--app-border-radius-small);
+    }
+  }
 }
 </style>

+ 6 - 1
ui/src/views/model/index.vue

@@ -125,10 +125,11 @@ import CreateModelDialog from '@/views/model/component/CreateModelDialog.vue'
 import SelectProviderDialog from '@/views/model/component/SelectProviderDialog.vue'
 import { loadSharedApi } from '@/utils/dynamics-api/shared-api'
 import useStore from '@/stores'
-import { useRoute } from 'vue-router'
+import { useRoute, useRouter } from 'vue-router'
 import permissionMap from '@/permission'
 
 const route = useRoute()
+const router = useRouter()
 const { model, user } = useStore()
 const apiType = computed(() => {
   if (route.path.includes('shared')) {
@@ -183,6 +184,10 @@ const createModelRef = ref<InstanceType<typeof CreateModelDialog>>()
 const selectProviderRef = ref<InstanceType<typeof SelectProviderDialog>>()
 
 const clickListHandle = (item: Provider) => {
+  if (item.provider === 'apikey') {
+    router.push('/model/apikey')
+    return
+  }
   active_provider.value = item
   list_model()
   if (active_provider.value.provider === '') {