682 lines
30 KiB
Python
682 lines
30 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import re
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, Mapping, Optional
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.config import settings
|
|
from app.models.model_setting import ModelSetting
|
|
|
|
MASKED_SECRET = "__KEEP_EXISTING_SECRET__"
|
|
SUPPORTED_PROVIDER_TYPES = {"openai", "anthropic"}
|
|
SUPPORTED_CONFIG_FORMATS = {"openai_toml", "anthropic_env_json"}
|
|
OPENAI_SECRET_FIELDS = {"api_key", "auth_token", "OPENAI_API_KEY", "SHARED_AI_OPENAI_API_KEY"}
|
|
ANTHROPIC_SECRET_FIELDS = {"ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"}
|
|
SHARED_AI_PLATFORM_SECRET_FIELDS = {
|
|
"SHARED_AI_PLATFORM_API_KEY",
|
|
"shared_ai_platform_api_key",
|
|
"api_key_encrypted",
|
|
}
|
|
SHARED_AI_PLATFORM_CONFIG_KEYS = {
|
|
"base_url",
|
|
"api_key",
|
|
"api_key_encrypted",
|
|
"api_prefix",
|
|
"app_id",
|
|
"timeout_seconds",
|
|
"provider",
|
|
"legal_review_enabled",
|
|
"legal_review_fallback_to_local",
|
|
"document_extraction_enabled",
|
|
}
|
|
|
|
|
|
class ModelSettingService:
|
|
def __init__(self, db: Session):
|
|
self.db = db
|
|
|
|
def get_current_setting(self, current_user: Dict[str, Any]) -> dict:
|
|
self._require_admin(current_user)
|
|
setting = self._get_active_setting(current_user["tenant_id"])
|
|
if setting is None:
|
|
return self._empty_setting(current_user["tenant_id"])
|
|
return self._to_item(setting, include_raw=True)
|
|
|
|
def upsert_current_setting(
|
|
self,
|
|
current_user: Dict[str, Any],
|
|
*,
|
|
config_format: str,
|
|
raw_config: str,
|
|
display_name: Optional[str] = None,
|
|
enabled: bool = True,
|
|
auth_token: Optional[str] = None,
|
|
) -> dict:
|
|
self._require_admin(current_user)
|
|
normalized_format = self._normalize_config_format(config_format)
|
|
normalized_raw = (raw_config or "").strip()
|
|
if not normalized_raw:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="raw_config is required")
|
|
|
|
parsed = self._parse_config(normalized_format, normalized_raw)
|
|
provider_type = parsed["provider_type"]
|
|
token = self._resolve_token(parsed, auth_token)
|
|
|
|
existing = self._get_active_setting(current_user["tenant_id"])
|
|
if token == MASKED_SECRET:
|
|
token = self._decrypt_secret(existing.auth_token_encrypted) if existing and existing.auth_token_encrypted else ""
|
|
elif not token and existing and existing.auth_token_encrypted:
|
|
token = self._decrypt_secret(existing.auth_token_encrypted)
|
|
encrypted_token = self._encrypt_secret(token) if token else None
|
|
config_json = self._prepare_config_json_for_storage(parsed["config_json"], existing)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
if existing is None:
|
|
setting = ModelSetting(
|
|
id=f"model-setting-{uuid4().hex[:12]}",
|
|
tenant_id=current_user["tenant_id"],
|
|
created_by=current_user.get("id"),
|
|
created_at=now,
|
|
updated_at=now,
|
|
config_format=normalized_format,
|
|
provider_type=provider_type,
|
|
display_name=self._normalize_display_name(display_name, provider_type),
|
|
)
|
|
else:
|
|
setting = existing
|
|
|
|
setting.provider_type = provider_type
|
|
setting.display_name = self._normalize_display_name(display_name, provider_type)
|
|
setting.enabled = bool(enabled)
|
|
setting.config_format = normalized_format
|
|
setting.model_provider = parsed.get("model_provider")
|
|
setting.model = parsed.get("model")
|
|
setting.review_model = parsed.get("review_model")
|
|
setting.base_url = parsed.get("base_url")
|
|
setting.wire_api = parsed.get("wire_api")
|
|
setting.reasoning_effort = parsed.get("reasoning_effort")
|
|
setting.network_access = parsed.get("network_access")
|
|
setting.auth_token_encrypted = encrypted_token
|
|
setting.config_json = config_json
|
|
setting.raw_config = self._mask_raw_config(normalized_format, normalized_raw)
|
|
setting.updated_by = current_user.get("id")
|
|
setting.updated_at = now
|
|
|
|
self.db.add(setting)
|
|
self.db.commit()
|
|
self.db.refresh(setting)
|
|
return self._to_item(setting, include_raw=True)
|
|
|
|
def build_runtime_export(self, current_user: Dict[str, Any]) -> dict:
|
|
self._require_admin(current_user)
|
|
setting = self._get_active_setting(current_user["tenant_id"])
|
|
if setting is None:
|
|
return {
|
|
"configured": False,
|
|
"provider_type": None,
|
|
"env": {},
|
|
"config": None,
|
|
"shared_ai_platform": self._empty_shared_ai_platform_summary(),
|
|
}
|
|
token = self._decrypt_secret(setting.auth_token_encrypted)
|
|
if setting.provider_type == "anthropic":
|
|
env = self._build_anthropic_env(setting, token)
|
|
env.update(self._build_shared_ai_platform_env(setting))
|
|
return {
|
|
"configured": bool(setting.enabled and env.get("ANTHROPIC_BASE_URL") and env.get("ANTHROPIC_AUTH_TOKEN")),
|
|
"provider_type": "anthropic",
|
|
"env": env,
|
|
"config": None,
|
|
"shared_ai_platform": self.get_shared_ai_platform_runtime_config(setting.tenant_id),
|
|
}
|
|
env = self._build_openai_shared_ai_env(setting, token)
|
|
return {
|
|
"configured": bool(setting.enabled and env.get("SHARED_AI_OPENAI_BASE_URL") and env.get("SHARED_AI_OPENAI_API_KEY")),
|
|
"provider_type": "openai",
|
|
"env": env,
|
|
"config": self._build_openai_codex_config(setting, token),
|
|
"shared_ai_platform": self.get_shared_ai_platform_runtime_config(setting.tenant_id),
|
|
}
|
|
|
|
def get_shared_ai_platform_runtime_config(self, tenant_id: str) -> dict:
|
|
setting = self._get_active_setting(tenant_id)
|
|
if setting is None or not setting.enabled:
|
|
return self._empty_shared_ai_platform_summary()
|
|
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
|
|
configured = bool(config.get("base_url") and config.get("api_key"))
|
|
return {
|
|
"configured": configured,
|
|
"source": "model_settings" if configured else None,
|
|
"base_url": config.get("base_url"),
|
|
"api_prefix": config.get("api_prefix") or settings.shared_ai_platform_api_prefix,
|
|
"app_id": config.get("app_id") or settings.shared_ai_platform_app_id,
|
|
"api_key": config.get("api_key") or "",
|
|
"has_api_key": bool(config.get("api_key")),
|
|
"api_key_preview": self._preview_secret(str(config.get("api_key") or "")),
|
|
"timeout_seconds": self._coerce_float(
|
|
config.get("timeout_seconds"),
|
|
settings.shared_ai_platform_timeout_seconds,
|
|
),
|
|
"provider": config.get("provider") or setting.provider_type,
|
|
"model_provider": setting.model_provider,
|
|
"model": setting.model,
|
|
"review_model": setting.review_model or setting.model,
|
|
"legal_review_enabled": self._coerce_bool(
|
|
config.get("legal_review_enabled"),
|
|
settings.shared_ai_platform_legal_review_enabled,
|
|
),
|
|
"legal_review_fallback_to_local": self._coerce_bool(
|
|
config.get("legal_review_fallback_to_local"),
|
|
settings.shared_ai_platform_legal_review_fallback_to_local,
|
|
),
|
|
"document_extraction_enabled": self._coerce_bool(
|
|
config.get("document_extraction_enabled"),
|
|
settings.shared_ai_platform_document_extraction_enabled,
|
|
),
|
|
}
|
|
|
|
def has_any_shared_ai_platform_runtime_config(self) -> bool:
|
|
for setting in self.db.query(ModelSetting).filter(ModelSetting.enabled.is_(True)).all():
|
|
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
|
|
if config.get("base_url") and config.get("api_key"):
|
|
return True
|
|
return False
|
|
|
|
def _get_active_setting(self, tenant_id: str) -> Optional[ModelSetting]:
|
|
return (
|
|
self.db.query(ModelSetting)
|
|
.filter(ModelSetting.tenant_id == tenant_id)
|
|
.order_by(ModelSetting.updated_at.desc())
|
|
.first()
|
|
)
|
|
|
|
@staticmethod
|
|
def _require_admin(current_user: Dict[str, Any]) -> None:
|
|
if current_user.get("role") != "admin":
|
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="forbidden")
|
|
|
|
@staticmethod
|
|
def _normalize_config_format(value: str) -> str:
|
|
normalized = (value or "").strip().lower()
|
|
if normalized not in SUPPORTED_CONFIG_FORMATS:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid config_format")
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _normalize_display_name(value: Optional[str], provider_type: str) -> str:
|
|
normalized = (value or "").strip()
|
|
if normalized:
|
|
return normalized[:100]
|
|
return "OpenAI 兼容模型" if provider_type == "openai" else "Anthropic/Claude 模型"
|
|
|
|
def _parse_config(self, config_format: str, raw_config: str) -> dict:
|
|
if config_format == "openai_toml":
|
|
return self._parse_openai_toml(raw_config)
|
|
if config_format == "anthropic_env_json":
|
|
return self._parse_anthropic_env_json(raw_config)
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="unsupported config_format")
|
|
|
|
def _parse_openai_toml(self, raw_config: str) -> dict:
|
|
data = self._parse_simple_toml(raw_config)
|
|
provider_name = str(data.get("model_provider") or "OpenAI").strip()
|
|
provider_section = data.get(f"model_providers.{provider_name}") or data.get("model_providers.OpenAI") or {}
|
|
if not isinstance(provider_section, Mapping):
|
|
provider_section = {}
|
|
model = self._required_str(data.get("model"), "model")
|
|
base_url = self._required_str(provider_section.get("base_url"), "model_providers.OpenAI.base_url")
|
|
token = self._optional_str(data.get("api_key") or data.get("auth_token") or data.get("OPENAI_API_KEY"))
|
|
config_json = {
|
|
"model_provider": provider_name,
|
|
"model": model,
|
|
"review_model": self._optional_str(data.get("review_model")) or model,
|
|
"model_reasoning_effort": self._optional_str(data.get("model_reasoning_effort")),
|
|
"disable_response_storage": bool(data.get("disable_response_storage", False)),
|
|
"network_access": self._optional_str(data.get("network_access")),
|
|
"windows_wsl_setup_acknowledged": bool(data.get("windows_wsl_setup_acknowledged", False)),
|
|
"model_providers": {
|
|
provider_name: dict(provider_section),
|
|
},
|
|
"features": data.get("features") if isinstance(data.get("features"), Mapping) else {},
|
|
"shared_ai_platform": self._parse_shared_ai_platform_config(
|
|
data.get("shared_ai_platform") if isinstance(data.get("shared_ai_platform"), Mapping) else {},
|
|
fallback=data,
|
|
),
|
|
}
|
|
return {
|
|
"provider_type": "openai",
|
|
"model_provider": provider_name,
|
|
"model": model,
|
|
"review_model": config_json["review_model"],
|
|
"base_url": base_url,
|
|
"wire_api": self._optional_str(provider_section.get("wire_api")),
|
|
"reasoning_effort": config_json["model_reasoning_effort"],
|
|
"network_access": config_json["network_access"],
|
|
"token": token,
|
|
"config_json": config_json,
|
|
}
|
|
|
|
def _parse_anthropic_env_json(self, raw_config: str) -> dict:
|
|
try:
|
|
payload = json.loads(raw_config)
|
|
except json.JSONDecodeError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="config must be valid JSON") from exc
|
|
if not isinstance(payload, Mapping):
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="config JSON must be an object")
|
|
env = payload.get("env")
|
|
if not isinstance(env, Mapping):
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="env must be an object")
|
|
base_url = self._required_str(env.get("ANTHROPIC_BASE_URL"), "ANTHROPIC_BASE_URL")
|
|
token = self._optional_str(env.get("ANTHROPIC_AUTH_TOKEN") or env.get("ANTHROPIC_API_KEY"))
|
|
config_json = {
|
|
"env": {key: str(value) for key, value in env.items() if value is not None},
|
|
"shared_ai_platform": self._parse_shared_ai_platform_config(
|
|
payload.get("shared_ai_platform") if isinstance(payload.get("shared_ai_platform"), Mapping) else {},
|
|
fallback=env,
|
|
),
|
|
}
|
|
return {
|
|
"provider_type": "anthropic",
|
|
"model_provider": "Anthropic",
|
|
"model": self._optional_str(env.get("ANTHROPIC_MODEL") or payload.get("model")),
|
|
"review_model": self._optional_str(env.get("ANTHROPIC_REVIEW_MODEL") or payload.get("review_model")),
|
|
"base_url": base_url,
|
|
"wire_api": "anthropic",
|
|
"reasoning_effort": None,
|
|
"network_access": None,
|
|
"token": token,
|
|
"config_json": config_json,
|
|
}
|
|
|
|
def _parse_simple_toml(self, raw_config: str) -> dict:
|
|
root: dict[str, Any] = {}
|
|
current: dict[str, Any] = root
|
|
for raw_line in raw_config.splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("[") and line.endswith("]"):
|
|
section = line[1:-1].strip()
|
|
if not section:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid section name")
|
|
current = root
|
|
parts = section.split(".")
|
|
for part in parts:
|
|
current = current.setdefault(part, {})
|
|
root[section] = current
|
|
continue
|
|
if "=" not in line:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"invalid config line: {line[:80]}")
|
|
key, value = line.split("=", 1)
|
|
current[key.strip()] = self._parse_toml_value(value.strip())
|
|
return root
|
|
|
|
@staticmethod
|
|
def _parse_toml_value(value: str) -> Any:
|
|
normalized = value.strip()
|
|
if normalized.lower() == "true":
|
|
return True
|
|
if normalized.lower() == "false":
|
|
return False
|
|
if (normalized.startswith('"') and normalized.endswith('"')) or (normalized.startswith("'") and normalized.endswith("'")):
|
|
return normalized[1:-1]
|
|
if re.fullmatch(r"-?\d+", normalized):
|
|
return int(normalized)
|
|
if re.fullmatch(r"-?\d+\.\d+", normalized):
|
|
return float(normalized)
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _required_str(value: Any, field_name: str) -> str:
|
|
normalized = str(value or "").strip()
|
|
if not normalized:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} is required")
|
|
return normalized
|
|
|
|
@staticmethod
|
|
def _optional_str(value: Any) -> Optional[str]:
|
|
normalized = str(value or "").strip()
|
|
return normalized or None
|
|
|
|
@staticmethod
|
|
def _resolve_token(parsed: Mapping[str, Any], auth_token: Optional[str]) -> str:
|
|
explicit_token = (auth_token or "").strip()
|
|
if explicit_token:
|
|
return explicit_token
|
|
token = str(parsed.get("token") or "").strip()
|
|
return token
|
|
|
|
def _to_item(self, setting: ModelSetting, *, include_raw: bool = False) -> dict:
|
|
token = self._decrypt_secret(setting.auth_token_encrypted)
|
|
item = {
|
|
"id": setting.id,
|
|
"tenant_id": setting.tenant_id,
|
|
"provider_type": setting.provider_type,
|
|
"display_name": setting.display_name,
|
|
"enabled": setting.enabled,
|
|
"config_format": setting.config_format,
|
|
"model_provider": setting.model_provider,
|
|
"model": setting.model,
|
|
"review_model": setting.review_model,
|
|
"base_url": setting.base_url,
|
|
"wire_api": setting.wire_api,
|
|
"reasoning_effort": setting.reasoning_effort,
|
|
"network_access": setting.network_access,
|
|
"has_auth_token": bool(token),
|
|
"auth_token_preview": self._preview_secret(token),
|
|
"config_json": self._without_secrets(setting.config_json or {}),
|
|
"shared_ai_platform": self._shared_ai_platform_public_summary(setting),
|
|
"created_at": setting.created_at.isoformat() if setting.created_at else None,
|
|
"updated_at": setting.updated_at.isoformat() if setting.updated_at else None,
|
|
}
|
|
if include_raw:
|
|
item["raw_config"] = setting.raw_config
|
|
return item
|
|
|
|
@staticmethod
|
|
def _empty_setting(tenant_id: str) -> dict:
|
|
return {
|
|
"id": None,
|
|
"tenant_id": tenant_id,
|
|
"provider_type": None,
|
|
"display_name": "",
|
|
"enabled": False,
|
|
"config_format": None,
|
|
"model_provider": None,
|
|
"model": None,
|
|
"review_model": None,
|
|
"base_url": None,
|
|
"wire_api": None,
|
|
"reasoning_effort": None,
|
|
"network_access": None,
|
|
"has_auth_token": False,
|
|
"auth_token_preview": None,
|
|
"config_json": {},
|
|
"shared_ai_platform": ModelSettingService._empty_shared_ai_platform_summary(),
|
|
"raw_config": "",
|
|
"created_at": None,
|
|
"updated_at": None,
|
|
}
|
|
|
|
def _build_openai_shared_ai_env(self, setting: ModelSetting, token: str) -> dict[str, str]:
|
|
config_json = setting.config_json or {}
|
|
env = {
|
|
"SHARED_AI_DEFAULT_PROVIDER": "openai_compatible",
|
|
"SHARED_AI_OPENAI_BASE_URL": setting.base_url or "",
|
|
"SHARED_AI_OPENAI_API_KEY": token,
|
|
"SHARED_AI_OPENAI_MODEL": setting.model or setting.review_model or "",
|
|
"SHARED_AI_OPENAI_TEMPERATURE": str(config_json.get("temperature") or "0.2"),
|
|
"SHARED_AI_OPENAI_MAX_TOKENS": str(config_json.get("max_tokens") or "1200"),
|
|
"SHARED_AI_OPENAI_TIMEOUT_SECONDS": str(config_json.get("timeout_seconds") or settings.shared_ai_platform_timeout_seconds),
|
|
}
|
|
env.update(self._build_shared_ai_platform_env(setting))
|
|
return env
|
|
|
|
@staticmethod
|
|
def _build_anthropic_env(setting: ModelSetting, token: str) -> dict[str, str]:
|
|
env = ((setting.config_json or {}).get("env") or {}).copy()
|
|
env["ANTHROPIC_BASE_URL"] = setting.base_url or env.get("ANTHROPIC_BASE_URL") or ""
|
|
env["ANTHROPIC_AUTH_TOKEN"] = token or env.get("ANTHROPIC_AUTH_TOKEN") or ""
|
|
return {str(key): str(value) for key, value in env.items() if value is not None}
|
|
|
|
def _build_shared_ai_platform_env(self, setting: ModelSetting) -> dict[str, str]:
|
|
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
|
|
if not config:
|
|
return {}
|
|
env = {
|
|
"SHARED_AI_PLATFORM_BASE_URL": str(config.get("base_url") or ""),
|
|
"SHARED_AI_PLATFORM_API_KEY": str(config.get("api_key") or ""),
|
|
"SHARED_AI_PLATFORM_APP_ID": str(config.get("app_id") or settings.shared_ai_platform_app_id),
|
|
"SHARED_AI_PLATFORM_API_PREFIX": str(config.get("api_prefix") or settings.shared_ai_platform_api_prefix),
|
|
"SHARED_AI_PLATFORM_TIMEOUT_SECONDS": str(
|
|
self._coerce_float(config.get("timeout_seconds"), settings.shared_ai_platform_timeout_seconds)
|
|
),
|
|
}
|
|
return {key: value for key, value in env.items() if value}
|
|
|
|
@staticmethod
|
|
def _build_openai_codex_config(setting: ModelSetting, token: str) -> dict[str, Any]:
|
|
provider_name = setting.model_provider or "OpenAI"
|
|
return {
|
|
"model_provider": provider_name,
|
|
"model": setting.model,
|
|
"review_model": setting.review_model or setting.model,
|
|
"model_reasoning_effort": setting.reasoning_effort,
|
|
"disable_response_storage": bool((setting.config_json or {}).get("disable_response_storage", False)),
|
|
"network_access": setting.network_access,
|
|
"model_providers": {
|
|
provider_name: {
|
|
"name": provider_name,
|
|
"base_url": setting.base_url,
|
|
"wire_api": setting.wire_api,
|
|
"requires_openai_auth": True,
|
|
"api_key": token,
|
|
}
|
|
},
|
|
"features": (setting.config_json or {}).get("features") or {},
|
|
}
|
|
|
|
def _mask_raw_config(self, config_format: str, raw_config: str) -> str:
|
|
if config_format == "anthropic_env_json":
|
|
try:
|
|
payload = json.loads(raw_config)
|
|
except json.JSONDecodeError:
|
|
return raw_config
|
|
return json.dumps(self._without_secrets(payload), ensure_ascii=False, indent=2)
|
|
masked = raw_config
|
|
for key in OPENAI_SECRET_FIELDS:
|
|
masked = re.sub(
|
|
rf'({re.escape(key)}\s*=\s*)(["\']).*?\2',
|
|
rf'\1"***"',
|
|
masked,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
return masked
|
|
|
|
def _without_secrets(self, value: Any) -> Any:
|
|
if isinstance(value, Mapping):
|
|
masked: dict[str, Any] = {}
|
|
for key, item in value.items():
|
|
if (
|
|
str(key) in OPENAI_SECRET_FIELDS
|
|
or str(key) in ANTHROPIC_SECRET_FIELDS
|
|
or str(key) in SHARED_AI_PLATFORM_SECRET_FIELDS
|
|
):
|
|
masked[str(key)] = "***" if item else ""
|
|
else:
|
|
masked[str(key)] = self._without_secrets(item)
|
|
return masked
|
|
if isinstance(value, list):
|
|
return [self._without_secrets(item) for item in value]
|
|
return value
|
|
|
|
def _encrypt_secret(self, value: str) -> str:
|
|
if not value:
|
|
return ""
|
|
data = value.encode("utf-8")
|
|
key = self._secret_key_bytes()
|
|
encrypted = bytes(byte ^ key[index % len(key)] for index, byte in enumerate(data))
|
|
signature = hmac.new(key, encrypted, hashlib.sha256).digest()[:12]
|
|
return base64.urlsafe_b64encode(signature + encrypted).decode("ascii")
|
|
|
|
def _decrypt_secret(self, value: Optional[str]) -> str:
|
|
if not value:
|
|
return ""
|
|
try:
|
|
payload = base64.urlsafe_b64decode(value.encode("ascii"))
|
|
except Exception:
|
|
return ""
|
|
if len(payload) <= 12:
|
|
return ""
|
|
signature = payload[:12]
|
|
encrypted = payload[12:]
|
|
key = self._secret_key_bytes()
|
|
expected = hmac.new(key, encrypted, hashlib.sha256).digest()[:12]
|
|
if not hmac.compare_digest(signature, expected):
|
|
return ""
|
|
decrypted = bytes(byte ^ key[index % len(key)] for index, byte in enumerate(encrypted))
|
|
return decrypted.decode("utf-8", errors="ignore")
|
|
|
|
@staticmethod
|
|
def _secret_key_bytes() -> bytes:
|
|
return hashlib.sha256(settings.secret_key.encode("utf-8")).digest()
|
|
|
|
@staticmethod
|
|
def _preview_secret(value: str) -> Optional[str]:
|
|
if not value:
|
|
return None
|
|
if len(value) <= 8:
|
|
return "***"
|
|
return f"{value[:4]}...{value[-4:]}"
|
|
|
|
def _prepare_config_json_for_storage(self, parsed_config_json: Mapping[str, Any], existing: Optional[ModelSetting]) -> dict:
|
|
config_json = dict(parsed_config_json)
|
|
shared_config = dict(config_json.get("shared_ai_platform") or {})
|
|
shared_api_key = self._resolve_shared_ai_platform_api_key(shared_config, existing)
|
|
shared_config.pop("api_key", None)
|
|
if shared_api_key:
|
|
shared_config["api_key_encrypted"] = self._encrypt_secret(shared_api_key)
|
|
elif "api_key_encrypted" in shared_config:
|
|
shared_config.pop("api_key_encrypted", None)
|
|
if self._has_shared_ai_platform_config_values(shared_config):
|
|
config_json["shared_ai_platform"] = shared_config
|
|
else:
|
|
config_json.pop("shared_ai_platform", None)
|
|
return config_json
|
|
|
|
def _resolve_shared_ai_platform_api_key(self, shared_config: Mapping[str, Any], existing: Optional[ModelSetting]) -> str:
|
|
raw_key = str(shared_config.get("api_key") or "").strip()
|
|
if raw_key and raw_key not in {"***", MASKED_SECRET}:
|
|
return raw_key
|
|
if existing is None:
|
|
return ""
|
|
return self._shared_ai_platform_config_from_setting(existing, include_secret=True).get("api_key") or ""
|
|
|
|
def _parse_shared_ai_platform_config(
|
|
self,
|
|
value: Mapping[str, Any],
|
|
*,
|
|
fallback: Optional[Mapping[str, Any]] = None,
|
|
) -> dict[str, Any]:
|
|
fallback = fallback or {}
|
|
config = {
|
|
"base_url": self._optional_str(
|
|
value.get("base_url")
|
|
or value.get("SHARED_AI_PLATFORM_BASE_URL")
|
|
or fallback.get("SHARED_AI_PLATFORM_BASE_URL")
|
|
or fallback.get("shared_ai_platform_base_url")
|
|
),
|
|
"api_key": self._optional_str(
|
|
value.get("api_key")
|
|
or value.get("auth_token")
|
|
or value.get("SHARED_AI_PLATFORM_API_KEY")
|
|
or fallback.get("SHARED_AI_PLATFORM_API_KEY")
|
|
or fallback.get("shared_ai_platform_api_key")
|
|
),
|
|
"api_prefix": self._optional_str(
|
|
value.get("api_prefix")
|
|
or value.get("SHARED_AI_PLATFORM_API_PREFIX")
|
|
or fallback.get("SHARED_AI_PLATFORM_API_PREFIX")
|
|
or fallback.get("shared_ai_platform_api_prefix")
|
|
),
|
|
"app_id": self._optional_str(
|
|
value.get("app_id")
|
|
or value.get("SHARED_AI_PLATFORM_APP_ID")
|
|
or fallback.get("SHARED_AI_PLATFORM_APP_ID")
|
|
or fallback.get("shared_ai_platform_app_id")
|
|
),
|
|
"timeout_seconds": value.get("timeout_seconds")
|
|
or value.get("SHARED_AI_PLATFORM_TIMEOUT_SECONDS")
|
|
or fallback.get("SHARED_AI_PLATFORM_TIMEOUT_SECONDS")
|
|
or fallback.get("shared_ai_platform_timeout_seconds"),
|
|
"provider": self._optional_str(value.get("provider") or fallback.get("SHARED_AI_DEFAULT_PROVIDER")),
|
|
"legal_review_enabled": value.get("legal_review_enabled"),
|
|
"legal_review_fallback_to_local": value.get("legal_review_fallback_to_local"),
|
|
"document_extraction_enabled": value.get("document_extraction_enabled"),
|
|
}
|
|
return {key: item for key, item in config.items() if item not in (None, "")}
|
|
|
|
def _shared_ai_platform_config_from_setting(self, setting: ModelSetting, *, include_secret: bool = False) -> dict[str, Any]:
|
|
raw_config = (setting.config_json or {}).get("shared_ai_platform")
|
|
if not isinstance(raw_config, Mapping):
|
|
return {}
|
|
config = {key: raw_config.get(key) for key in SHARED_AI_PLATFORM_CONFIG_KEYS if raw_config.get(key) not in (None, "")}
|
|
if include_secret:
|
|
encrypted = str(raw_config.get("api_key_encrypted") or "")
|
|
if encrypted:
|
|
config["api_key"] = self._decrypt_secret(encrypted)
|
|
return config
|
|
|
|
def _shared_ai_platform_public_summary(self, setting: ModelSetting) -> dict[str, Any]:
|
|
config = self._shared_ai_platform_config_from_setting(setting, include_secret=True)
|
|
if not config:
|
|
return self._empty_shared_ai_platform_summary()
|
|
return {
|
|
"configured": bool(setting.enabled and config.get("base_url") and config.get("api_key")),
|
|
"source": "model_settings",
|
|
"base_url": config.get("base_url"),
|
|
"api_prefix": config.get("api_prefix") or settings.shared_ai_platform_api_prefix,
|
|
"app_id": config.get("app_id") or settings.shared_ai_platform_app_id,
|
|
"has_api_key": bool(config.get("api_key")),
|
|
"api_key_preview": self._preview_secret(str(config.get("api_key") or "")),
|
|
"timeout_seconds": self._coerce_float(config.get("timeout_seconds"), settings.shared_ai_platform_timeout_seconds),
|
|
"provider": config.get("provider") or setting.provider_type,
|
|
"legal_review_enabled": self._coerce_bool(config.get("legal_review_enabled"), settings.shared_ai_platform_legal_review_enabled),
|
|
"legal_review_fallback_to_local": self._coerce_bool(
|
|
config.get("legal_review_fallback_to_local"),
|
|
settings.shared_ai_platform_legal_review_fallback_to_local,
|
|
),
|
|
"document_extraction_enabled": self._coerce_bool(
|
|
config.get("document_extraction_enabled"),
|
|
settings.shared_ai_platform_document_extraction_enabled,
|
|
),
|
|
}
|
|
|
|
@staticmethod
|
|
def _empty_shared_ai_platform_summary() -> dict[str, Any]:
|
|
return {
|
|
"configured": False,
|
|
"source": None,
|
|
"base_url": None,
|
|
"api_prefix": None,
|
|
"app_id": None,
|
|
"has_api_key": False,
|
|
"api_key_preview": None,
|
|
"timeout_seconds": None,
|
|
"provider": None,
|
|
"legal_review_enabled": None,
|
|
"legal_review_fallback_to_local": None,
|
|
"document_extraction_enabled": None,
|
|
}
|
|
|
|
@staticmethod
|
|
def _has_shared_ai_platform_config_values(value: Mapping[str, Any]) -> bool:
|
|
return any(value.get(key) not in (None, "") for key in SHARED_AI_PLATFORM_CONFIG_KEYS)
|
|
|
|
@staticmethod
|
|
def _coerce_float(value: Any, default: float) -> float:
|
|
try:
|
|
return float(value)
|
|
except (TypeError, ValueError):
|
|
return float(default)
|
|
|
|
@staticmethod
|
|
def _coerce_bool(value: Any, default: bool) -> bool:
|
|
if value is None:
|
|
return bool(default)
|
|
if isinstance(value, bool):
|
|
return value
|
|
normalized = str(value).strip().lower()
|
|
if normalized in {"1", "true", "yes", "on"}:
|
|
return True
|
|
if normalized in {"0", "false", "no", "off"}:
|
|
return False
|
|
return bool(default)
|