259 lines
10 KiB
Python
259 lines
10 KiB
Python
from __future__ import annotations
|
|
|
|
from .shared import *
|
|
|
|
class LLMEngineAdminMixin:
|
|
async def list_engines(self, db: AsyncSession, tenant_id: int = 1) -> dict[str, Any]:
|
|
_, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
|
engines = bundle.get("engines", [])
|
|
return {
|
|
"active_engine_id": bundle.get("active_engine_id"),
|
|
"engines": [self._sanitize_engine_for_response(item) for item in engines],
|
|
"settings": self._normalize_engine_settings(bundle.get("settings")),
|
|
}
|
|
|
|
async def update_engine_settings(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
tenant_id: int,
|
|
enable_local_kb_qa: Optional[bool] = None,
|
|
) -> dict[str, Any]:
|
|
integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
|
settings = self._normalize_engine_settings(bundle.get("settings"))
|
|
if enable_local_kb_qa is not None:
|
|
settings["enable_local_kb_qa"] = bool(enable_local_kb_qa)
|
|
bundle["settings"] = settings
|
|
integration.config = json.loads(json.dumps(bundle))
|
|
await db.commit()
|
|
return settings
|
|
|
|
async def create_engine(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
tenant_id: int,
|
|
name: str,
|
|
provider: str,
|
|
api_key: Optional[str],
|
|
model: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
set_active: bool = True,
|
|
) -> dict[str, Any]:
|
|
provider = _normalize_provider(provider)
|
|
api_key = str(api_key or "").strip()
|
|
if provider != "ollama" and not api_key:
|
|
raise ValueError("api_key 不能为空")
|
|
|
|
integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
|
now = now_cn_aware().isoformat()
|
|
engine_id = str(uuid4())
|
|
defaults = PROVIDER_DEFAULTS[provider]
|
|
engine = {
|
|
"id": engine_id,
|
|
"name": (name or defaults["name"]).strip()[:80] or defaults["name"],
|
|
"provider": provider,
|
|
"api_key": api_key,
|
|
"model": (model or defaults["model"]).strip() or defaults["model"],
|
|
"base_url": (base_url or defaults["base_url"]).strip().rstrip("/") or defaults["base_url"],
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
engines = bundle.get("engines", [])
|
|
engines.append(engine)
|
|
bundle["engines"] = engines
|
|
if set_active or not bundle.get("active_engine_id"):
|
|
bundle["active_engine_id"] = engine_id
|
|
integration.config = json.loads(json.dumps(bundle))
|
|
integration.is_active = True
|
|
await db.commit()
|
|
return self._sanitize_engine_for_response(engine)
|
|
|
|
async def update_engine(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
tenant_id: int,
|
|
engine_id: str,
|
|
name: Optional[str] = None,
|
|
provider: Optional[str] = None,
|
|
api_key: Optional[str] = None,
|
|
model: Optional[str] = None,
|
|
base_url: Optional[str] = None,
|
|
) -> dict[str, Any]:
|
|
integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
|
engines = bundle.get("engines", [])
|
|
target: Optional[dict[str, Any]] = None
|
|
for item in engines:
|
|
if str(item.get("id")) == str(engine_id):
|
|
target = item
|
|
break
|
|
if not target:
|
|
raise ValueError("engine 不存在")
|
|
|
|
if provider is not None:
|
|
target["provider"] = _normalize_provider(provider)
|
|
active_provider = _normalize_provider(target.get("provider"))
|
|
defaults = PROVIDER_DEFAULTS[active_provider]
|
|
|
|
if name is not None:
|
|
target["name"] = (name or "").strip()[:80] or target.get("name") or defaults["name"]
|
|
if api_key is not None:
|
|
api_key_val = str(api_key).strip()
|
|
if api_key_val:
|
|
target["api_key"] = api_key_val
|
|
if model is not None:
|
|
target["model"] = (model or "").strip() or defaults["model"]
|
|
if base_url is not None:
|
|
target["base_url"] = (base_url or "").strip().rstrip("/") or defaults["base_url"]
|
|
|
|
target["updated_at"] = now_cn_aware().isoformat()
|
|
integration.config = json.loads(json.dumps(bundle))
|
|
await db.commit()
|
|
return self._sanitize_engine_for_response(target)
|
|
|
|
async def delete_engine(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
tenant_id: int,
|
|
engine_id: str,
|
|
) -> dict[str, Any]:
|
|
integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
|
engines = bundle.get("engines", [])
|
|
kept = [item for item in engines if str(item.get("id")) != str(engine_id)]
|
|
if len(kept) == len(engines):
|
|
raise ValueError("engine 不存在")
|
|
bundle["engines"] = kept
|
|
active_engine_id = str(bundle.get("active_engine_id") or "")
|
|
if active_engine_id == str(engine_id):
|
|
bundle["active_engine_id"] = kept[0]["id"] if kept else None
|
|
integration.config = json.loads(json.dumps(bundle))
|
|
await db.commit()
|
|
return {
|
|
"deleted_engine_id": str(engine_id),
|
|
"active_engine_id": bundle.get("active_engine_id"),
|
|
"remaining_count": len(kept),
|
|
}
|
|
|
|
async def activate_engine(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
tenant_id: int,
|
|
engine_id: str,
|
|
) -> dict[str, Any]:
|
|
integration, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id)
|
|
engines = bundle.get("engines", [])
|
|
exists = any(str(item.get("id")) == str(engine_id) for item in engines)
|
|
if not exists:
|
|
raise ValueError("engine 不存在")
|
|
bundle["active_engine_id"] = str(engine_id)
|
|
integration.config = json.loads(json.dumps(bundle))
|
|
await db.commit()
|
|
return {"active_engine_id": str(engine_id)}
|
|
|
|
async def test_engine_connection(
|
|
self,
|
|
db: AsyncSession,
|
|
*,
|
|
tenant_id: int,
|
|
engine_id: Optional[str] = None,
|
|
prompt: Optional[str] = None,
|
|
max_tokens: int = 64,
|
|
) -> dict[str, Any]:
|
|
test_prompt = (prompt or "你是连通性测试助手。请只返回: OK").strip()
|
|
if not test_prompt:
|
|
raise ValueError("测试 prompt 不能为空")
|
|
|
|
engine = await self._resolve_engine_for_test(
|
|
db,
|
|
tenant_id=tenant_id,
|
|
engine_id=engine_id,
|
|
)
|
|
response_text = ""
|
|
status = "success"
|
|
started = time.perf_counter()
|
|
try:
|
|
transient_retry_budget = 2
|
|
transient_retry_attempt = 0
|
|
while True:
|
|
try:
|
|
response_text = await self._chat_completion(
|
|
provider=str(engine.get("provider") or ""),
|
|
prompt=test_prompt,
|
|
system_prompt="",
|
|
api_key=str(engine.get("api_key") or ""),
|
|
base_url=str(engine.get("base_url") or ""),
|
|
model=str(engine.get("model") or ""),
|
|
temperature=0.0,
|
|
max_tokens=max_tokens,
|
|
)
|
|
break
|
|
except Exception as transient_exc:
|
|
status_code = self._http_status_from_exception(transient_exc)
|
|
can_retry_transient = (
|
|
transient_retry_budget > 0
|
|
and (
|
|
self._is_retryable_http_status(status_code)
|
|
or isinstance(transient_exc, httpx.RequestError)
|
|
)
|
|
)
|
|
if not can_retry_transient:
|
|
raise
|
|
retry_after_header = None
|
|
if isinstance(transient_exc, httpx.HTTPStatusError) and transient_exc.response is not None:
|
|
retry_after_header = transient_exc.response.headers.get("Retry-After")
|
|
retry_delay = self._http_retry_delay_seconds(
|
|
status_code=status_code,
|
|
attempt=transient_retry_attempt,
|
|
retry_after_header=retry_after_header,
|
|
)
|
|
_runtime_log(
|
|
"LLM",
|
|
(
|
|
"连通测试重试:"
|
|
f"engine={engine.get('provider')}/{engine.get('model')}, "
|
|
f"status={status_code or 'request_error'}, "
|
|
f"retry_in={retry_delay:.2f}s, left={transient_retry_budget - 1}"
|
|
),
|
|
)
|
|
transient_retry_budget -= 1
|
|
transient_retry_attempt += 1
|
|
await asyncio.sleep(retry_delay)
|
|
if not response_text.strip():
|
|
raise ValueError("测试响应为空")
|
|
latency_ms = int((time.perf_counter() - started) * 1000)
|
|
return {
|
|
"ok": True,
|
|
"engine_id": str(engine.get("id") or ""),
|
|
"name": str(engine.get("name") or ""),
|
|
"provider": str(engine.get("provider") or ""),
|
|
"model": str(engine.get("model") or ""),
|
|
"base_url": str(engine.get("base_url") or ""),
|
|
"api_key_masked": _mask_secret(str(engine.get("api_key") or "")),
|
|
"latency_ms": latency_ms,
|
|
"response_preview": response_text[:300],
|
|
}
|
|
except Exception as exc:
|
|
status = "failed"
|
|
response_text = f"[error] {exc}"
|
|
status_code = self._http_status_from_exception(exc)
|
|
if int(status_code or 0) == 429:
|
|
raise ValueError("引擎连通测试失败: 上游限流(429),请稍后重试或更换 API Key。") from exc
|
|
raise ValueError(f"引擎连通测试失败: {exc}") from exc
|
|
finally:
|
|
try:
|
|
await self._log_ai_call(
|
|
tenant_id=tenant_id,
|
|
function_name="test_engine_connection",
|
|
prompt=test_prompt,
|
|
response=response_text,
|
|
status=status,
|
|
provider=str(engine.get("provider") or ""),
|
|
model=str(engine.get("model") or ""),
|
|
)
|
|
except Exception as log_exc:
|
|
_runtime_log("LLM", f"测试调用日志写入失败:{log_exc}")
|
|
|