1025 lines
43 KiB
Python
1025 lines
43 KiB
Python
import base64
|
||
import binascii
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import secrets
|
||
from pathlib import Path
|
||
from typing import Any, Literal
|
||
from urllib.parse import urljoin, urlparse
|
||
|
||
import boto3
|
||
import httpx
|
||
import anyio
|
||
import psycopg
|
||
import redis
|
||
from botocore.config import Config as BotoConfig
|
||
from cryptography.fernet import Fernet, InvalidToken
|
||
from pydantic import BaseModel, Field
|
||
|
||
from app.config import Settings, get_settings
|
||
from app.settings_schema import CATEGORIES, EDITABLE_FIELDS, FIELDS, SECRET_FIELDS, SettingCategory
|
||
|
||
|
||
class SettingsReadiness(BaseModel):
|
||
ready: bool
|
||
completed_required: int
|
||
total_required: int
|
||
missing: list[str] = Field(default_factory=list)
|
||
untested: list[str] = Field(default_factory=list)
|
||
|
||
|
||
class RuntimeSettingsResponse(BaseModel):
|
||
categories: list[SettingCategory]
|
||
values: dict[str, Any]
|
||
configured: dict[str, bool]
|
||
tests: dict[str, dict[str, Any]]
|
||
readiness: SettingsReadiness
|
||
|
||
|
||
class RuntimeSettingsUpdate(BaseModel):
|
||
values: dict[str, Any]
|
||
|
||
|
||
class RuntimeSettingsTestRequest(BaseModel):
|
||
target: Literal[
|
||
"infrastructure",
|
||
"storage",
|
||
"baidu_ocr",
|
||
"gpu",
|
||
"langfuse",
|
||
"sentry",
|
||
]
|
||
values: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
class RuntimeSettingsTestResult(BaseModel):
|
||
target: str
|
||
ok: bool
|
||
message: str
|
||
details: dict[str, Any] = Field(default_factory=dict)
|
||
|
||
|
||
ModelCategory = Literal["language", "multimodal"]
|
||
ModelCapability = Literal["orchestration", "spatial_understanding", "image_generation", "image_editing"]
|
||
ImageProtocol = Literal["openai_images", "aigc_media"]
|
||
ImageParameterProfile = Literal["generic", "gpt_image_2", "nano_banana_pro", "seedream_5_pro"]
|
||
|
||
|
||
class ModelPoolItem(BaseModel):
|
||
id: str = Field(min_length=1, max_length=120)
|
||
name: str = Field(min_length=1, max_length=120)
|
||
model_id: str = Field(min_length=1, max_length=200)
|
||
category: ModelCategory
|
||
provider: str = Field(default="custom", min_length=1, max_length=80)
|
||
base_url: str = Field(min_length=1, max_length=500)
|
||
api_key: str = ""
|
||
capabilities: list[ModelCapability] = Field(default_factory=list)
|
||
models_path: str = "/models"
|
||
chat_path: str = "/chat/completions"
|
||
image_generation_path: str = "/images/generations"
|
||
image_edit_path: str = "/images/edits"
|
||
image_status_path: str = "/v1/media/status"
|
||
image_protocol: ImageProtocol = "openai_images"
|
||
image_parameter_profile: ImageParameterProfile = "generic"
|
||
enabled: bool = True
|
||
|
||
|
||
class SecretGenerateRequest(BaseModel):
|
||
kind: Literal["hex24", "hex32", "base64_32"]
|
||
|
||
|
||
class SecretGenerateResponse(BaseModel):
|
||
value: str
|
||
|
||
|
||
REQUIRED_GROUPS: dict[str, list[str]] = {
|
||
"基础运行": ["database_status", "redis_status", "encryption_status"],
|
||
"文件存储": [
|
||
"s3_endpoint",
|
||
"s3_public_endpoint",
|
||
"s3_access_key_id",
|
||
"s3_secret_access_key",
|
||
"s3_bucket_inputs",
|
||
"s3_bucket_derived",
|
||
"s3_bucket_renders",
|
||
],
|
||
}
|
||
|
||
REQUIRED_TESTS = {
|
||
"基础运行": "infrastructure",
|
||
"文件存储": "storage",
|
||
}
|
||
|
||
MODEL_SETTINGS_DEFAULTS: dict[str, Any] = {
|
||
"model_pool": [],
|
||
"orchestrator_model_id": "",
|
||
"spatial_routing_mode": "auto",
|
||
"spatial_model_id": "",
|
||
"image_routing_mode": "auto",
|
||
"image_model_id": "",
|
||
}
|
||
MODEL_SETTINGS_KEYS = set(MODEL_SETTINGS_DEFAULTS)
|
||
LEGACY_SECRET_FIELDS = {"ai_api_key"}
|
||
|
||
TEST_FIELDS: dict[str, set[str]] = {
|
||
"infrastructure": set(),
|
||
"storage": {key for key in FIELDS if key.startswith("s3_")},
|
||
"baidu_ocr": {key for key in FIELDS if key.startswith("baidu_ocr_")},
|
||
"gpu": {key for key in FIELDS if key.startswith("gpu_")},
|
||
"langfuse": {key for key in FIELDS if key.startswith("langfuse_")},
|
||
"sentry": {key for key in FIELDS if key.startswith("sentry_")},
|
||
}
|
||
|
||
|
||
def _is_configured(value: Any) -> bool:
|
||
if value is None or value is False:
|
||
return False
|
||
if isinstance(value, str):
|
||
normalized = value.strip().lower()
|
||
return bool(normalized) and not normalized.startswith(("replace_", "__auto_"))
|
||
return True
|
||
|
||
|
||
def _join_api_url(base_url: str, path: str) -> str:
|
||
return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
|
||
|
||
|
||
class EncryptedSettingsStore:
|
||
def __init__(self, bootstrap: Settings | None = None) -> None:
|
||
self.bootstrap = bootstrap or get_settings()
|
||
self.root = Path(self.bootstrap.runtime_settings_dir)
|
||
if not self.root.is_absolute():
|
||
self.root = Path.cwd() / self.root
|
||
self.data_path = self.root / "settings.enc"
|
||
self.key_path = self.root / ".master-key"
|
||
|
||
def _fernet(self) -> Fernet:
|
||
self.root.mkdir(parents=True, exist_ok=True)
|
||
configured_key = self.bootstrap.settings_master_key.strip()
|
||
if configured_key:
|
||
try:
|
||
return Fernet(configured_key.encode("ascii"))
|
||
except (ValueError, TypeError):
|
||
derived = base64.urlsafe_b64encode(hashlib.sha256(configured_key.encode()).digest())
|
||
return Fernet(derived)
|
||
|
||
if self.key_path.exists():
|
||
return Fernet(self.key_path.read_bytes().strip())
|
||
|
||
key = Fernet.generate_key()
|
||
self.key_path.write_bytes(key)
|
||
try:
|
||
os.chmod(self.key_path, 0o600)
|
||
except OSError:
|
||
pass
|
||
return Fernet(key)
|
||
|
||
def defaults(self) -> dict[str, Any]:
|
||
field_defaults = {
|
||
key: field.default
|
||
for key, field in FIELDS.items()
|
||
if key in EDITABLE_FIELDS and field.default is not None
|
||
}
|
||
return {**field_defaults, **MODEL_SETTINGS_DEFAULTS}
|
||
|
||
def _migrate_legacy_models(self, document: dict[str, Any]) -> bool:
|
||
values = document["values"]
|
||
if values.get("model_pool") or not values.get("ai_base_url"):
|
||
return False
|
||
common = {
|
||
"provider": values.get("ai_provider", "custom"),
|
||
"base_url": values.get("ai_base_url", ""),
|
||
"api_key": values.get("ai_api_key", ""),
|
||
"models_path": values.get("ai_models_path", "/models"),
|
||
"chat_path": values.get("ai_chat_path", "/chat/completions"),
|
||
"image_generation_path": values.get("ai_image_generation_path", "/images/generations"),
|
||
"image_edit_path": values.get("ai_image_edit_path", "/images/edits"),
|
||
"enabled": True,
|
||
}
|
||
pool: list[dict[str, Any]] = []
|
||
legacy_roles = [
|
||
("legacy-orchestrator", "总调度模型", values.get("orchestrator_model"), "language", ["orchestration"]),
|
||
("legacy-spatial", "空间理解模型", values.get("vision_model"), "multimodal", ["spatial_understanding"]),
|
||
("legacy-image", "生图模型", values.get("image_model"), "multimodal", ["image_generation", "image_editing"]),
|
||
]
|
||
for item_id, name, model_id, category, capabilities in legacy_roles:
|
||
if not model_id:
|
||
continue
|
||
pool.append({
|
||
"id": item_id,
|
||
"name": name,
|
||
"model_id": model_id,
|
||
"category": category,
|
||
"capabilities": capabilities,
|
||
**common,
|
||
})
|
||
if not pool:
|
||
return False
|
||
values.update(MODEL_SETTINGS_DEFAULTS)
|
||
values["model_pool"] = pool
|
||
values["orchestrator_model_id"] = "legacy-orchestrator" if values.get("orchestrator_model") else ""
|
||
values["spatial_routing_mode"] = "manual"
|
||
values["spatial_model_id"] = "legacy-spatial" if values.get("vision_model") else ""
|
||
values["image_routing_mode"] = "manual"
|
||
values["image_model_id"] = "legacy-image" if values.get("image_model") else ""
|
||
for legacy_key in (
|
||
"ai_provider",
|
||
"ai_base_url",
|
||
"ai_api_key",
|
||
"orchestrator_model",
|
||
"vision_model",
|
||
"image_model",
|
||
"ai_models_path",
|
||
"ai_chat_path",
|
||
"ai_image_generation_path",
|
||
"ai_image_edit_path",
|
||
):
|
||
values.pop(legacy_key, None)
|
||
document["tests"].pop("ai_models", None)
|
||
return True
|
||
|
||
@staticmethod
|
||
def _migrate_model_protocol_fields(document: dict[str, Any]) -> bool:
|
||
changed = False
|
||
known_profiles = {
|
||
"gpt-image-2": "gpt_image_2",
|
||
"gemini-3-pro-image-preview": "nano_banana_pro",
|
||
"doubao-seedream-5-0-pro-260628": "seedream_5_pro",
|
||
}
|
||
for item in document.get("values", {}).get("model_pool", []):
|
||
if not isinstance(item, dict):
|
||
continue
|
||
defaults = {
|
||
"image_status_path": "/v1/media/status",
|
||
"image_protocol": "openai_images",
|
||
"image_parameter_profile": "generic",
|
||
}
|
||
host = urlparse(str(item.get("base_url", ""))).hostname or ""
|
||
profile = known_profiles.get(str(item.get("model_id", "")))
|
||
if host == "api.lk888.ai" and profile and "image_generation" in item.get("capabilities", []):
|
||
defaults.update(
|
||
image_protocol="aigc_media",
|
||
image_parameter_profile=profile,
|
||
image_generation_path="/v1/media/generate",
|
||
image_status_path="/v1/media/status",
|
||
)
|
||
for key, value in defaults.items():
|
||
if key not in item:
|
||
item[key] = value
|
||
changed = True
|
||
return changed
|
||
|
||
def load_document(self) -> dict[str, Any]:
|
||
if not self.data_path.exists():
|
||
return {"values": self.defaults(), "tests": {}}
|
||
try:
|
||
decrypted = self._fernet().decrypt(self.data_path.read_bytes())
|
||
document = json.loads(decrypted.decode("utf-8"))
|
||
except (InvalidToken, ValueError, json.JSONDecodeError) as exc:
|
||
raise RuntimeError("运行期配置无法解密,请检查主密钥是否发生变化。") from exc
|
||
document.setdefault("values", {})
|
||
document.setdefault("tests", {})
|
||
migrated = self._migrate_legacy_models(document)
|
||
migrated = self._migrate_model_protocol_fields(document) or migrated
|
||
if migrated:
|
||
self.save_document(document)
|
||
return document
|
||
|
||
def save_document(self, document: dict[str, Any]) -> None:
|
||
self.root.mkdir(parents=True, exist_ok=True)
|
||
payload = json.dumps(document, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||
encrypted = self._fernet().encrypt(payload)
|
||
temporary = self.data_path.with_suffix(".tmp")
|
||
temporary.write_bytes(encrypted)
|
||
temporary.replace(self.data_path)
|
||
|
||
def merged_values(self, pending: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
document = self.load_document()
|
||
values = {**self.defaults(), **document["values"]}
|
||
for key, value in (pending or {}).items():
|
||
if key == "model_pool":
|
||
values[key] = self._coerce_model_pool(value, values.get("model_pool", []))
|
||
continue
|
||
if key in MODEL_SETTINGS_KEYS:
|
||
values[key] = value.strip() if isinstance(value, str) else value
|
||
continue
|
||
if key not in EDITABLE_FIELDS:
|
||
continue
|
||
if key in SECRET_FIELDS and not _is_configured(value):
|
||
continue
|
||
values[key] = self._coerce(key, value)
|
||
return values
|
||
|
||
def update(self, patch: dict[str, Any]) -> None:
|
||
document = self.load_document()
|
||
changed: set[str] = set()
|
||
for key, raw_value in patch.items():
|
||
if key == "model_pool":
|
||
value = self._coerce_model_pool(raw_value, document["values"].get("model_pool", []))
|
||
if document["values"].get(key) != value:
|
||
document["values"][key] = value
|
||
changed.add(key)
|
||
continue
|
||
if key in MODEL_SETTINGS_KEYS:
|
||
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
|
||
if document["values"].get(key) != value:
|
||
document["values"][key] = value
|
||
changed.add(key)
|
||
continue
|
||
if key not in EDITABLE_FIELDS:
|
||
continue
|
||
if key in SECRET_FIELDS and not _is_configured(raw_value):
|
||
continue
|
||
value = self._coerce(key, raw_value)
|
||
if document["values"].get(key) != value:
|
||
document["values"][key] = value
|
||
changed.add(key)
|
||
for target, fields in TEST_FIELDS.items():
|
||
if changed & fields:
|
||
document["tests"].pop(target, None)
|
||
if "model_pool" in changed:
|
||
current_ids = {item["id"] for item in document["values"].get("model_pool", [])}
|
||
document["tests"] = {
|
||
target: result
|
||
for target, result in document["tests"].items()
|
||
if not (
|
||
target.startswith("model:") or target.startswith("generation:")
|
||
) or target.split(":", 1)[1] in current_ids
|
||
}
|
||
self.save_document(document)
|
||
|
||
def record_test(self, result: RuntimeSettingsTestResult, values: dict[str, Any] | None = None) -> None:
|
||
document = self.load_document()
|
||
document["tests"][result.target] = {
|
||
"ok": result.ok,
|
||
"message": result.message,
|
||
"details": result.details,
|
||
"fingerprint": self.test_fingerprint(result.target, values or self.merged_values()),
|
||
}
|
||
self.save_document(document)
|
||
|
||
def public_response(self) -> RuntimeSettingsResponse:
|
||
document = self.load_document()
|
||
values = {**self.defaults(), **document["values"]}
|
||
public_values = {
|
||
key: value
|
||
for key, value in values.items()
|
||
if key not in SECRET_FIELDS and key not in LEGACY_SECRET_FIELDS
|
||
}
|
||
public_values["model_pool"] = [
|
||
{
|
||
**{key: value for key, value in item.items() if key != "api_key"},
|
||
"api_key_configured": _is_configured(item.get("api_key")),
|
||
}
|
||
for item in values.get("model_pool", [])
|
||
]
|
||
configured = {key: _is_configured(values.get(key)) for key in FIELDS}
|
||
configured.update(self.bootstrap_statuses())
|
||
current_tests = self.current_tests(values, document["tests"])
|
||
return RuntimeSettingsResponse(
|
||
categories=CATEGORIES,
|
||
values=public_values,
|
||
configured=configured,
|
||
tests=current_tests,
|
||
readiness=self.readiness(values, current_tests),
|
||
)
|
||
|
||
@staticmethod
|
||
def _coerce_model_pool(raw_pool: Any, existing_pool: Any) -> list[dict[str, Any]]:
|
||
if not isinstance(raw_pool, list):
|
||
raise ValueError("模型池必须是列表。")
|
||
if len(raw_pool) > 50:
|
||
raise ValueError("模型池最多保存 50 个模型。")
|
||
existing_by_id = {
|
||
str(item.get("id")): item
|
||
for item in existing_pool
|
||
if isinstance(item, dict) and item.get("id")
|
||
}
|
||
normalized: list[dict[str, Any]] = []
|
||
seen: set[str] = set()
|
||
for raw_item in raw_pool:
|
||
if not isinstance(raw_item, dict):
|
||
raise ValueError("模型配置格式不正确。")
|
||
item_data = dict(raw_item)
|
||
item_id = str(item_data.get("id", "")).strip()
|
||
if item_id in seen:
|
||
raise ValueError(f"模型 ID 重复:{item_id}")
|
||
if not _is_configured(item_data.get("api_key")):
|
||
item_data["api_key"] = existing_by_id.get(item_id, {}).get("api_key", "")
|
||
item_data.pop("api_key_configured", None)
|
||
item = ModelPoolItem.model_validate(item_data)
|
||
if item.category == "language":
|
||
item.capabilities = ["orchestration"]
|
||
elif not item.capabilities:
|
||
raise ValueError(f"多模态模型“{item.name}”至少需要选择一种能力。")
|
||
normalized.append(item.model_dump())
|
||
seen.add(item.id)
|
||
return normalized
|
||
|
||
def bootstrap_statuses(self) -> dict[str, bool]:
|
||
return {
|
||
"database_status": _is_configured(self.bootstrap.database_url),
|
||
"redis_status": _is_configured(self.bootstrap.redis_url),
|
||
"encryption_status": self._master_key_available(),
|
||
}
|
||
|
||
def _master_key_available(self) -> bool:
|
||
if self.bootstrap.settings_master_key.strip() or self.key_path.exists():
|
||
return True
|
||
try:
|
||
self._fernet()
|
||
return True
|
||
except OSError:
|
||
return False
|
||
|
||
def readiness(
|
||
self,
|
||
values: dict[str, Any] | None = None,
|
||
tests: dict[str, dict[str, Any]] | None = None,
|
||
) -> SettingsReadiness:
|
||
values = values or self.merged_values()
|
||
if tests is None:
|
||
tests = self.current_tests(values, self.load_document()["tests"])
|
||
statuses = self.bootstrap_statuses()
|
||
all_values = {**values, **statuses}
|
||
missing: list[str] = []
|
||
untested: list[str] = []
|
||
completed = 0
|
||
for group, keys in REQUIRED_GROUPS.items():
|
||
group_missing = [key for key in keys if not _is_configured(all_values.get(key))]
|
||
if group_missing:
|
||
labels = [FIELDS[key].label for key in group_missing]
|
||
missing.append(f"{group}:{'、'.join(labels)}")
|
||
continue
|
||
target = REQUIRED_TESTS[group]
|
||
if not tests.get(target, {}).get("ok"):
|
||
untested.append(f"{group}:尚未通过连接测试")
|
||
continue
|
||
completed += 1
|
||
|
||
pool = [item for item in values.get("model_pool", []) if isinstance(item, dict) and item.get("enabled", True)]
|
||
models = {str(item.get("id")): item for item in pool if item.get("id")}
|
||
model_missing: list[str] = []
|
||
model_untested: list[str] = []
|
||
|
||
language_models = [item for item in pool if item.get("category") == "language"]
|
||
if not language_models:
|
||
model_missing.append("至少添加一个已启用的大语言模型")
|
||
orchestrator_id = str(values.get("orchestrator_model_id", ""))
|
||
orchestrator = models.get(orchestrator_id)
|
||
if not orchestrator_id:
|
||
model_missing.append("选择总调度模型")
|
||
elif not orchestrator or orchestrator.get("category") != "language":
|
||
model_missing.append("总调度模型已删除、已停用或类型不正确")
|
||
elif not tests.get(f"model:{orchestrator_id}", {}).get("ok"):
|
||
model_untested.append(f"总调度模型“{orchestrator.get('name')}”尚未通过测试")
|
||
|
||
route_specs = [
|
||
("空间理解", "spatial_routing_mode", "spatial_model_id", "spatial_understanding"),
|
||
("生图", "image_routing_mode", "image_model_id", "image_generation"),
|
||
]
|
||
for label, mode_key, selected_key, capability in route_specs:
|
||
eligible = [item for item in pool if capability in item.get("capabilities", [])]
|
||
mode = values.get(mode_key, "auto")
|
||
if not eligible:
|
||
model_missing.append(f"添加支持{label}的多模态模型")
|
||
continue
|
||
if mode == "manual":
|
||
selected_id = str(values.get(selected_key, ""))
|
||
selected = models.get(selected_id)
|
||
test_target = f"generation:{selected_id}" if capability == "image_generation" else f"model:{selected_id}"
|
||
if not selected_id:
|
||
model_missing.append(f"手动选择{label}模型")
|
||
elif not selected or capability not in selected.get("capabilities", []):
|
||
model_missing.append(f"{label}模型已删除、已停用或能力不匹配")
|
||
elif not tests.get(test_target, {}).get("ok"):
|
||
requirement = "真实试生成" if capability == "image_generation" else "连接测试"
|
||
model_untested.append(f"{label}模型“{selected.get('name')}”尚未通过{requirement}")
|
||
elif mode == "auto":
|
||
prefix = "generation" if capability == "image_generation" else "model"
|
||
if not any(tests.get(f"{prefix}:{item['id']}", {}).get("ok") for item in eligible):
|
||
requirement = "真实试生成" if capability == "image_generation" else "连接测试"
|
||
model_untested.append(f"{label}自动路由没有已通过{requirement}的候选模型")
|
||
else:
|
||
model_missing.append(f"{label}路由方式无效")
|
||
|
||
incomplete_models = [
|
||
item.get("name", item.get("model_id", "未命名模型"))
|
||
for item in pool
|
||
if not all(_is_configured(item.get(key)) for key in ("name", "model_id", "base_url", "api_key"))
|
||
]
|
||
if incomplete_models:
|
||
model_missing.append(f"补全模型配置:{'、'.join(incomplete_models)}")
|
||
|
||
if model_missing:
|
||
missing.append(f"AI 模型:{';'.join(dict.fromkeys(model_missing))}")
|
||
elif model_untested:
|
||
untested.append(f"AI 模型:{';'.join(dict.fromkeys(model_untested))}")
|
||
else:
|
||
completed += 1
|
||
return SettingsReadiness(
|
||
ready=not missing and not untested,
|
||
completed_required=completed,
|
||
total_required=len(REQUIRED_GROUPS) + 1,
|
||
missing=missing,
|
||
untested=untested,
|
||
)
|
||
|
||
def current_tests(
|
||
self,
|
||
values: dict[str, Any],
|
||
tests: dict[str, dict[str, Any]],
|
||
) -> dict[str, dict[str, Any]]:
|
||
current: dict[str, dict[str, Any]] = {}
|
||
for target, result in tests.items():
|
||
if target.startswith("model:") or target.startswith("generation:"):
|
||
model_id = target.split(":", 1)[1]
|
||
if not any(item.get("id") == model_id for item in values.get("model_pool", [])):
|
||
continue
|
||
item = dict(result)
|
||
if item.get("fingerprint") != self.test_fingerprint(target, values):
|
||
item["ok"] = False
|
||
item["message"] = "配置已变更,请重新测试。"
|
||
current[target] = item
|
||
return current
|
||
|
||
def test_fingerprint(self, target: str, values: dict[str, Any]) -> str:
|
||
if target == "infrastructure":
|
||
relevant: dict[str, Any] = {
|
||
"database_url": self.bootstrap.database_url,
|
||
"redis_url": self.bootstrap.redis_url,
|
||
}
|
||
elif target.startswith("model:") or target.startswith("generation:"):
|
||
model_id = target.split(":", 1)[1]
|
||
relevant = next(
|
||
(item for item in values.get("model_pool", []) if item.get("id") == model_id),
|
||
{"id": model_id, "missing": True},
|
||
)
|
||
else:
|
||
relevant = {key: values.get(key) for key in sorted(TEST_FIELDS.get(target, set()))}
|
||
payload = json.dumps(relevant, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
|
||
return hashlib.sha256(payload).hexdigest()
|
||
|
||
@staticmethod
|
||
def _coerce(key: str, value: Any) -> Any:
|
||
field = FIELDS[key]
|
||
if field.kind == "toggle":
|
||
if isinstance(value, str):
|
||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||
return bool(value)
|
||
if field.kind == "number":
|
||
return int(value)
|
||
return value.strip() if isinstance(value, str) else value
|
||
|
||
|
||
def get_runtime_store() -> EncryptedSettingsStore:
|
||
return EncryptedSettingsStore(get_settings())
|
||
|
||
|
||
def generate_secret(kind: str) -> str:
|
||
if kind == "hex24":
|
||
return secrets.token_hex(24)
|
||
if kind == "hex32":
|
||
return secrets.token_hex(32)
|
||
if kind == "base64_32":
|
||
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii")
|
||
raise ValueError("不支持的密钥类型。")
|
||
|
||
|
||
async def test_runtime_settings(
|
||
target: str,
|
||
values: dict[str, Any],
|
||
bootstrap: Settings,
|
||
) -> RuntimeSettingsTestResult:
|
||
try:
|
||
if target == "infrastructure":
|
||
return await _test_infrastructure(bootstrap)
|
||
if target == "storage":
|
||
return await _test_storage(values)
|
||
if target == "baidu_ocr":
|
||
return await _test_baidu_ocr(values)
|
||
if target == "gpu":
|
||
return await _test_gpu(values)
|
||
if target == "langfuse":
|
||
return await _test_langfuse(values)
|
||
if target == "sentry":
|
||
return await _test_sentry(values)
|
||
except Exception as exc: # Integration boundaries return a safe, user-readable failure.
|
||
return RuntimeSettingsTestResult(target=target, ok=False, message=f"连接失败:{exc}")
|
||
return RuntimeSettingsTestResult(target=target, ok=False, message="未知测试类型。")
|
||
|
||
|
||
async def _test_infrastructure(bootstrap: Settings) -> RuntimeSettingsTestResult:
|
||
return await anyio.to_thread.run_sync(_test_infrastructure_sync, bootstrap)
|
||
|
||
|
||
def _test_infrastructure_sync(bootstrap: Settings) -> RuntimeSettingsTestResult:
|
||
with psycopg.connect(bootstrap.database_url, connect_timeout=5) as connection:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute("SELECT 1")
|
||
cursor.fetchone()
|
||
redis_client = redis.Redis.from_url(
|
||
bootstrap.redis_url,
|
||
socket_connect_timeout=5,
|
||
socket_timeout=5,
|
||
)
|
||
redis_client.ping()
|
||
return RuntimeSettingsTestResult(
|
||
target="infrastructure",
|
||
ok=True,
|
||
message="PostgreSQL 与 Redis 均连接正常。",
|
||
)
|
||
|
||
|
||
async def _test_storage(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||
return await anyio.to_thread.run_sync(_test_storage_sync, values)
|
||
|
||
|
||
def _test_storage_sync(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||
client = boto3.client(
|
||
"s3",
|
||
endpoint_url=values["s3_endpoint"],
|
||
aws_access_key_id=values["s3_access_key_id"],
|
||
aws_secret_access_key=values["s3_secret_access_key"],
|
||
region_name="us-east-1",
|
||
config=BotoConfig(
|
||
connect_timeout=5,
|
||
read_timeout=5,
|
||
retries={"max_attempts": 1},
|
||
s3={"addressing_style": "path" if values.get("s3_force_path_style", True) else "virtual"},
|
||
),
|
||
)
|
||
existing = {bucket["Name"] for bucket in client.list_buckets().get("Buckets", [])}
|
||
required = [
|
||
values["s3_bucket_inputs"],
|
||
values["s3_bucket_derived"],
|
||
values["s3_bucket_renders"],
|
||
]
|
||
missing = [bucket for bucket in required if bucket not in existing]
|
||
return RuntimeSettingsTestResult(
|
||
target="storage",
|
||
ok=not missing,
|
||
message="MinIO 连接正常,三个存储空间均可用。" if not missing else "MinIO 可连接,但需要先创建部分存储空间。",
|
||
details={"missing_buckets": missing},
|
||
)
|
||
|
||
|
||
async def _test_baidu_ocr(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||
if not values.get("baidu_ocr_enabled"):
|
||
return RuntimeSettingsTestResult(target="baidu_ocr", ok=True, message="百度 OCR 当前未启用。")
|
||
async with httpx.AsyncClient(timeout=8) as client:
|
||
response = await client.post(
|
||
"https://aip.baidubce.com/oauth/2.0/token",
|
||
params={
|
||
"grant_type": "client_credentials",
|
||
"client_id": values.get("baidu_ocr_api_key", ""),
|
||
"client_secret": values.get("baidu_ocr_secret_key", ""),
|
||
},
|
||
)
|
||
response.raise_for_status()
|
||
payload = response.json()
|
||
ok = bool(payload.get("access_token"))
|
||
return RuntimeSettingsTestResult(
|
||
target="baidu_ocr",
|
||
ok=ok,
|
||
message="百度 OCR 凭证有效。" if ok else "百度 OCR 未返回访问令牌。",
|
||
)
|
||
|
||
|
||
async def test_model_pool_item(values: dict[str, Any], model_id: str) -> RuntimeSettingsTestResult:
|
||
target = f"model:{model_id}"
|
||
item = next(
|
||
(candidate for candidate in values.get("model_pool", []) if candidate.get("id") == model_id),
|
||
None,
|
||
)
|
||
if item is None:
|
||
return RuntimeSettingsTestResult(target=target, ok=False, message="模型不存在或已被删除。")
|
||
name = item.get("name") or item.get("model_id") or model_id
|
||
missing_fields = [
|
||
label
|
||
for key, label in (("model_id", "模型 ID"), ("base_url", "Base URL"), ("api_key", "API Key"))
|
||
if not _is_configured(item.get(key))
|
||
]
|
||
if missing_fields:
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=False,
|
||
message=f"模型“{name}”配置不完整:缺少{'、'.join(missing_fields)}。",
|
||
)
|
||
|
||
url = _join_api_url(str(item["base_url"]), str(item.get("models_path", "/models")))
|
||
headers = {"Authorization": f"Bearer {item['api_key']}"}
|
||
try:
|
||
async with httpx.AsyncClient(timeout=12) as client:
|
||
response = await client.get(url, headers=headers)
|
||
response.raise_for_status()
|
||
payload = response.json()
|
||
except httpx.HTTPStatusError as exc:
|
||
status_code = exc.response.status_code
|
||
if status_code in {401, 403}:
|
||
reason = "API Key 无效或账号无权访问模型列表"
|
||
elif status_code == 404:
|
||
reason = f"模型列表地址不存在,请检查路径 {item.get('models_path', '/models')}"
|
||
else:
|
||
reason = f"模型列表接口返回 HTTP {status_code}"
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=False,
|
||
message=f"模型“{name}”测试失败:{reason}。",
|
||
details={"model_id": item["model_id"], "status_code": status_code},
|
||
)
|
||
except httpx.RequestError as exc:
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=False,
|
||
message=f"模型“{name}”无法连接 API:{exc}。",
|
||
details={"model_id": item["model_id"]},
|
||
)
|
||
except (ValueError, json.JSONDecodeError):
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=False,
|
||
message=f"模型“{name}”的模型列表接口没有返回有效 JSON。",
|
||
details={"model_id": item["model_id"]},
|
||
)
|
||
|
||
rows = payload.get("data", []) if isinstance(payload, dict) else []
|
||
listed_ids = {
|
||
candidate.get("id")
|
||
for candidate in rows
|
||
if isinstance(candidate, dict) and candidate.get("id")
|
||
}
|
||
protocol_error = _validate_image_protocol(item)
|
||
if protocol_error:
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=False,
|
||
message=f"模型“{name}”的生图协议配置不正确:{protocol_error}",
|
||
details={"model_id": item["model_id"], "model_count": len(listed_ids)},
|
||
)
|
||
is_aigc_media = (
|
||
"image_generation" in item.get("capabilities", [])
|
||
and item.get("image_protocol") == "aigc_media"
|
||
)
|
||
if not listed_ids:
|
||
if is_aigc_media:
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=True,
|
||
message=(
|
||
f"模型“{name}”的 API Key 与模型列表接口连接正常;平台未在 /v1/models 中公开媒体模型 ID"
|
||
f"“{item['model_id']}”,这里只完成连接与鉴权验证。最终可用性以“试生成”为准。"
|
||
),
|
||
details={
|
||
"model_id": item["model_id"],
|
||
"model_count": 0,
|
||
"id_advertised": False,
|
||
"verification": "connectivity_only",
|
||
},
|
||
)
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=False,
|
||
message=f"模型“{name}”的 API 可以连接,但模型列表为空,暂时无法确认模型 ID。",
|
||
details={"model_id": item["model_id"], "model_count": 0},
|
||
)
|
||
if item["model_id"] not in listed_ids:
|
||
if is_aigc_media:
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=True,
|
||
message=(
|
||
f"模型“{name}”的 API Key 与模型列表接口连接正常;平台返回了 {len(listed_ids)} 个模型,"
|
||
f"但未公开媒体模型 ID“{item['model_id']}”。这不是生图失败,最终可用性以“试生成”为准。"
|
||
),
|
||
details={
|
||
"model_id": item["model_id"],
|
||
"model_count": len(listed_ids),
|
||
"id_advertised": False,
|
||
"verification": "connectivity_only",
|
||
},
|
||
)
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=False,
|
||
message=f"模型“{name}”测试失败:账号模型列表中未找到 ID“{item['model_id']}”。",
|
||
details={"model_id": item["model_id"], "model_count": len(listed_ids)},
|
||
)
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=True,
|
||
message=f"模型“{name}”连接正常,模型 ID“{item['model_id']}”可用。此项不实际生图,不消耗额度。",
|
||
details={"model_id": item["model_id"], "model_count": len(listed_ids)},
|
||
)
|
||
|
||
|
||
def _validate_image_protocol(item: dict[str, Any]) -> str:
|
||
if "image_generation" not in item.get("capabilities", []):
|
||
return ""
|
||
host = urlparse(str(item.get("base_url", ""))).hostname or ""
|
||
known_aigc_models = {
|
||
"gpt-image-2": "gpt_image_2",
|
||
"gemini-3-pro-image-preview": "nano_banana_pro",
|
||
"doubao-seedream-5-0-pro-260628": "seedream_5_pro",
|
||
}
|
||
expected_profile = known_aigc_models.get(str(item.get("model_id", "")))
|
||
if host == "api.lk888.ai" and expected_profile:
|
||
if item.get("image_protocol", "openai_images") != "aigc_media":
|
||
return "聚合引擎上的该模型应选择“AIGC 异步媒体协议”"
|
||
generation_path = urlparse(_join_api_url(str(item["base_url"]), str(item.get("image_generation_path", "")))).path
|
||
status_path = urlparse(_join_api_url(str(item["base_url"]), str(item.get("image_status_path", "")))).path
|
||
if generation_path != "/v1/media/generate":
|
||
return "生图地址应解析为 /v1/media/generate"
|
||
if status_path != "/v1/media/status":
|
||
return "任务查询地址应解析为 /v1/media/status"
|
||
if item.get("image_parameter_profile", "generic") != expected_profile:
|
||
return "参数预设与模型不匹配,请重新选择对应的模型预设"
|
||
return ""
|
||
|
||
|
||
async def test_image_generation(values: dict[str, Any], model_id: str) -> RuntimeSettingsTestResult:
|
||
"""Run one real, billable image request to prove the configured protocol end to end."""
|
||
target = f"generation:{model_id}"
|
||
item = next(
|
||
(candidate for candidate in values.get("model_pool", []) if candidate.get("id") == model_id),
|
||
None,
|
||
)
|
||
if item is None:
|
||
return RuntimeSettingsTestResult(target=target, ok=False, message="模型不存在或已被删除。")
|
||
if "image_generation" not in item.get("capabilities", []):
|
||
return RuntimeSettingsTestResult(target=target, ok=False, message="该模型没有启用图像生成能力。")
|
||
protocol_error = _validate_image_protocol(item)
|
||
if protocol_error:
|
||
return RuntimeSettingsTestResult(target=target, ok=False, message=f"无法试生成:{protocol_error}")
|
||
|
||
profile = item.get("image_parameter_profile", "generic")
|
||
trial_options: dict[str, Any] = {}
|
||
if profile == "gpt_image_2":
|
||
trial_options = {"size": "1024x1024", "quality": "low"}
|
||
elif profile == "nano_banana_pro":
|
||
trial_options = {"aspectRatio": "1:1", "imageSize": "1K"}
|
||
elif profile == "seedream_5_pro":
|
||
trial_options = {"aspect_ratio": "1:1", "size": "1K"}
|
||
|
||
try:
|
||
from app.integrations.openai_compatible import OpenAICompatibleGateway
|
||
|
||
result = await OpenAICompatibleGateway(values).generate_image(
|
||
"极简室内材质测试图:一个米白色立方体放在浅灰背景中,无文字",
|
||
model_instance_id=model_id,
|
||
**trial_options,
|
||
)
|
||
output = _find_generated_image(result)
|
||
if not output:
|
||
from app.integrations.openai_compatible import safe_response_diagnostic
|
||
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=False,
|
||
message="平台请求已结束,但返回体中没有找到可识别的任务 ID、图片地址或图片数据。",
|
||
details={"response_diagnostic": safe_response_diagnostic(result)},
|
||
)
|
||
verification = await _verify_generated_image(output)
|
||
details = {
|
||
"model_id": item["model_id"],
|
||
"output_kind": output[0],
|
||
"verified_image": True,
|
||
**verification,
|
||
}
|
||
if output[0] == "url":
|
||
details["result_url"] = output[1]
|
||
return RuntimeSettingsTestResult(
|
||
target=target,
|
||
ok=True,
|
||
message=(
|
||
f"模型“{item.get('name', item['model_id'])}”已生成并成功下载校验一张"
|
||
f" {verification['image_format'].upper()} 图片({verification['byte_size'] // 1024} KB),端到端配置可用。"
|
||
),
|
||
details=details,
|
||
)
|
||
except (httpx.HTTPError, RuntimeError, ValueError) as exc:
|
||
return RuntimeSettingsTestResult(target=target, ok=False, message=f"真实生图失败:{exc}")
|
||
|
||
|
||
def _find_generated_image(payload: dict[str, Any]) -> tuple[str, str] | None:
|
||
if payload.get("result_url"):
|
||
return "url", str(payload["result_url"])
|
||
for row in payload.get("data", []) if isinstance(payload.get("data"), list) else []:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
if row.get("url"):
|
||
return "url", str(row["url"])
|
||
if row.get("b64_json"):
|
||
return "inline", str(row["b64_json"])
|
||
for candidate in payload.get("candidates", []) if isinstance(payload.get("candidates"), list) else []:
|
||
parts = candidate.get("content", {}).get("parts", []) if isinstance(candidate, dict) else []
|
||
for part in parts:
|
||
if not isinstance(part, dict):
|
||
continue
|
||
inline_data = part.get("inlineData") or part.get("inline_data") or {}
|
||
if isinstance(inline_data, dict) and inline_data.get("data"):
|
||
return "inline", str(inline_data["data"])
|
||
return None
|
||
|
||
|
||
async def _verify_generated_image(output: tuple[str, str]) -> dict[str, Any]:
|
||
kind, value = output
|
||
content_type = ""
|
||
if kind == "url":
|
||
async with httpx.AsyncClient(timeout=45, follow_redirects=True) as client:
|
||
response = await client.get(value)
|
||
response.raise_for_status()
|
||
image_bytes = response.content
|
||
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||
elif kind == "inline":
|
||
encoded = value.split(",", 1)[1] if value.startswith("data:") and "," in value else value
|
||
try:
|
||
image_bytes = base64.b64decode(encoded, validate=True)
|
||
except (binascii.Error, ValueError) as exc:
|
||
raise ValueError("平台返回了无法解码的 Base64 图片数据") from exc
|
||
else:
|
||
raise ValueError(f"不支持的图片输出类型:{kind}")
|
||
|
||
if len(image_bytes) < 1024:
|
||
raise ValueError(f"平台返回的图片文件过小({len(image_bytes)} 字节),不能视为有效生图")
|
||
if len(image_bytes) > 25 * 1024 * 1024:
|
||
raise ValueError("平台返回的测试图片超过 25 MB,已拒绝继续处理")
|
||
image_format = _detect_image_format(image_bytes)
|
||
if not image_format:
|
||
description = content_type or "未知内容类型"
|
||
raise ValueError(f"结果地址可以访问,但下载内容不是可识别的图片({description})")
|
||
if content_type and not content_type.startswith("image/") and content_type != "application/octet-stream":
|
||
raise ValueError(f"结果地址返回了非图片内容类型:{content_type}")
|
||
return {
|
||
"byte_size": len(image_bytes),
|
||
"content_type": content_type or f"image/{image_format}",
|
||
"image_format": image_format,
|
||
}
|
||
|
||
|
||
def _detect_image_format(content: bytes) -> str:
|
||
if content.startswith(b"\x89PNG\r\n\x1a\n"):
|
||
return "png"
|
||
if content.startswith(b"\xff\xd8\xff"):
|
||
return "jpeg"
|
||
if content.startswith((b"GIF87a", b"GIF89a")):
|
||
return "gif"
|
||
if content.startswith(b"RIFF") and content[8:12] == b"WEBP":
|
||
return "webp"
|
||
if content.startswith(b"BM"):
|
||
return "bmp"
|
||
if len(content) >= 12 and content[4:8] == b"ftyp":
|
||
brand = content[8:12]
|
||
if brand in {b"avif", b"avis"}:
|
||
return "avif"
|
||
if brand in {b"heic", b"heix", b"hevc", b"hevx", b"mif1", b"msf1"}:
|
||
return "heic"
|
||
return ""
|
||
|
||
|
||
async def _test_gpu(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||
mode = values.get("gpu_mode", "disabled")
|
||
if mode == "disabled":
|
||
return RuntimeSettingsTestResult(target="gpu", ok=True, message="本地 GPU 当前未启用。")
|
||
local_host = "host.docker.internal" if Path("/.dockerenv").exists() else "127.0.0.1"
|
||
url = f"http://{local_host}:8100" if mode == "local" else values.get("gpu_service_url", "")
|
||
async with httpx.AsyncClient(timeout=8) as client:
|
||
response = await client.get(
|
||
_join_api_url(url, "/health"),
|
||
headers={"Authorization": f"Bearer {values.get('gpu_service_token', '')}"},
|
||
)
|
||
response.raise_for_status()
|
||
return RuntimeSettingsTestResult(target="gpu", ok=True, message="GPU Worker 连接正常。")
|
||
|
||
|
||
async def _test_langfuse(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||
if not values.get("langfuse_enabled"):
|
||
return RuntimeSettingsTestResult(target="langfuse", ok=True, message="Langfuse 当前未启用。")
|
||
async with httpx.AsyncClient(timeout=8) as client:
|
||
response = await client.get(
|
||
_join_api_url(values.get("langfuse_host", ""), "/api/public/projects"),
|
||
auth=(
|
||
str(values.get("langfuse_public_key", "")),
|
||
str(values.get("langfuse_secret_key", "")),
|
||
),
|
||
)
|
||
response.raise_for_status()
|
||
return RuntimeSettingsTestResult(target="langfuse", ok=True, message="Langfuse 服务与项目密钥均有效。")
|
||
|
||
|
||
async def _test_sentry(values: dict[str, Any]) -> RuntimeSettingsTestResult:
|
||
if not values.get("sentry_enabled"):
|
||
return RuntimeSettingsTestResult(target="sentry", ok=True, message="Sentry 当前未启用。")
|
||
dsn = values.get("sentry_dsn", "")
|
||
parsed = urlparse(dsn)
|
||
if parsed.scheme not in {"http", "https"} or not parsed.hostname or not parsed.username:
|
||
return RuntimeSettingsTestResult(target="sentry", ok=False, message="Sentry DSN 格式不正确。")
|
||
async with httpx.AsyncClient(timeout=8) as client:
|
||
response = await client.get(f"{parsed.scheme}://{parsed.netloc.split('@')[-1]}")
|
||
return RuntimeSettingsTestResult(
|
||
target="sentry",
|
||
ok=response.status_code < 500,
|
||
message="Sentry 地址可访问,DSN 格式正确。" if response.status_code < 500 else "Sentry 服务返回异常。",
|
||
)
|