"""AI 模型配置:每个组织可在页面配置 OpenAI 或本地 OpenAI 兼容服务(Ollama / LM Studio / vLLM 等)。 数据库配置优先;未配置时回退到环境变量(AI_ENABLED / OPENAI_*),保持旧行为兼容。 API Key 只存后端,读取接口只做脱敏回显,绝不把密钥下发到浏览器。 """ import httpx from django.conf import settings from django.http import JsonResponse from .models import AIConfig from .views import APIError, audit, body, endpoint PROVIDERS = ("openai", "local") OPENAI_BASE_URL = "https://api.openai.com/v1" def require_full_admin(request): if request.member.departments: raise APIError("没有配置 AI 模型的权限", 403) def resolve(org): """返回生效的 AI 配置字典;未启用或配置不完整时返回 None。""" cfg = AIConfig.objects.filter(organization=org).first() if cfg: base_url = (cfg.base_url or "").rstrip("/") or (OPENAI_BASE_URL if cfg.provider == "openai" else "") key_required = cfg.provider == "openai" if not cfg.enabled or not base_url or not cfg.model or (key_required and not cfg.api_key): return None return { "enabled": True, "provider": cfg.provider, "base_url": base_url, "api_key": cfg.api_key, "model": cfg.model, } if settings.AI_ENABLED and settings.OPENAI_API_KEY and settings.OPENAI_MODEL: return { "enabled": True, "provider": "openai", "base_url": (settings.OPENAI_BASE_URL or OPENAI_BASE_URL).rstrip("/"), "api_key": settings.OPENAI_API_KEY, "model": settings.OPENAI_MODEL, } return None def org_models_endpoint(org): """组织配置或环境回退的 base_url/api_key,用于探测模型列表。""" cfg = AIConfig.objects.filter(organization=org).first() if cfg: base_url = (cfg.base_url or "").rstrip("/") or (OPENAI_BASE_URL if cfg.provider == "openai" else "") return base_url, cfg.api_key return (settings.OPENAI_BASE_URL or OPENAI_BASE_URL).rstrip("/"), settings.OPENAI_API_KEY @endpoint(("GET", "PUT"), roles=("admin",)) def ai_config(request): require_full_admin(request) org = request.member.organization if request.method == "GET": cfg = AIConfig.objects.filter(organization=org).first() return JsonResponse( { "enabled": cfg.enabled if cfg else False, "provider": cfg.provider if cfg else "local", "base_url": cfg.base_url if cfg else "", "model": cfg.model if cfg else "", "api_key_set": bool(cfg and cfg.api_key), "updated_at": cfg.updated_at.isoformat() if cfg else None, } ) data = body(request) enabled = data.get("enabled") provider = data.get("provider") base_url = data.get("base_url", "") api_key = data.get("api_key", "") model = data.get("model", "") if type(enabled) is not bool or provider not in PROVIDERS: raise APIError("AI 配置参数无效") if not isinstance(base_url, str) or len(base_url) > 500: raise APIError("Base URL 无效") base_url = base_url.rstrip("/") if base_url and not base_url.startswith(("http://", "https://")): raise APIError("Base URL 必须以 http:// 或 https:// 开头") if not isinstance(api_key, str) or len(api_key) > 500: raise APIError("API Key 无效") if not isinstance(model, str) or len(model) > 200: raise APIError("模型名无效") cfg = AIConfig.objects.filter(organization=org).first() if enabled: if provider == "local" and not base_url: raise APIError("本地服务需要填写 Base URL,例如 http://127.0.0.1:11434/v1") if provider == "openai" and not (api_key or (cfg and cfg.api_key)): raise APIError("OpenAI 服务需要 API Key") if not model: raise APIError("启用前需要填写模型名,可点击“获取可用模型”选择") key_changed = bool(api_key) defaults = { "enabled": enabled, "provider": provider, "base_url": base_url, "model": model, } if api_key: defaults["api_key"] = api_key if not cfg: AIConfig.objects.create(organization=org, **defaults) else: for field, value in defaults.items(): setattr(cfg, field, value) cfg.save() audit( request, "ai_config.update", details={ "provider": provider, "model": model, "enabled": enabled, "key_changed": key_changed, }, ) return JsonResponse({"ok": True}) @endpoint(roles=("admin",)) def ai_models(request): require_full_admin(request) base_url, api_key = org_models_endpoint(request.member.organization) if not base_url: raise APIError("请先保存 Base URL 再获取模型列表", 400) headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} try: with httpx.Client(timeout=10) as client: response = client.get(f"{base_url}/models", headers=headers) response.raise_for_status() data = response.json() items = data.get("data", []) ids = [m.get("id") for m in items if isinstance(m, dict) and isinstance(m.get("id"), str)] if not ids and isinstance(data.get("models"), list): ids = [ m.get("name") for m in data["models"] if isinstance(m, dict) and isinstance(m.get("name"), str) ] return JsonResponse({"items": ids[:200]}) except (httpx.HTTPError, ValueError, AttributeError) as exc: raise APIError("无法从该服务获取模型列表,请检查 Base URL 与 API Key", 502) from exc