Complete admin model management

This commit is contained in:
2026-07-07 12:58:26 +08:00
parent 08c301c136
commit 55b7c5b0ea
12 changed files with 859 additions and 40 deletions

View File

@@ -1,7 +1,9 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlencode
import httpx
from sqlalchemy import select
@@ -37,7 +39,7 @@ class ModelClientService:
answer = _mock_answer(rag_result)
else:
model_name = model.model_name
answer = _call_openai_compatible_model(model, rag_result)
answer = _call_configured_model(model, rag_result)
return ModelCompletion(
answer=answer,
@@ -47,6 +49,16 @@ class ModelClientService:
output_token=_rough_token_count(answer),
)
@staticmethod
def test_model(model: ModelConfig) -> dict[str, Any]:
test_prompt = "请回复 OK用于测试模型配置是否可用。"
rag_result = RagResult(question=test_prompt, knowledge_scopes=[], chunks=[], prompt=test_prompt)
try:
answer = _call_configured_model(model, rag_result, allow_no_hit=True)
except ExternalServiceError as exc:
return {"ok": False, "message": str(exc), "answer": ""}
return {"ok": True, "message": "模型配置可用", "answer": answer[:500]}
def _mock_answer(rag_result: RagResult) -> str:
if not rag_result.is_hit:
@@ -67,40 +79,116 @@ def _rough_token_count(text: str) -> int:
return max(1, len(text.strip()) // 2)
def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str:
if not rag_result.is_hit:
def _call_configured_model(model: ModelConfig, rag_result: RagResult, *, allow_no_hit: bool = False) -> str:
if not allow_no_hit and not rag_result.is_hit:
return NO_HIT_ANSWER
if not model.api_url or not model.api_key:
raise ExternalServiceError("模型 API URL 或 API Key 未配置", provider="model")
if not (model.api_url or model.base_url) or not model.api_key:
raise ExternalServiceError("模型 Base URL/API URL 或 API Key 未配置", provider="model")
api_type = model.api_type or "openai_compatible"
if api_type == "anthropic_messages":
return _call_anthropic_messages(model, rag_result)
if api_type == "gemini_generate_content":
return _call_gemini_generate_content(model, rag_result)
return _call_openai_compatible_model(model, rag_result)
def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str:
payload = {
"model": model.model_name,
"messages": [
{"role": "system", "content": "你是企业知识库问答助手,只能基于已提供的知识片段回答。"},
{"role": "user", "content": rag_result.prompt},
],
"temperature": float(model.temperature) if model.temperature is not None else 0.2,
"max_tokens": model.max_token or 1024,
"stream": False,
"max_tokens": model.max_token or 1024,
}
headers = {
"Authorization": f"Bearer {model.api_key}",
"Content-Type": "application/json",
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
_put_if_not_none(payload, "presence_penalty", _decimal_to_float(model.presence_penalty))
_put_if_not_none(payload, "frequency_penalty", _decimal_to_float(model.frequency_penalty))
if model.response_format == "json_object":
payload["response_format"] = {"type": "json_object"}
payload.update(_load_extra_params(model.extra_params))
headers = _auth_headers(model)
try:
response = httpx.post(
model.api_url,
_resolve_openai_endpoint(model),
json=payload,
headers=headers,
timeout=model.timeout_second,
)
response.raise_for_status()
return _extract_answer(response.json())
return _extract_openai_answer(response.json())
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc
def _extract_answer(data: dict[str, Any]) -> str:
def _call_anthropic_messages(model: ModelConfig, rag_result: RagResult) -> str:
payload = {
"model": model.model_name,
"max_tokens": model.max_token or 1024,
"system": "你是企业知识库问答助手,只能基于已提供的知识片段回答。",
"messages": [{"role": "user", "content": rag_result.prompt}],
"stream": False,
}
_put_if_not_none(payload, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(payload, "top_p", _decimal_to_float(model.top_p))
_put_if_not_none(payload, "top_k", model.top_k)
payload.update(_load_extra_params(model.extra_params))
headers = {
"x-api-key": model.api_key,
"anthropic-version": model.api_version or "2023-06-01",
"Content-Type": "application/json",
}
try:
response = httpx.post(
_resolve_anthropic_endpoint(model),
json=payload,
headers=headers,
timeout=model.timeout_second,
)
response.raise_for_status()
return _extract_anthropic_answer(response.json())
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc
def _call_gemini_generate_content(model: ModelConfig, rag_result: RagResult) -> str:
generation_config: dict[str, Any] = {}
_put_if_not_none(generation_config, "temperature", _decimal_to_float(model.temperature))
_put_if_not_none(generation_config, "topP", _decimal_to_float(model.top_p))
_put_if_not_none(generation_config, "topK", model.top_k)
_put_if_not_none(generation_config, "maxOutputTokens", model.max_token)
if model.response_format == "json_object":
generation_config["responseMimeType"] = "application/json"
payload = {
"systemInstruction": {
"parts": [{"text": "你是企业知识库问答助手,只能基于已提供的知识片段回答。"}]
},
"contents": [{"role": "user", "parts": [{"text": rag_result.prompt}]}],
}
if generation_config:
payload["generationConfig"] = generation_config
payload.update(_load_extra_params(model.extra_params))
try:
response = httpx.post(
_resolve_gemini_endpoint(model),
json=payload,
headers={"Content-Type": "application/json"},
timeout=model.timeout_second,
)
response.raise_for_status()
return _extract_gemini_answer(response.json())
except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc:
raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc
def _extract_openai_answer(data: dict[str, Any]) -> str:
choices = data.get("choices")
if not isinstance(choices, list) or not choices:
raise ValueError("模型响应缺少 choices")
@@ -117,3 +205,80 @@ def _extract_answer(data: dict[str, Any]) -> str:
return str(first_choice["text"])
raise ValueError("模型响应缺少回答内容")
def _extract_anthropic_answer(data: dict[str, Any]) -> str:
content = data.get("content")
if not isinstance(content, list):
raise ValueError("Anthropic 响应缺少 content")
texts = [item.get("text", "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
answer = "".join(texts).strip()
if not answer:
raise ValueError("Anthropic 响应缺少文本内容")
return answer
def _extract_gemini_answer(data: dict[str, Any]) -> str:
candidates = data.get("candidates")
if not isinstance(candidates, list) or not candidates:
raise ValueError("Gemini 响应缺少 candidates")
parts = candidates[0].get("content", {}).get("parts", [])
texts = [item.get("text", "") for item in parts if isinstance(item, dict)]
answer = "".join(texts).strip()
if not answer:
raise ValueError("Gemini 响应缺少文本内容")
return answer
def _resolve_openai_endpoint(model: ModelConfig) -> str:
if model.api_url:
return model.api_url
base_url = (model.base_url or "").rstrip("/")
return f"{base_url}/chat/completions"
def _resolve_anthropic_endpoint(model: ModelConfig) -> str:
if model.api_url:
return model.api_url
base_url = (model.base_url or "https://api.anthropic.com").rstrip("/")
return f"{base_url}/v1/messages"
def _resolve_gemini_endpoint(model: ModelConfig) -> str:
if model.api_url:
return model.api_url
base_url = (model.base_url or "https://generativelanguage.googleapis.com/v1beta").rstrip("/")
query = urlencode({"key": model.api_key})
return f"{base_url}/models/{model.model_name}:generateContent?{query}"
def _auth_headers(model: ModelConfig) -> dict[str, str]:
headers = {"Content-Type": "application/json"}
if model.auth_type == "api_key":
headers["x-api-key"] = model.api_key
else:
headers["Authorization"] = f"Bearer {model.api_key}"
if model.api_version:
headers["api-version"] = model.api_version
return headers
def _decimal_to_float(value: Any) -> float | None:
return float(value) if value is not None else None
def _put_if_not_none(target: dict[str, Any], key: str, value: Any) -> None:
if value is not None:
target[key] = value
def _load_extra_params(extra_params: str | None) -> dict[str, Any]:
if not extra_params:
return {}
try:
data = json.loads(extra_params)
except json.JSONDecodeError as exc:
raise ExternalServiceError(f"模型高级参数不是合法 JSON{exc}", provider="model") from exc
if not isinstance(data, dict):
raise ExternalServiceError("模型高级参数必须是 JSON 对象", provider="model")
return data