feat: add visual settings and readiness gate

This commit is contained in:
Codex
2026-08-01 22:14:20 +08:00
parent 6a15217d55
commit 3b4106e8cc
25 changed files with 2447 additions and 239 deletions
+28 -14
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from app.config import Settings, get_settings
from app.api.settings import require_workflow_ready
from app.domain.models import CommandRequest, ProjectSnapshot, WorkflowDefinition
from app.domain.workflow import (
InvalidTransitionError,
@@ -14,6 +15,7 @@ from app.domain.workflow import (
from app.integrations.model_router import ModelRouter
from app.integrations.storage import S3Storage
from app.repositories.memory import repository
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
router = APIRouter(prefix="/v1")
@@ -24,23 +26,28 @@ class UploadRequest(BaseModel):
@router.get("/health")
def health(settings: Settings = Depends(get_settings)) -> dict:
router_state = ModelRouter(settings)
def health(
settings: Settings = Depends(get_settings),
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
) -> dict:
values = runtime_store.merged_values()
router_state = ModelRouter(values)
return {
"status": "ok",
"environment": settings.app_env,
"integrations": {
"storage": S3Storage(settings).configured,
"storage": S3Storage(values).configured,
"baidu_ocr": bool(
settings.baidu_ocr_enabled
and settings.baidu_ocr_api_key
and settings.baidu_ocr_secret_key
values.get("baidu_ocr_enabled")
and values.get("baidu_ocr_api_key")
and values.get("baidu_ocr_secret_key")
),
"gpu": bool(settings.gpu_service_url and settings.gpu_service_token),
"gpu": values.get("gpu_mode") in {"local", "remote"}
and bool(values.get("gpu_service_token")),
"langfuse": bool(
settings.langfuse_enabled
and settings.langfuse_public_key
and settings.langfuse_secret_key
values.get("langfuse_enabled")
and values.get("langfuse_public_key")
and values.get("langfuse_secret_key")
),
"image_providers": [item.__dict__ for item in router_state.capabilities()],
},
@@ -60,7 +67,11 @@ def get_project(project_id: str) -> ProjectSnapshot:
return project
@router.post("/projects/{project_id}/commands", response_model=ProjectSnapshot)
@router.post(
"/projects/{project_id}/commands",
response_model=ProjectSnapshot,
dependencies=[Depends(require_workflow_ready)],
)
def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot:
project = repository.get(project_id)
if project is None:
@@ -74,15 +85,18 @@ def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot
return repository.save(updated)
@router.post("/uploads/presign")
@router.post("/uploads/presign", dependencies=[Depends(require_workflow_ready)])
def create_upload(
request: UploadRequest,
settings: Settings = Depends(get_settings),
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
) -> dict:
safe_name = request.filename.replace("/", "_").replace("\\", "_")
object_key = f"projects/pending/{uuid4()}/{safe_name}"
try:
upload = S3Storage(settings).presign_input_upload(object_key, request.content_type)
upload = S3Storage(runtime_store.merged_values()).presign_input_upload(
object_key,
request.content_type,
)
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
return upload.__dict__
+89
View File
@@ -0,0 +1,89 @@
from fastapi import APIRouter, Depends, HTTPException, status
from app.config import Settings, get_settings
from app.runtime_settings import (
EncryptedSettingsStore,
RuntimeSettingsResponse,
RuntimeSettingsTestRequest,
RuntimeSettingsTestResult,
RuntimeSettingsUpdate,
SecretGenerateRequest,
SecretGenerateResponse,
SettingsReadiness,
generate_secret,
get_runtime_store,
test_runtime_settings,
)
router = APIRouter(prefix="/v1")
@router.get("/settings", response_model=RuntimeSettingsResponse)
def read_settings(
store: EncryptedSettingsStore = Depends(get_runtime_store),
) -> RuntimeSettingsResponse:
try:
return store.public_response()
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
@router.put("/settings", response_model=RuntimeSettingsResponse)
def update_settings(
request: RuntimeSettingsUpdate,
store: EncryptedSettingsStore = Depends(get_runtime_store),
) -> RuntimeSettingsResponse:
try:
store.update(request.values)
return store.public_response()
except RuntimeError as exc:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
@router.post("/settings/test", response_model=RuntimeSettingsTestResult)
async def test_settings(
request: RuntimeSettingsTestRequest,
store: EncryptedSettingsStore = Depends(get_runtime_store),
bootstrap: Settings = Depends(get_settings),
) -> RuntimeSettingsTestResult:
try:
values = store.merged_values(request.values)
result = await test_runtime_settings(request.target, values, bootstrap)
if not request.values:
store.record_test(result, values)
return result
except (KeyError, RuntimeError) as exc:
return RuntimeSettingsTestResult(
target=request.target,
ok=False,
message=f"配置不完整:{exc}",
)
@router.post("/settings/generate-secret", response_model=SecretGenerateResponse)
def create_secret(request: SecretGenerateRequest) -> SecretGenerateResponse:
return SecretGenerateResponse(value=generate_secret(request.kind))
@router.get("/readiness", response_model=SettingsReadiness)
def readiness(
store: EncryptedSettingsStore = Depends(get_runtime_store),
) -> SettingsReadiness:
return store.public_response().readiness
def require_workflow_ready(
store: EncryptedSettingsStore = Depends(get_runtime_store),
) -> None:
current = store.public_response().readiness
if current.ready:
return
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "SETTINGS_INCOMPLETE",
"message": "请先完成系统设置并通过必备连接测试。",
"missing": current.missing,
"untested": current.untested,
},
)
+4 -44
View File
@@ -18,52 +18,12 @@ class Settings(BaseSettings):
api_port: int = 8000
cors_origins: str = "http://localhost:3000"
database_url: str = "postgresql+psycopg://zhuangxiu:zhuangxiu@localhost:5432/zhuangxiu"
runtime_settings_dir: str = ".runtime"
settings_master_key: str = ""
database_url: str = "postgresql://zhuangxiu:zhuangxiu@localhost:5432/zhuangxiu"
redis_url: str = "redis://localhost:6379/0"
s3_endpoint: str = ""
s3_public_endpoint: str = ""
s3_access_key_id: str = ""
s3_secret_access_key: str = ""
s3_region: str = "us-east-1"
s3_bucket_inputs: str = "renovation-inputs"
s3_bucket_derived: str = "renovation-derived"
s3_bucket_renders: str = "renovation-renders"
s3_force_path_style: bool = True
s3_presigned_url_ttl_seconds: int = 900
baidu_ocr_enabled: bool = False
baidu_ocr_api_key: str = ""
baidu_ocr_secret_key: str = ""
baidu_ocr_token_url: str = "https://aip.baidubce.com/oauth/2.0/token"
llm_default_provider: str = "openai"
openai_api_key: str = ""
openai_base_url: str = "https://api.openai.com/v1"
openai_text_model: str = "gpt-5"
openai_image_model: str = "gpt-image-2"
gemini_api_key: str = ""
gemini_model: str = "gemini-3-pro"
ark_api_key: str = ""
seedream_model_endpoint: str = ""
image_provider_priority: str = "seedream,openai,gemini"
gpu_service_url: str = "http://localhost:8100"
gpu_service_token: str = ""
jwt_secret: str = ""
session_secret: str = ""
app_encryption_key: str = ""
webhook_signing_secret: str = ""
langfuse_enabled: bool = False
langfuse_host: str = "http://localhost:3001"
langfuse_public_key: str = ""
langfuse_secret_key: str = ""
sentry_dsn: str = ""
sentry_environment: str = "development"
sentry_traces_sample_rate: float = 0
@property
def cors_origin_list(self) -> list[str]:
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
+16 -22
View File
@@ -1,4 +1,6 @@
from dataclasses import dataclass
from collections.abc import Mapping
from typing import Any
from app.config import Settings
@@ -15,35 +17,27 @@ class ProviderCapability:
class ModelRouter:
def __init__(self, settings: Settings) -> None:
def __init__(self, settings: Settings | Mapping[str, Any]) -> None:
self.settings = settings
def _value(self, key: str, default: str = "") -> str:
if isinstance(self.settings, Mapping):
return str(self.settings.get(key, default) or "")
return str(getattr(self.settings, key, default) or "")
def capabilities(self) -> list[ProviderCapability]:
return [
ProviderCapability(
provider="seedream",
configured=_configured(self.settings.ark_api_key)
and bool(self.settings.seedream_model_endpoint),
strengths=("中文风格指令", "快速方向探索"),
),
ProviderCapability(
provider="openai",
configured=_configured(self.settings.openai_api_key),
strengths=("局部编辑", "多轮一致性", "遮罩修改"),
),
ProviderCapability(
provider="gemini",
configured=_configured(self.settings.gemini_api_key),
strengths=("多参考图理解", "复杂视觉指令"),
provider=self._value("ai_provider", "custom"),
configured=_configured(self._value("ai_api_key"))
and bool(self._value("ai_base_url"))
and bool(self._value("image_model")),
strengths=("统一模型网关", "空间理解", "图像生成与编辑"),
),
]
def choose_image_provider(self, task: str) -> str:
configured = {item.provider for item in self.capabilities() if item.configured}
priorities = [item.strip() for item in self.settings.image_provider_priority.split(",")]
if task == "masked_edit" and "openai" in configured:
return "openai"
for provider in priorities:
if provider in configured:
return provider
configured = [item.provider for item in self.capabilities() if item.configured]
if configured:
return configured[0]
raise RuntimeError("No image provider is configured.")
+14 -7
View File
@@ -1,4 +1,6 @@
import base64
from collections.abc import Mapping
from typing import Any
import httpx
@@ -8,25 +10,30 @@ from app.config import Settings
class BaiduOcrAdapter:
OCR_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic"
def __init__(self, settings: Settings) -> None:
def __init__(self, settings: Settings | Mapping[str, Any]) -> None:
self.settings = settings
def _value(self, key: str, default: str | bool = "") -> str | bool:
if isinstance(self.settings, Mapping):
return self.settings.get(key, default)
return getattr(self.settings, key, default)
@property
def configured(self) -> bool:
return bool(
self.settings.baidu_ocr_enabled
and self.settings.baidu_ocr_api_key
and self.settings.baidu_ocr_secret_key
self._value("baidu_ocr_enabled")
and self._value("baidu_ocr_api_key")
and self._value("baidu_ocr_secret_key")
)
async def _access_token(self) -> str:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.post(
self.settings.baidu_ocr_token_url,
str(self._value("baidu_ocr_token_url", "https://aip.baidubce.com/oauth/2.0/token")),
params={
"grant_type": "client_credentials",
"client_id": self.settings.baidu_ocr_api_key,
"client_secret": self.settings.baidu_ocr_secret_key,
"client_id": self._value("baidu_ocr_api_key"),
"client_secret": self._value("baidu_ocr_secret_key"),
},
)
response.raise_for_status()
@@ -0,0 +1,78 @@
from collections.abc import Mapping
from typing import Any
from urllib.parse import urljoin
import httpx
class OpenAICompatibleGateway:
"""One adapter for OpenAI, lk666.ai and other OpenAI-compatible gateways."""
def __init__(self, values: Mapping[str, Any]) -> None:
self.values = values
def _url(self, path_key: str, fallback: str) -> str:
base_url = str(self.values.get("ai_base_url", "")).rstrip("/") + "/"
path = str(self.values.get(path_key, fallback)).lstrip("/")
return urljoin(base_url, path)
@property
def headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self.values.get('ai_api_key', '')}",
"Content-Type": "application/json",
}
async def list_models(self) -> list[str]:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(self._url("ai_models_path", "/models"), headers=self.headers)
response.raise_for_status()
payload = response.json()
return [item["id"] for item in payload.get("data", []) if isinstance(item, dict) and item.get("id")]
async def chat(self, messages: list[dict[str, Any]], *, vision: bool = False) -> dict[str, Any]:
model_key = "vision_model" if vision else "orchestrator_model"
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
self._url("ai_chat_path", "/chat/completions"),
headers=self.headers,
json={"model": self.values[model_key], "messages": messages},
)
response.raise_for_status()
return response.json()
async def generate_image(self, prompt: str, **options: Any) -> dict[str, Any]:
payload = {"model": self.values["image_model"], "prompt": prompt, **options}
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
self._url("ai_image_generation_path", "/images/generations"),
headers=self.headers,
json=payload,
)
response.raise_for_status()
return response.json()
async def edit_image(
self,
prompt: str,
image: bytes,
*,
filename: str = "image.png",
mask: bytes | None = None,
**options: Any,
) -> dict[str, Any]:
files: dict[str, tuple[str, bytes, str]] = {
"image": (filename, image, "image/png"),
}
if mask is not None:
files["mask"] = ("mask.png", mask, "image/png")
data = {"model": self.values["image_model"], "prompt": prompt, **options}
headers = {"Authorization": self.headers["Authorization"]}
async with httpx.AsyncClient(timeout=180) as client:
response = await client.post(
self._url("ai_image_edit_path", "/images/edits"),
headers=headers,
data=data,
files=files,
)
response.raise_for_status()
return response.json()
+21 -14
View File
@@ -1,4 +1,6 @@
from dataclasses import dataclass
from collections.abc import Mapping
from typing import Any
import boto3
from botocore.client import Config
@@ -15,33 +17,38 @@ class PresignedUpload:
class S3Storage:
def __init__(self, settings: Settings) -> None:
def __init__(self, settings: Settings | Mapping[str, Any]) -> None:
self.settings = settings
def _value(self, key: str, default: Any = "") -> Any:
if isinstance(self.settings, Mapping):
return self.settings.get(key, default)
return getattr(self.settings, key, default)
@property
def configured(self) -> bool:
values = (
self.settings.s3_endpoint,
self.settings.s3_access_key_id,
self.settings.s3_secret_access_key,
self._value("s3_endpoint"),
self._value("s3_access_key_id"),
self._value("s3_secret_access_key"),
)
return all(values) and not any(value.startswith("replace_with_") for value in values)
def _client(self, public: bool = False):
endpoint = (
self.settings.s3_public_endpoint
if public and self.settings.s3_public_endpoint
else self.settings.s3_endpoint
self._value("s3_public_endpoint")
if public and self._value("s3_public_endpoint")
else self._value("s3_endpoint")
)
return boto3.client(
"s3",
endpoint_url=endpoint,
aws_access_key_id=self.settings.s3_access_key_id,
aws_secret_access_key=self.settings.s3_secret_access_key,
region_name=self.settings.s3_region,
aws_access_key_id=self._value("s3_access_key_id"),
aws_secret_access_key=self._value("s3_secret_access_key"),
region_name=self._value("s3_region", "us-east-1"),
config=Config(
signature_version="s3v4",
s3={"addressing_style": "path" if self.settings.s3_force_path_style else "auto"},
s3={"addressing_style": "path" if self._value("s3_force_path_style", True) else "auto"},
),
)
@@ -51,15 +58,15 @@ class S3Storage:
url = self._client(public=True).generate_presigned_url(
"put_object",
Params={
"Bucket": self.settings.s3_bucket_inputs,
"Bucket": self._value("s3_bucket_inputs"),
"Key": object_key,
"ContentType": content_type,
},
ExpiresIn=self.settings.s3_presigned_url_ttl_seconds,
ExpiresIn=int(self._value("s3_presigned_url_ttl_seconds", 900)),
)
return PresignedUpload(
method="PUT",
url=url,
object_key=object_key,
expires_in=self.settings.s3_presigned_url_ttl_seconds,
expires_in=int(self._value("s3_presigned_url_ttl_seconds", 900)),
)
+2
View File
@@ -2,6 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import router
from app.api.settings import router as settings_router
from app.config import get_settings
settings = get_settings()
@@ -20,6 +21,7 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(router)
app.include_router(settings_router)
@app.get("/")
+500
View File
@@ -0,0 +1,500 @@
import base64
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",
"ai_models",
"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)
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",
],
"AI 模型": [
"ai_provider",
"ai_base_url",
"ai_api_key",
"orchestrator_model",
"vision_model",
"image_model",
],
}
REQUIRED_TESTS = {
"基础运行": "infrastructure",
"文件存储": "storage",
"AI 模型": "ai_models",
}
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_")},
"ai_models": {key for key in FIELDS if key.startswith("ai_")} | {
"orchestrator_model",
"vision_model",
"image_model",
},
"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]:
return {
key: field.default
for key, field in FIELDS.items()
if key in EDITABLE_FIELDS and field.default is not None
}
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", {})
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 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 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)
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,
"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}
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),
)
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
return SettingsReadiness(
ready=not missing and not untested,
completed_required=completed,
total_required=len(REQUIRED_GROUPS),
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():
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,
}
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 == "ai_models":
return await _test_ai_models(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_ai_models(values: dict[str, Any]) -> RuntimeSettingsTestResult:
url = _join_api_url(values["ai_base_url"], values.get("ai_models_path", "/models"))
async with httpx.AsyncClient(timeout=12) as client:
response = await client.get(url, headers={"Authorization": f"Bearer {values['ai_api_key']}"})
response.raise_for_status()
payload = response.json()
model_ids = {
item.get("id")
for item in payload.get("data", [])
if isinstance(item, dict) and item.get("id")
}
selected = [values.get("orchestrator_model"), values.get("vision_model"), values.get("image_model")]
missing = [model for model in selected if model and model_ids and model not in model_ids]
return RuntimeSettingsTestResult(
target="ai_models",
ok=not missing,
message="API Key 有效,三个模型均可用。" if not missing else "API 可以连接,但部分模型名不在账号模型列表中。",
details={"missing_models": missing, "model_count": len(model_ids)},
)
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 服务返回异常。",
)
+404
View File
@@ -0,0 +1,404 @@
from typing import Any, Literal
from pydantic import BaseModel, Field
FieldKind = Literal["text", "password", "url", "select", "combobox", "toggle", "number", "status"]
class SettingOption(BaseModel):
value: str
label: str
help: str
class SettingField(BaseModel):
key: str
label: str
description: str
kind: FieldKind
required: bool = False
secret: bool = False
default: Any = None
placeholder: str = ""
options: list[SettingOption] = Field(default_factory=list)
generator: Literal["hex24", "hex32", "base64_32"] | None = None
advanced: bool = False
visible_when: dict[str, Any] | None = None
class SettingCategory(BaseModel):
id: str
label: str
description: str
test_targets: list[str] = Field(default_factory=list)
required_for_workflow: bool = False
fields: list[SettingField]
def option(value: str, label: str, help_text: str) -> SettingOption:
return SettingOption(value=value, label=label, help=help_text)
CATEGORIES = [
SettingCategory(
id="deployment",
label="基础运行",
description="由安装器自动管理,通常不需要手动填写。修改数据库密码需要重启服务。",
test_targets=["infrastructure"],
required_for_workflow=True,
fields=[
SettingField(
key="database_status",
label="PostgreSQL",
description="保存项目、版本、任务和结构化设计状态。密码由启动脚本自动生成。",
kind="status",
required=True,
),
SettingField(
key="redis_status",
label="Redis",
description="用于任务队列、缓存和并发锁。密码由启动脚本自动生成。",
kind="status",
required=True,
),
SettingField(
key="encryption_status",
label="配置加密",
description="模型 API Key 会加密保存;主密钥首次启动时自动生成,不显示在页面中。",
kind="status",
required=True,
),
],
),
SettingCategory(
id="storage",
label="文件存储",
description="保存原始户型、清洗图、3D 白模和效果图。你的 NAS MinIO 可以直接使用。",
test_targets=["storage"],
required_for_workflow=True,
fields=[
SettingField(
key="s3_endpoint",
label="MinIO 内部地址",
description="后端访问 NAS 的 S3 API 地址,不是 MinIO 控制台地址。",
kind="url",
required=True,
placeholder="http://192.168.200.36:9000",
),
SettingField(
key="s3_public_endpoint",
label="浏览器可访问地址",
description="上传和预览文件时使用;纯内网部署通常与内部地址相同。",
kind="url",
required=True,
placeholder="http://192.168.200.36:9000",
),
SettingField(
key="s3_access_key_id",
label="服务账号 Access Key",
description="建议在 MinIO 中单独创建服务账号,不要使用 Root 账号。",
kind="password",
required=True,
secret=True,
),
SettingField(
key="s3_secret_access_key",
label="服务账号 Secret Key",
description="仅加密保存于后端,不会返回到浏览器。",
kind="password",
required=True,
secret=True,
),
SettingField(
key="s3_bucket_inputs",
label="原始文件空间",
description="保存用户上传的 PDF、DXF 和图片;测试会提示是否缺失。",
kind="text",
required=True,
default="renovation-inputs",
),
SettingField(
key="s3_bucket_derived",
label="中间结果空间",
description="保存 SVG、控制图、蒙版和 GLB 白模。",
kind="text",
required=True,
default="renovation-derived",
),
SettingField(
key="s3_bucket_renders",
label="效果图空间",
description="保存方向图、效果图和局部修改版本。",
kind="text",
required=True,
default="renovation-renders",
),
SettingField(
key="s3_force_path_style",
label="MinIO 兼容模式",
description="MinIO 通常保持开启;AWS S3 可以关闭。",
kind="toggle",
default=True,
advanced=True,
),
],
),
SettingCategory(
id="document",
label="图纸识别",
description="矢量 PDF 优先直接解析;只有扫描图或文字层损坏时才调用 OCR。",
test_targets=["baidu_ocr"],
fields=[
SettingField(
key="baidu_ocr_enabled",
label="启用百度 OCR",
description="开启后,扫描户型图会使用百度 OCR 识别尺寸和文字。",
kind="toggle",
default=False,
),
SettingField(
key="baidu_ocr_api_key",
label="百度 OCR API Key",
description="来自百度智能云 OCR 应用。",
kind="password",
required=True,
secret=True,
visible_when={"baidu_ocr_enabled": True},
),
SettingField(
key="baidu_ocr_secret_key",
label="百度 OCR Secret Key",
description="只用于换取访问令牌,不会返回前端。",
kind="password",
required=True,
secret=True,
visible_when={"baidu_ocr_enabled": True},
),
],
),
SettingCategory(
id="models",
label="AI 模型",
description="统一配置总调度、空间理解和生图模型;同一个聚合 API 可以同时承担三类能力。",
test_targets=["ai_models"],
required_for_workflow=True,
fields=[
SettingField(
key="ai_provider",
label="API 来源",
description="选择自然语意预设;自建或其他聚合服务选择兼容接口。",
kind="select",
required=True,
default="lingke",
options=[
option("lingke", "聚合引擎 AIGC", "你提供的 lk666.ai 聚合服务,模型名以其控制台为准。"),
option("openai", "OpenAI 官方", "直接使用 OpenAI 官方 API。"),
option("custom", "其他兼容接口", "支持 OpenAI 请求格式的代理、自建或聚合服务。"),
],
),
SettingField(
key="ai_base_url",
label="API Base URL",
description="填写到 /v1 层级;聚合引擎的准确地址请从登录后的开发者文档复制。",
kind="url",
required=True,
placeholder="https://example.com/v1",
),
SettingField(
key="ai_api_key",
label="API Key",
description="同一聚合账号可供总调度、多模态和生图使用。",
kind="password",
required=True,
secret=True,
),
SettingField(
key="orchestrator_model",
label="总调度模型",
description="负责追问、拆解任务、维护 Plan / Scene / Style 状态。",
kind="combobox",
required=True,
placeholder="选择预设或输入平台模型 ID",
options=[
option("gpt-5.6-terra", "GPT-5.6 Terra", "速度与规划能力均衡,适合日常工作流。"),
option("gpt-5.6-sol", "GPT-5.6 Sol", "更强推理,适合复杂户型和高价值方案。"),
option("gpt-5", "GPT-5", "通用调度预设,具体可用性取决于账号。"),
],
),
SettingField(
key="vision_model",
label="空间理解模型",
description="读取户型、参考图和渲染结果,判断空间关系与审美一致性。",
kind="combobox",
required=True,
placeholder="选择预设或输入多模态模型 ID",
options=[
option("gpt-5.6-sol", "GPT-5.6 Sol", "优先空间推理和复杂视觉评审。"),
option("gemini-3-pro", "Gemini 3 Pro", "长上下文与多模态理解预设。"),
option("gpt-5", "GPT-5", "通用多模态预设。"),
],
),
SettingField(
key="image_model",
label="默认生图模型",
description="先使用你指定的 gpt-image-2;后续可以增加按任务自动路由。",
kind="combobox",
required=True,
default="gpt-image-2",
options=[
option("gpt-image-2", "GPT Image 2", "默认室内方向图与局部编辑模型。"),
option("seedream-5.0", "Seedream 5.0", "适合高质量中文场景生成,模型 ID 以平台为准。"),
option("nano-banana-pro", "Nano Banana Pro", "适合参考图编辑,模型 ID 以平台为准。"),
],
),
SettingField(
key="ai_models_path",
label="模型列表路径",
description="测试按钮使用。OpenAI 兼容接口通常为 /models。",
kind="text",
default="/models",
advanced=True,
),
SettingField(
key="ai_chat_path",
label="对话路径",
description="OpenAI 兼容接口通常为 /chat/completions。",
kind="text",
default="/chat/completions",
advanced=True,
),
SettingField(
key="ai_image_generation_path",
label="生图路径",
description="OpenAI 兼容接口通常为 /images/generations。",
kind="text",
default="/images/generations",
advanced=True,
),
SettingField(
key="ai_image_edit_path",
label="图片编辑路径",
description="OpenAI 兼容接口通常为 /images/edits。",
kind="text",
default="/images/edits",
advanced=True,
),
],
),
SettingCategory(
id="gpu",
label="本地算力",
description="GPU 主要用于户型分割、深度估计和本地视觉模型,不是调用云端生图 API 的必需项。",
test_targets=["gpu"],
fields=[
SettingField(
key="gpu_mode",
label="运行方式",
description="同机 Worker 会自动使用本机地址;只有独立 GPU 服务器才需要手填地址。",
kind="select",
default="disabled",
options=[
option("disabled", "暂不使用本地 GPU", "全部使用云端 API,最容易部署。"),
option("local", "本机 GPU Worker", "API 通过本机 Worker 调用显卡,页面无需填写地址。"),
option("remote", "局域网 GPU 服务器", "GPU 在另一台机器时填写内网地址。"),
],
),
SettingField(
key="gpu_service_url",
label="GPU 服务地址",
description="仅远程模式需要,例如 http://192.168.200.50:8100。",
kind="url",
required=True,
placeholder="http://192.168.200.50:8100",
visible_when={"gpu_mode": "remote"},
),
SettingField(
key="gpu_service_token",
label="内部访问令牌",
description="用于阻止其他内网设备随意调用 GPU;可以一键生成。",
kind="password",
required=True,
secret=True,
generator="hex32",
visible_when={"gpu_mode__not": "disabled"},
),
],
),
SettingCategory(
id="observability",
label="监控与追踪",
description="均为可选项。Langfuse 用于评估模型链路,Sentry 用于发现程序异常。",
test_targets=["langfuse", "sentry"],
fields=[
SettingField(
key="langfuse_enabled",
label="启用 Langfuse",
description="记录每次 Agent 调用、模型耗时、费用与评测结果。",
kind="toggle",
default=False,
),
SettingField(
key="langfuse_host",
label="Langfuse 地址",
description="支持自部署地址,例如 http://192.168.200.20:3001。",
kind="url",
required=True,
visible_when={"langfuse_enabled": True},
),
SettingField(
key="langfuse_public_key",
label="Langfuse Public Key",
description="从 Langfuse 项目设置中复制。",
kind="password",
required=True,
secret=True,
visible_when={"langfuse_enabled": True},
),
SettingField(
key="langfuse_secret_key",
label="Langfuse Secret Key",
description="只保存在后端。",
kind="password",
required=True,
secret=True,
visible_when={"langfuse_enabled": True},
),
SettingField(
key="sentry_enabled",
label="启用 Sentry",
description="收集异常和性能问题,支持自部署。",
kind="toggle",
default=False,
),
SettingField(
key="sentry_dsn",
label="Sentry DSN",
description="从 Sentry 项目的 Client Keys 页面复制。",
kind="password",
required=True,
secret=True,
visible_when={"sentry_enabled": True},
),
SettingField(
key="sentry_traces_sample_rate",
label="性能采样率",
description="0 表示关闭,0.1 表示记录约 10% 的请求。",
kind="select",
default="0",
visible_when={"sentry_enabled": True},
options=[
option("0", "仅错误,不采集性能", "最省资源,适合初期。"),
option("0.05", "采样 5%", "适合请求量较大的生产环境。"),
option("0.1", "采样 10%", "排障信息和资源消耗较均衡。"),
option("1", "全部采样", "仅建议短期排障使用。"),
],
),
],
),
]
FIELDS = {field.key: field for category in CATEGORIES for field in category.fields}
SECRET_FIELDS = {key for key, field in FIELDS.items() if field.secret}
EDITABLE_FIELDS = {key for key, field in FIELDS.items() if field.kind != "status"}
+3
View File
@@ -9,11 +9,14 @@ description = "Workflow orchestrator for the AI interior style studio"
requires-python = ">=3.11"
dependencies = [
"boto3>=1.35,<2",
"cryptography>=44,<47",
"fastapi>=0.115,<1",
"httpx>=0.28,<1",
"psycopg[binary]>=3.2,<4",
"pydantic>=2.10,<3",
"pydantic-settings>=2.7,<3",
"python-multipart>=0.0.20,<1",
"redis>=5.2,<7",
"uvicorn[standard]>=0.34,<1"
]
+37 -1
View File
@@ -1,11 +1,23 @@
import httpx
import pytest
from pathlib import Path
from app.config import Settings
from app.main import app
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
@pytest.fixture
def transport() -> httpx.ASGITransport:
def transport(tmp_path: Path) -> httpx.ASGITransport:
store = EncryptedSettingsStore(
Settings(
_env_file=None,
runtime_settings_dir=str(tmp_path),
database_url="postgresql://user:secret@postgres:5432/app",
redis_url="redis://:secret@redis:6379/0",
)
)
app.dependency_overrides[get_runtime_store] = lambda: store
return httpx.ASGITransport(app=app)
@@ -25,3 +37,27 @@ async def test_demo_project_contract(transport: httpx.ASGITransport) -> None:
body = response.json()
assert body["stage"] == "plan_review"
assert body["plan"]["cad_layer_count"] == 43
@pytest.mark.asyncio
async def test_settings_contract_never_returns_secret_values(transport: httpx.ASGITransport) -> None:
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/v1/settings")
assert response.status_code == 200
body = response.json()
assert len(body["categories"]) == 6
assert "ai_api_key" not in body["values"]
assert body["readiness"]["ready"] is False
@pytest.mark.asyncio
async def test_workflow_command_is_blocked_until_required_settings_are_ready(
transport: httpx.ASGITransport,
) -> None:
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/v1/projects/demo-apartment/commands",
json={"command": "confirm_plan", "expected_revision": 3, "payload": {}},
)
assert response.status_code == 503
assert response.json()["detail"]["code"] == "SETTINGS_INCOMPLETE"
+69
View File
@@ -0,0 +1,69 @@
from pathlib import Path
from app.config import Settings
from app.runtime_settings import (
EncryptedSettingsStore,
RuntimeSettingsTestResult,
generate_secret,
)
def create_store(path: Path) -> EncryptedSettingsStore:
settings = Settings(
_env_file=None,
runtime_settings_dir=str(path),
database_url="postgresql://user:secret@postgres:5432/app",
redis_url="redis://:secret@redis:6379/0",
)
return EncryptedSettingsStore(settings)
def test_runtime_secrets_are_encrypted_and_never_returned(tmp_path: Path) -> None:
store = create_store(tmp_path)
store.update(
{
"s3_endpoint": "http://minio:9000",
"s3_public_endpoint": "http://localhost:9000",
"s3_access_key_id": "service-account",
"s3_secret_access_key": "very-private-secret",
}
)
response = store.public_response()
assert "s3_secret_access_key" not in response.values
assert response.configured["s3_secret_access_key"] is True
assert b"very-private-secret" not in store.data_path.read_bytes()
def test_readiness_requires_configuration_and_successful_tests(tmp_path: Path) -> None:
store = create_store(tmp_path)
store.update(
{
"s3_endpoint": "http://minio:9000",
"s3_public_endpoint": "http://localhost:9000",
"s3_access_key_id": "access",
"s3_secret_access_key": "secret",
"ai_provider": "lingke",
"ai_base_url": "https://example.com/v1",
"ai_api_key": "api-secret",
"orchestrator_model": "planner",
"vision_model": "vision",
"image_model": "gpt-image-2",
}
)
assert store.public_response().readiness.ready is False
for target in ("infrastructure", "storage", "ai_models"):
store.record_test(RuntimeSettingsTestResult(target=target, ok=True, message="ok"))
assert store.public_response().readiness.ready is True
store.update({"image_model": "another-image-model"})
assert store.public_response().readiness.ready is False
assert store.public_response().tests.get("ai_models") is None
def test_secret_generators_use_expected_lengths() -> None:
assert len(generate_secret("hex24")) == 48
assert len(generate_secret("hex32")) == 64
assert len(generate_secret("base64_32")) == 44