Files

1025 lines
43 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 服务返回异常。",
)