Files
MoviePilot/scripts/evaluation/models.py

155 lines
7.8 KiB
Python

"""读取明确配置的模型连接,并为真实评测统计所有模型调用。"""
import os
import threading
import time
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
from langchain_core.callbacks.base import BaseCallbackHandler
@dataclass(frozen=True)
class ModelSettings:
"""评测连接只在控制进程使用,凭据不进入 repr 或公开报告。"""
model: str
base_url: str = field(repr=False)
api_key: str = field(repr=False)
reasoning_effort: str = "high"
max_model_calls: int = 12
max_output_tokens: int = 8192
timeout_seconds: int = 180
context_window: int = 128000
wire_api: str = "responses"
def __post_init__(self) -> None:
"""配置文件和 worker 输入共享校验,不能绕过限额或夹带 URL 凭据。"""
if not isinstance(self.base_url, str):
raise ValueError("模型 endpoint 无效")
endpoint = urlsplit(self.base_url)
if (endpoint.scheme not in {"http", "https"} or not endpoint.hostname or endpoint.username is not None
or endpoint.password is not None or endpoint.query or endpoint.fragment):
raise ValueError("模型 endpoint 无效")
if not isinstance(self.model, str) or not self.model.strip():
raise ValueError("需要明确模型名称")
if not isinstance(self.api_key, str) or not self.api_key.strip() or any(char in self.api_key for char in "\r\n"):
raise ValueError("所选 provider 没有可用的显式评测凭据")
if self.reasoning_effort not in {"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}:
raise ValueError("推理预算名称无效")
if self.wire_api not in {"responses", "chat_completions"}:
raise ValueError("模型 provider 协议无效")
for value, minimum, maximum in (
(self.max_model_calls, 1, 64), (self.max_output_tokens, 256, 32768),
(self.timeout_seconds, 30, 900), (self.context_window, 4096, 2000000),
):
if type(value) is not int or not minimum <= value <= maximum:
raise ValueError("评测预算超出允许范围")
def public_metadata(self) -> dict[str, Any]:
"""公开模型与评测预算,省略凭据、完整连接路径和用户配置文件。"""
return {
"requested_model": self.model, "provider_host": urlsplit(self.base_url).hostname,
"wire_api": self.wire_api,
"reasoning_effort": self.reasoning_effort, "max_model_calls": self.max_model_calls,
"max_output_tokens": self.max_output_tokens, "timeout_seconds": self.timeout_seconds,
"harness_context_window": self.context_window,
}
def load_codex_model_settings(
path: Path, *, model: str | None = None, reasoning_effort: str | None = None,
max_model_calls: int = 12, max_output_tokens: int = 8192, timeout_seconds: int = 180,
) -> ModelSettings:
"""只使用选中 provider 显式配置的 bearer/env 凭据,不挪用其他服务的登录令牌。"""
config = tomllib.loads(path.read_text(encoding="utf-8"))
provider = config.get("model_providers", {}).get(config.get("model_provider"), {})
wire_api = provider.get("wire_api")
if wire_api not in {"responses", "chat_completions"}:
raise ValueError("当前真实评测入口要求明确配置的 Responses 或 Chat Completions provider")
endpoint = provider.get("base_url")
selected_model = model or config.get("model")
if not isinstance(selected_model, str):
raise ValueError("需要明确模型名称")
key = provider.get("experimental_bearer_token")
if not key and isinstance(provider.get("env_key"), str):
key = os.environ.get(provider["env_key"])
effort = reasoning_effort or config.get("model_reasoning_effort", "high")
return ModelSettings(selected_model, endpoint, key, effort, max_model_calls, max_output_tokens,
timeout_seconds, wire_api=wire_api)
class ModelCallLimitError(RuntimeError):
"""在下一次真实请求之前拒绝超出评测预算的模型调用。"""
# strict mypy 的 follow_imports=skip 不展开第三方回调基类。
class ModelUsageTracker(BaseCallbackHandler): # type: ignore[misc]
"""合计主模型、筛选、摘要与子代理的请求,不把缺失 usage 当成零消耗。"""
raise_error = True
run_inline = True
def __init__(self, limit: int) -> None:
"""建立单次评测共享的并发安全计数器。"""
self.limit = limit
self._lock = threading.Lock()
self._runs: dict[str, dict[str, Any]] = {}
self.blocked_calls = 0
def on_chat_model_start(self, serialized: dict[str, Any], messages: list[Any], **kwargs: Any) -> None:
"""原子执行预算判断,既不记录提示词,也不允许并行调用越过上限。"""
run_id = str(kwargs["run_id"])
with self._lock:
if run_id in self._runs:
return
if len(self._runs) >= self.limit:
self.blocked_calls += 1
raise ModelCallLimitError("评测已达到模型调用上限")
self._runs[run_id] = {"started": time.monotonic(), "completed": False, "usage": None, "model": None}
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
"""只提取用量与模型标识,不把原始响应或凭据带入统计。"""
generation = next((item for group in getattr(response, "generations", []) for item in group), None)
message = getattr(generation, "message", None)
usage = getattr(message, "usage_metadata", None)
if not isinstance(usage, dict) or not all(
type(usage.get(key)) is int and usage[key] >= 0
for key in ("input_tokens", "output_tokens", "total_tokens")
):
usage = None
else:
usage = {key: usage[key] for key in ("input_tokens", "output_tokens", "total_tokens")}
metadata = getattr(message, "response_metadata", None) or {}
with self._lock:
record = self._runs.get(str(kwargs["run_id"]))
if record is not None and not record["completed"]:
record.update(completed=True, usage=usage, model=metadata.get("model_name") or metadata.get("model"))
def on_llm_error(self, error: BaseException, **kwargs: Any) -> None:
"""错误请求保留为用量未知,不保存可能包含私有响应的异常文本。"""
with self._lock:
record = self._runs.get(str(kwargs["run_id"]))
if record is not None:
record["error_type"] = type(error).__name__
status = getattr(error, "status_code", None)
record["http_status"] = status if isinstance(status, int) else None
def snapshot(self) -> dict[str, Any]:
"""返回可审计的已知消耗及覆盖度;部分失败时数字仅为已知下界。"""
with self._lock:
records = list(self._runs.values())
known = [record["usage"] for record in records if isinstance(record["usage"], dict)]
return {
"model_calls": len(records), "completed_model_calls": sum(record["completed"] for record in records),
"blocked_model_calls": self.blocked_calls, "usage_complete": len(known) == len(records),
"reported_models": sorted({record["model"] for record in records if isinstance(record["model"], str)}),
"errors": [{key: record.get(key) for key in ("error_type", "http_status")}
for record in records if record.get("error_type")],
"tokens": ({key: sum(int(usage.get(key, 0)) for usage in known)
for key in ("input_tokens", "output_tokens", "total_tokens")} if known else None),
}