migration: preserve mentor and llm streaming slice

This commit is contained in:
leefer
2026-07-31 04:18:53 +08:00
parent 4bab921d14
commit 2919229c73
26 changed files with 1705 additions and 1222 deletions
+2
View File
@@ -5,6 +5,7 @@ from .gateway import (
LLMStreamEvent,
ModelProfile,
)
from .stream import OpenAIStreamAccumulator
__all__ = [
"LLMGateway",
@@ -12,4 +13,5 @@ __all__ = [
"LLMResult",
"LLMStreamEvent",
"ModelProfile",
"OpenAIStreamAccumulator",
]
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import json
from http import HTTPStatus
class LLMHttpMixin:
def save_llm_settings(self) -> None:
try:
body = self.read_json_body()
service = self.application_service
service.save_llm_settings(
body.get("primary") or {},
body.get("fallback") or {},
bool(body.get("fallback_enabled")),
)
self.send_json(
{
"ok": True,
"configured": service.llm_configured,
"model": service.llm_primary_model,
"fallback_configured": service.llm_fallback_configured,
"fallback_model": service.llm_fallback_model,
}
)
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def save_llm_mode(self) -> None:
try:
body = self.read_json_body()
service = self.application_service
service.save_llm_mode(str(body.get("mode") or "auto"))
self.send_json({"ok": True, "llm_access": service.llm_access_status()})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
def test_llm_settings(self) -> None:
try:
body = self.read_json_body()
role = str(body.get("role") or "")
profile = body.get("profile") or {}
result = self.application_service.test_llm_profile(role, profile)
self.send_json({"ok": True, "result": result})
except (ValueError, json.JSONDecodeError) as exc:
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
from datetime import datetime, timezone
class LLMAuditRepositoryMixin:
def record_llm_usage(
self,
user_id: int,
feature: str,
source: str,
model: str,
status: str,
latency_ms: int = 0,
*,
role: str = "",
prompt_version: str = "",
error_code: str = "",
input_tokens: int = 0,
output_tokens: int = 0,
) -> None:
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
with self.connect() as connection:
connection.execute(
"""
INSERT INTO llm_usage
(user_id, feature, source, model, status, latency_ms, created_at,
role, prompt_version, error_code, input_tokens, output_tokens)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id, feature, source, model, status, int(latency_ms), now,
role, prompt_version, error_code, int(input_tokens), int(output_tokens),
),
)
def count_llm_usage_since(self, user_id: int, source: str, since: str) -> int:
with self.connect() as connection:
row = connection.execute(
"""
SELECT COUNT(*) AS total FROM llm_usage
WHERE user_id = ? AND source = ? AND created_at >= ?
""",
(user_id, source, since),
).fetchone()
return int(row["total"] if row else 0)
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
from backend.features.screener.compiler import LLMCompilerError, test_llm_connection
class LLMServiceMixin:
def _personal_llm_profile(self) -> dict[str, Any]:
credentials = self._credentials()
return {
"source": "personal",
"primary": {
"api_key": credentials["llm_primary_api_key"],
"base_url": credentials["llm_primary_base_url"],
"model": credentials["llm_primary_model"],
},
"fallback": {
"api_key": credentials["llm_fallback_api_key"],
"base_url": credentials["llm_fallback_base_url"],
"model": credentials["llm_fallback_model"],
},
}
def _platform_llm_profile(self) -> dict[str, Any]:
models = {
str(item.get("id") or ""): item
for item in self._system_credentials.get("llm_models") or []
if isinstance(item, dict) and item.get("id")
}
def selected(role: str) -> dict[str, str]:
item = models.get(str(self._system_credentials.get(f"{role}_model_id") or ""), {})
return {
"id": str(item.get("id") or ""),
"name": str(item.get("name") or ""),
"api_key": str(item.get("api_key") or ""),
"base_url": str(item.get("base_url") or ""),
"model": str(item.get("model") or ""),
}
return {
"source": "platform",
"primary": selected("primary"),
"fallback": selected("fallback"),
}
@staticmethod
def _profile_configured(profile: dict[str, str]) -> bool:
return bool(profile.get("api_key") and profile.get("base_url") and profile.get("model"))
def _resolved_llm_profile(self) -> dict[str, Any]:
platform = self._platform_llm_profile()
platform_ready = self.membership()["active"] and self._profile_configured(platform["primary"])
if platform_ready:
return platform
return {"source": "none", "primary": {}, "fallback": {}}
@property
def llm_primary_api_key(self) -> str:
return str(self._resolved_llm_profile()["primary"].get("api_key") or "")
@property
def llm_primary_base_url(self) -> str:
return str(self._resolved_llm_profile()["primary"].get("base_url") or "")
@property
def llm_primary_model(self) -> str:
return str(self._resolved_llm_profile()["primary"].get("model") or "")
@property
def llm_fallback_api_key(self) -> str:
return str(self._resolved_llm_profile()["fallback"].get("api_key") or "")
@property
def llm_fallback_base_url(self) -> str:
return str(self._resolved_llm_profile()["fallback"].get("base_url") or "")
@property
def llm_fallback_model(self) -> str:
return str(self._resolved_llm_profile()["fallback"].get("model") or "")
@property
def llm_source(self) -> str:
return str(self._resolved_llm_profile().get("source") or "none")
@property
def llm_configured(self) -> bool:
return bool(self.llm_primary_api_key and self.llm_primary_model)
@property
def llm_fallback_configured(self) -> bool:
return bool(
self.llm_fallback_api_key
and self.llm_fallback_base_url
and self.llm_fallback_model
)
def save_llm_settings(
self,
primary: dict[str, Any],
fallback: dict[str, Any],
fallback_enabled: bool,
) -> None:
personal = self._personal_llm_profile()
primary_profile = self._validate_llm_profile(
primary,
personal["primary"],
required=True,
label="主模型",
)
if fallback_enabled:
fallback_profile = self._validate_llm_profile(
fallback,
personal["fallback"],
required=True,
label="辅助模型",
)
else:
fallback_profile = {"api_key": "", "base_url": "", "model": ""}
credentials = self._credentials()
credentials.update(
{
"llm_primary_api_key": primary_profile["api_key"],
"llm_primary_base_url": primary_profile["base_url"],
"llm_primary_model": primary_profile["model"],
"llm_fallback_api_key": fallback_profile["api_key"],
"llm_fallback_base_url": fallback_profile["base_url"],
"llm_fallback_model": fallback_profile["model"],
}
)
self._save_credentials(credentials)
def save_llm_mode(self, mode: str) -> None:
raise ValueError("LLM 算力由管理员统一配置,会员账号自动使用平台模型。")
def test_llm_profile(self, role: str, payload: dict[str, Any]) -> dict[str, Any]:
personal = self._personal_llm_profile()
if role == "primary":
current = personal["primary"]
label = "主模型"
elif role == "fallback":
current = personal["fallback"]
label = "辅助模型"
else:
raise ValueError("模型角色不支持。")
profile = self._validate_llm_profile(payload, current, required=True, label=label)
try:
return self.llm_gateway.probe(
profile,
lambda model: test_llm_connection(
model.api_key, model.base_url, model.model
),
)
except LLMCompilerError as exc:
raise ValueError(str(exc)) from exc
@staticmethod
def _validate_llm_profile(
payload: dict[str, Any],
current: dict[str, str],
required: bool,
label: str,
) -> dict[str, str]:
api_key = str(payload.get("api_key") or current.get("api_key") or "").strip()
base_url = str(payload.get("base_url") or current.get("base_url") or "").strip().rstrip("/")
model = str(payload.get("model") or current.get("model") or "").strip()
if not required and not any((api_key, base_url, model)):
return {"api_key": "", "base_url": "", "model": ""}
parsed = urlparse(base_url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"{label} Base URL 格式不正确。")
if not api_key or len(api_key) > 300:
raise ValueError(f"{label} API Key 不能为空或过长。")
if not model or len(model) > 100:
raise ValueError(f"{label}模型名称不能为空或过长。")
return {"api_key": api_key, "base_url": base_url, "model": model}
def llm_access_status(self) -> dict[str, Any]:
platform = self._platform_llm_profile()
membership = self.membership()
limit = max(1, int(self._system_credentials.get("member_daily_limit") or 50))
used = self._platform_usage_today() if membership["active"] else 0
resolved = self._resolved_llm_profile()
return {
"mode": "platform" if membership["active"] else "locked",
"resolved_source": resolved.get("source") or "none",
"resolved_model": str(resolved.get("primary", {}).get("model") or ""),
"platform_configured": self._profile_configured(platform["primary"]),
"membership": membership,
"daily_limit": limit,
"used_today": used,
"remaining_calls": None if membership["is_admin"] else max(0, limit - used),
}
def _platform_usage_today(self) -> int:
return self._platform_usage_today_for_user(self.current_user_id)
def _platform_usage_today_for_user(self, user_id: int) -> int:
now = datetime.now().astimezone()
start = now.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
return self.database.count_llm_usage_since(
user_id,
"platform",
start.isoformat(timespec="seconds"),
)
def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]:
current = next(
(
item
for item in self._system_credentials.get("llm_models") or []
if str(item.get("id") or "") == model_id
),
{},
)
label = validate_text(payload.get("name") or current.get("name"), "模型名称", 50, required=True)
profile = self._validate_llm_profile(
payload, current, required=True, label=label
)
try:
return self.llm_gateway.probe(
profile,
lambda model: test_llm_connection(
model.api_key, model.base_url, model.model
),
)
except LLMCompilerError as exc:
raise ValueError(str(exc)) from exc
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from typing import Any
class OpenAIStreamAccumulator:
"""Normalize incremental deltas and provider-specific full-message snapshots."""
def __init__(self) -> None:
self.text = ""
self.saw_delta = False
def feed(self, choice: dict[str, Any]) -> str:
delta = choice.get("delta")
if isinstance(delta, dict) and delta.get("content") is not None:
chunk = str(delta.get("content") or "")
if chunk:
self.saw_delta = True
self.text += chunk
return chunk
message = choice.get("message")
if not isinstance(message, dict) or message.get("content") is None:
return ""
snapshot = str(message.get("content") or "")
if not snapshot:
return ""
if not self.text:
self.text = snapshot
return snapshot
if snapshot == self.text or self.text.startswith(snapshot):
return ""
if snapshot.startswith(self.text):
suffix = snapshot[len(self.text):]
self.text = snapshot
return suffix
if self.saw_delta:
# A final full snapshot cannot safely replace chunks already delivered.
return ""
return ""