Files
ai-contract-platform/backend/app/services/model_setting_service.py
T

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)