Files
planet/backend/app/api/v1/settings.py
rayd1o 5bf5c73ca0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.66.0
2026-05-26 03:41:47 +08:00

2133 lines
79 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from copy import deepcopy
from datetime import UTC, datetime
import os
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
import httpx
from pydantic import BaseModel, EmailStr, Field
from dotenv import dotenv_values
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.core.security import get_current_user
from app.core.time import to_iso8601_utc
from app.core.config import settings as app_settings
from app.core.data_sources import get_data_sources_config
from app.core.datasource_defaults import DEFAULT_DATASOURCES
from app.ai_tasks.prompts import (
get_effective_prompt,
list_effective_prompts,
reset_prompt_override,
save_prompt_override,
serialize_effective_prompt,
)
from app.db.session import get_db
from app.models.datasource import DataSource
from app.models.datasource_config import DataSourceConfig
from app.models.system_setting import SystemSetting
from app.models.user import User
from app.models.vessel import AISSourceHealth
from app.schemas.ai import SituationalAnalysisRequest
from app.services.barentswatch import (
BarentsWatchConfig,
check_barentswatch_config,
check_barentswatch_connectivity,
get_barentswatch_datasource_record,
resolve_barentswatch_config,
)
from app.services.credential_guides import (
generate_credential_guide,
get_credential_guide,
reset_credential_guide,
)
from app.services.datasource_connectivity import (
build_builtin_connectivity_checksum,
save_connectivity_success,
)
from app.services.ai_client import AIProviderClient, get_ai_provider_client
from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig
from app.services.ai_tools.web_search import (
WebSearchClient,
WebSearchConfigurationError,
WebSearchError,
get_web_search_provider_preset,
list_web_search_provider_presets,
normalize_web_search_provider,
provider_defaults as web_search_provider_defaults,
)
from app.services.llm_provider_catalog import (
FALLBACK_LLM_PROVIDER_PRESETS,
get_fallback_llm_provider_preset,
list_fallback_llm_provider_presets,
refresh_llm_provider_preset,
)
from app.services.scheduler import sync_datasource_job
from app.services.tv_streams import DEFAULT_TV_SETTINGS, get_tv_settings_payload, normalize_tv_settings
from app.services.persistent_logs import record_audit_log
from app.services.business_logs import emit_business_log, exception_context
router = APIRouter()
logger = get_logger(__name__, service="api")
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS = 5
AI_CONNECTION_TEST_PROMPT_KEY = "ai.connection_test"
SECRET_REVEAL_ROLES = {"admin", "super_admin"}
DEFAULT_SETTINGS = {
"system": {
"system_name": "智能星球",
"refresh_interval": 60,
"auto_refresh": True,
"data_retention_days": 30,
"max_concurrent_tasks": 5,
"demo_mode": False,
},
"notifications": {
"email_enabled": False,
"email_address": "",
"critical_alerts": True,
"warning_alerts": True,
"daily_summary": False,
},
"security": {
"session_timeout": 60,
"max_login_attempts": 5,
"password_policy": "medium",
},
"tv": DEFAULT_TV_SETTINGS,
"smtp": {
"host": "",
"port": 587,
"username": "",
"password": "",
"from_address": "",
"from_name": "Planet",
"use_tls": False,
"use_starttls": True,
"timeout_seconds": 20,
},
"external_integrations": {
"ai_provider": {
"service_url": "",
"service_token": "",
"default_provider": "minimax",
"providers": {},
"timeout_seconds": 60,
"retry_attempts": 2,
},
"web_search": {
"enabled": False,
"default_provider": "tavily",
"providers": {},
},
"ocr": {
"enabled": False,
"provider": "paddleocr",
"base_url": "",
"api_key": "",
"model": "",
"languages": ["zh", "en"],
"timeout_seconds": 30,
"max_file_size_mb": 20,
"output_format": "markdown",
},
},
}
def _user_role_value(user: User) -> str:
role = getattr(user, "role", "")
return role.value if hasattr(role, "value") else str(role or "")
def _user_display_name(user: User) -> str | None:
return getattr(user, "username", None) or getattr(user, "email", None)
def _request_client_ip(request: Request | None) -> str | None:
if request is None or request.client is None:
return None
return request.client.host
def _can_reveal_integration_secrets(user: User) -> bool:
return _user_role_value(user) in SECRET_REVEAL_ROLES
async def _record_integration_secret_reveal(
*,
current_user: User,
request: Request | None,
target_id: str,
result: str,
details: dict,
) -> None:
await record_audit_log(
action="settings.integration_secret.reveal",
actor_id=getattr(current_user, "id", None),
actor_name=_user_display_name(current_user),
target_type="integration_secret",
target_id=target_id,
result=result,
ip=_request_client_ip(request),
details=details,
)
async def _ensure_secret_reveal_allowed(
*,
current_user: User,
request: Request | None,
target_id: str,
details: dict | None = None,
) -> None:
if _can_reveal_integration_secrets(current_user):
return
await _record_integration_secret_reveal(
current_user=current_user,
request=request,
target_id=target_id,
result="denied",
details={
**(details or {}),
"role": _user_role_value(current_user),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can reveal integration secrets",
)
class SystemSettingsUpdate(BaseModel):
system_name: str = "智能星球"
refresh_interval: int = Field(default=60, ge=10, le=3600)
auto_refresh: bool = True
data_retention_days: int = Field(default=30, ge=1, le=3650)
max_concurrent_tasks: int = Field(default=5, ge=1, le=50)
demo_mode: bool = False
class NotificationSettingsUpdate(BaseModel):
email_enabled: bool = False
email_address: Optional[EmailStr] = None
critical_alerts: bool = True
warning_alerts: bool = True
daily_summary: bool = False
class SecuritySettingsUpdate(BaseModel):
session_timeout: int = Field(default=60, ge=5, le=1440)
max_login_attempts: int = Field(default=5, ge=1, le=20)
password_policy: str = Field(default="medium")
class CollectorSettingsUpdate(BaseModel):
is_active: bool
priority: str = Field(default="P1")
frequency_minutes: int = Field(default=60, ge=1, le=10080)
class TVStreamSourceUpdate(BaseModel):
id: str = Field(min_length=1, max_length=100)
name: str = Field(min_length=1, max_length=200)
provider: str = Field(default="Unknown", max_length=100)
region: str = Field(default="Global", max_length=100)
language: str = Field(default="und", max_length=32)
source_type: str = Field(default="iframe", pattern="^(iframe|hls|video|external|youtube)$")
embed_url: str = ""
stream_url: str = ""
homepage_url: str = ""
poster_url: str = ""
youtube_video_id: str = ""
youtube_channel: str = ""
is_enabled: bool = True
is_fallback: bool = False
sort_order: int = Field(default=10, ge=0, le=9999)
collector_source: Optional[str] = None
notes: str = ""
class TVSettingsUpdate(BaseModel):
default_source_id: str = Field(default=DEFAULT_TV_SETTINGS["default_source_id"], min_length=1)
auto_fallback: bool = True
sources: list[TVStreamSourceUpdate] = Field(default_factory=list)
class SMTPSettingsUpdate(BaseModel):
host: str = Field(default="", max_length=255)
port: int = Field(default=587, ge=1, le=65535)
username: str = Field(default="", max_length=255)
password: Optional[str] = None
clear_password: bool = False
from_address: str = Field(default="", max_length=255)
from_name: str = Field(default="Planet", max_length=120)
use_tls: bool = False
use_starttls: bool = True
timeout_seconds: int = Field(default=20, ge=3, le=300)
class SMTPTestRequest(BaseModel):
to: EmailStr
settings: Optional[SMTPSettingsUpdate] = None
class AIProviderIntegrationUpdate(BaseModel):
service_url: str = ""
service_token: Optional[str] = None
default_provider: Optional[str] = None
provider: str = Field(default="minimax", max_length=80)
provider_api: str = Field(default="anthropic-messages", max_length=80)
base_url: str = Field(default="", max_length=500)
model: str = Field(default="", max_length=200)
api_key: Optional[str] = None
max_tokens: int = Field(default=1200, ge=1, le=200000)
anthropic_version: str = Field(default="2023-06-01", max_length=40)
timeout_seconds: int = Field(default=60, ge=5, le=600)
retry_attempts: int = Field(default=2, ge=1, le=10)
clear_service_token: bool = False
clear_api_key: bool = False
class BarentsWatchIntegrationUpdate(BaseModel):
endpoint: str = ""
client_id: str = ""
client_secret: Optional[str] = None
clear_client_secret: bool = False
class WebSearchIntegrationUpdate(BaseModel):
enabled: bool = False
default_provider: Optional[str] = None
provider: str = Field(default="tavily", max_length=80)
base_url: str = Field(default="", max_length=500)
api_key: Optional[str] = None
max_results: int = Field(default=5, ge=1, le=20)
timeout_seconds: int = Field(default=20, ge=3, le=120)
endpoint_path: str = Field(default="", max_length=200)
search_depth: str = Field(default="basic", max_length=40)
engine: str = Field(default="google", max_length=80)
include_answer: bool = False
include_raw_content: bool = False
include_text: bool = False
categories: str = Field(default="general", max_length=120)
engines: list[str] = Field(default_factory=list)
search_path: str = Field(default="", max_length=200)
scrape_path: str = Field(default="", max_length=200)
scrape_formats: list[str] = Field(default_factory=lambda: ["markdown"])
class OCRIntegrationUpdate(BaseModel):
enabled: bool = False
provider: str = Field(default="paddleocr", max_length=80)
base_url: str = Field(default="", max_length=500)
api_key: Optional[str] = None
model: str = Field(default="", max_length=200)
languages: list[str] = Field(default_factory=lambda: ["zh", "en"])
timeout_seconds: int = Field(default=30, ge=3, le=300)
max_file_size_mb: int = Field(default=20, ge=1, le=200)
output_format: str = Field(default="markdown", pattern="^(markdown|text|json)$")
class AIPromptUpdate(BaseModel):
system_prompt: str = Field(default="", max_length=8000)
prompt: str = Field(min_length=1, max_length=20000)
class ExternalIntegrationsUpdate(BaseModel):
ai_provider: AIProviderIntegrationUpdate
barentswatch: BarentsWatchIntegrationUpdate
web_search: WebSearchIntegrationUpdate | None = None
ocr: OCRIntegrationUpdate | None = None
def merge_with_defaults(category: str, payload: Optional[dict]) -> dict:
merged = deepcopy(DEFAULT_SETTINGS[category])
if payload:
merged.update(payload)
return merged
async def get_setting_record(db: AsyncSession, category: str) -> Optional[SystemSetting]:
result = await db.execute(select(SystemSetting).where(SystemSetting.category == category))
return result.scalar_one_or_none()
async def get_setting_payloads(db: AsyncSession, categories: list[str]) -> dict[str, dict]:
if not categories:
return {}
result = await db.execute(
select(SystemSetting).where(SystemSetting.category.in_(categories))
)
records_by_category = {
record.category: record
for record in result.scalars().all()
}
return {
category: merge_with_defaults(
category,
records_by_category.get(category).payload if records_by_category.get(category) else None,
)
for category in categories
}
async def get_setting_payload(db: AsyncSession, category: str) -> dict:
record = await get_setting_record(db, category)
return merge_with_defaults(category, record.payload if record else None)
async def save_setting_payload(db: AsyncSession, category: str, payload: dict) -> dict:
record = await get_setting_record(db, category)
if record is None:
record = SystemSetting(category=category, payload=payload)
db.add(record)
else:
record.payload = payload
await db.commit()
await db.refresh(record)
return merge_with_defaults(category, record.payload)
AI_PROVIDER_ENV_FILE = Path(__file__).resolve().parents[4] / "aiprovider" / ".env"
WEB_SEARCH_ENV_FILES = (
Path(__file__).resolve().parents[4] / ".env",
Path(__file__).resolve().parents[3] / ".env",
AI_PROVIDER_ENV_FILE,
)
def _mask_secret(value: Optional[str], source: str = "") -> dict:
if not value:
return {"configured": False, "preview": "", "source": source}
text = str(value)
if "-" in text:
prefix = text.split("-", 1)[0] + "-"
preview = prefix + ("*" * max(len(text) - len(prefix), 1))
else:
preview = "*" * len(text)
return {"configured": True, "preview": preview, "source": source}
def _normalize_provider_id(provider: Optional[str]) -> str:
return (provider or "minimax").strip().lower() or "minimax"
def _get_provider_preset(provider: str) -> dict:
try:
return get_fallback_llm_provider_preset(provider)
except ValueError:
return {
"provider": provider,
"provider_api": "openai-completions",
"base_url": "",
"model": "",
"models": [],
"model_provider_apis": {},
"api_key_env": "",
}
def _read_ai_provider_env_file() -> dict[str, str]:
if not AI_PROVIDER_ENV_FILE.exists():
return {}
return {
key: str(value)
for key, value in dotenv_values(AI_PROVIDER_ENV_FILE).items()
if value is not None
}
def _resolve_env_secret(*names: str) -> tuple[str, str]:
env_file_values = _read_ai_provider_env_file()
for name in names:
if not name:
continue
value = env_file_values.get(name)
if value:
return value, "env_file"
value = os.environ.get(name)
if value:
return value, "env"
return "", ""
def _read_web_search_env_files() -> dict[str, str]:
values: dict[str, str] = {}
for path in WEB_SEARCH_ENV_FILES:
if not path.exists():
continue
values.update({
key: str(value)
for key, value in dotenv_values(path).items()
if value is not None
})
return values
def _resolve_web_search_env_secret(*names: str) -> tuple[str, str]:
env_file_values = _read_web_search_env_files()
for name in names:
if not name:
continue
value = env_file_values.get(name)
if value:
return value, "env_file"
value = os.environ.get(name)
if value:
return value, "env"
return "", ""
def _provider_defaults(provider: str) -> dict:
preset = _get_provider_preset(provider)
return {
"provider": provider,
"provider_api": preset.get("provider_api") or "openai-completions",
"base_url": preset.get("base_url") or "",
"model": preset.get("model") or "",
"api_key": "",
"max_tokens": (
1200 if preset.get("provider_api") == "anthropic-messages" else 4096
),
"anthropic_version": "2023-06-01",
"model_provider_apis": preset.get("model_provider_apis") or {},
}
def _selected_ai_env_provider() -> str:
env_file_values = _read_ai_provider_env_file()
provider = env_file_values.get("AI_PROVIDER") or os.environ.get("AI_PROVIDER") or "minimax"
return _normalize_provider_id(provider)
def _normalize_ai_provider_payload(ai_payload: dict | None) -> dict:
raw = dict(ai_payload or {})
default_provider = _normalize_provider_id(raw.get("default_provider") or raw.get("provider"))
providers = {
_normalize_provider_id(provider): dict(config or {})
for provider, config in (raw.get("providers") or {}).items()
if provider
}
legacy_fields = {
key: raw.get(key)
for key in (
"provider_api",
"base_url",
"model",
"api_key",
"max_tokens",
"anthropic_version",
"model_provider_apis",
)
if raw.get(key) not in (None, "")
}
if legacy_fields:
providers[default_provider] = {
**providers.get(default_provider, {}),
**legacy_fields,
}
normalized_providers: dict[str, dict] = {}
for provider, config in providers.items():
provider_id = _normalize_provider_id(provider)
normalized_providers[provider_id] = {
**_provider_defaults(provider_id),
**dict(config or {}),
"provider": provider_id,
}
if default_provider not in normalized_providers:
normalized_providers[default_provider] = _provider_defaults(default_provider)
return {
"service_url": raw.get("service_url") or "",
"service_token": raw.get("service_token") or "",
"default_provider": default_provider,
"providers": normalized_providers,
"timeout_seconds": int(raw.get("timeout_seconds") or 60),
"retry_attempts": int(raw.get("retry_attempts") or 2),
}
def _resolve_provider_api_key(provider: str, provider_config: dict) -> tuple[str, str]:
saved_key = provider_config.get("api_key") or ""
if saved_key:
return str(saved_key), "runtime"
preset = _get_provider_preset(provider)
api_key_env = preset.get("api_key_env") or ""
value, source = _resolve_env_secret(api_key_env)
if value:
return value, source
if _normalize_provider_id(provider) == _selected_ai_env_provider():
return _resolve_env_secret("AI_API_KEY")
return "", ""
def _resolve_service_token(ai_payload: dict) -> tuple[str, str]:
saved_token = ai_payload.get("service_token") or ""
if saved_token:
return str(saved_token), "runtime"
token, source = _resolve_env_secret("AI_PROVIDER_SERVICE_TOKEN")
if token:
return token, source
if app_settings.AI_PROVIDER_SERVICE_TOKEN:
return app_settings.AI_PROVIDER_SERVICE_TOKEN, "backend_env"
return "", ""
def _is_secret_placeholder(value: Optional[str], current_preview: str = "") -> bool:
if value in (None, ""):
return True
text = str(value).strip()
if not text:
return True
if text == current_preview or text.startswith("••••"):
return True
if "-" in text:
_prefix, masked = text.split("-", 1)
if masked and all(char in {"*", "", " ", "\t"} for char in masked):
return True
return all(char in {"*", "", " ", "\t"} for char in text)
def _build_ai_provider_payload(current_payload: dict, update: AIProviderIntegrationUpdate) -> dict:
current_ai = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_id = _normalize_provider_id(update.provider)
default_provider = (
_normalize_provider_id(update.default_provider)
if update.default_provider is not None
else current_ai["default_provider"]
)
current_providers = {
provider: dict(config or {})
for provider, config in current_ai.get("providers", {}).items()
}
current_provider = current_providers.get(provider_id) or _provider_defaults(provider_id)
current_api_key, current_api_key_source = _resolve_provider_api_key(provider_id, current_provider)
current_api_key_preview = _mask_secret(current_api_key, current_api_key_source)["preview"]
provider_payload = {
**_provider_defaults(provider_id),
**current_provider,
"provider": provider_id,
"provider_api": update.provider_api.strip()
or current_provider.get("provider_api")
or "anthropic-messages",
"base_url": update.base_url.strip(),
"model": update.model.strip(),
"max_tokens": update.max_tokens,
"anthropic_version": update.anthropic_version.strip() or "2023-06-01",
}
if not _is_secret_placeholder(update.api_key, current_api_key_preview):
provider_payload["api_key"] = str(update.api_key).strip()
elif current_provider.get("api_key"):
provider_payload["api_key"] = current_provider.get("api_key") or ""
else:
provider_payload["api_key"] = ""
current_providers[provider_id] = provider_payload
current_service_token, current_service_source = _resolve_service_token(current_ai)
current_service_preview = _mask_secret(current_service_token, current_service_source)["preview"]
ai_payload = {
"service_url": update.service_url.strip()
or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": current_ai.get("service_token") or "",
"default_provider": default_provider,
"providers": current_providers,
"timeout_seconds": update.timeout_seconds,
"retry_attempts": update.retry_attempts,
}
if not _is_secret_placeholder(update.service_token, current_service_preview):
ai_payload["service_token"] = str(update.service_token).strip()
return ai_payload
def _runtime_config_from_ai_payload(ai_payload: dict) -> dict:
normalized_ai = _normalize_ai_provider_payload(ai_payload)
default_provider = normalized_ai["default_provider"]
provider_config = (
normalized_ai["providers"].get(default_provider) or _provider_defaults(default_provider)
)
api_key, _api_key_source = _resolve_provider_api_key(default_provider, provider_config)
return {
"service_url": normalized_ai.get("service_url") or app_settings.AI_PROVIDER_SERVICE_URL,
"service_token": _resolve_service_token(normalized_ai)[0],
"timeout_seconds": int(
normalized_ai.get("timeout_seconds") or app_settings.AI_PROVIDER_TIMEOUT_SECONDS
),
"retry_attempts": int(
normalized_ai.get("retry_attempts") or app_settings.AI_PROVIDER_RETRY_ATTEMPTS
),
"llm_config": {
"provider": default_provider,
"provider_api": provider_config.get("provider_api") or "anthropic-messages",
"base_url": provider_config.get("base_url") or "",
"model": provider_config.get("model") or "",
"api_key": api_key,
"max_tokens": int(provider_config.get("max_tokens") or 1200),
"anthropic_version": provider_config.get("anthropic_version") or "2023-06-01",
"model_provider_apis": provider_config.get("model_provider_apis") or {},
"preset_models": _get_provider_preset(default_provider).get("models") or [],
},
}
def _ai_provider_runtime_fingerprint(ai_payload: dict) -> dict:
runtime_config = _runtime_config_from_ai_payload(ai_payload)
llm_config = runtime_config.get("llm_config") or {}
return {
"service_url": runtime_config.get("service_url") or "",
"service_token": runtime_config.get("service_token") or "",
"timeout_seconds": int(runtime_config.get("timeout_seconds") or 0),
"retry_attempts": int(runtime_config.get("retry_attempts") or 0),
"provider": llm_config.get("provider") or "",
"provider_api": llm_config.get("provider_api") or "",
"base_url": llm_config.get("base_url") or "",
"model": llm_config.get("model") or "",
"api_key": llm_config.get("api_key") or "",
"max_tokens": int(llm_config.get("max_tokens") or 0),
"anthropic_version": llm_config.get("anthropic_version") or "",
}
async def _validate_ai_provider_full_connection(ai_payload: dict) -> dict:
runtime_config = _runtime_config_from_ai_payload(ai_payload)
client = AIProviderClient(
service_url=runtime_config["service_url"],
service_token=runtime_config["service_token"],
timeout=runtime_config["timeout_seconds"],
retry_attempts=runtime_config["retry_attempts"],
llm_config=runtime_config.get("llm_config") or {},
)
await emit_business_log(
logger,
event="settings.ai_provider.full_connection.start",
message="AI provider full connection validation started",
category="ai",
service="api",
module=__name__,
context={
"provider": runtime_config.get("llm_config", {}).get("provider"),
"model": runtime_config.get("llm_config", {}).get("model"),
},
)
status_result = await client.get_status()
if not status_result.configured:
raise HTTPException(
status_code=400,
detail="AI Provider 可访问,但当前 provider/model/key 未完整配置。",
)
prompt = await get_effective_prompt(None, AI_CONNECTION_TEST_PROMPT_KEY)
analysis_result = await client.analyze(
SituationalAnalysisRequest(
title="保存前完整连接测试",
objective=prompt.prompt,
system_prompt=prompt.system_prompt or None,
observations=["这是保存 AI Provider 配置前的完整 LLM 调用测试。"],
constraints=["回复尽量简短。"],
)
)
await emit_business_log(
logger,
event="settings.ai_provider.full_connection.success",
message="AI provider full connection validation completed",
category="ai",
service="api",
module=__name__,
context={
"provider": analysis_result.provider,
"model": analysis_result.model,
"configured": status_result.configured,
},
)
return {
"status": status_result.model_dump(),
"provider": analysis_result.provider,
"model": analysis_result.model,
}
def _join_provider_url(base_url: str, path: str) -> str:
return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
def _extract_model_ids(payload: dict) -> list[str]:
data = payload.get("data") if isinstance(payload, dict) else None
if isinstance(data, list):
return [
str(item.get("id"))
for item in data
if isinstance(item, dict) and item.get("id")
]
models = payload.get("models") if isinstance(payload, dict) else None
if isinstance(models, list):
return [
str(item.get("name") or item.get("model") or item.get("id") or item)
for item in models
if item
]
return []
def _contains_model(model_ids: list[str], model: str) -> bool:
normalized_model = model.strip().lower()
return any(str(item).strip().lower() == normalized_model for item in model_ids)
async def _check_ai_provider_lightweight(llm_config: dict, timeout_seconds: int) -> dict:
provider = _normalize_provider_id(llm_config.get("provider") or "")
configured_api = str(llm_config.get("provider_api") or "").strip() or "openai-completions"
model = str(llm_config.get("model") or "").strip()
base_url = str(llm_config.get("base_url") or "").strip().rstrip("/")
api_key = str(llm_config.get("api_key") or "").strip()
provider_api = configured_api
model_provider_apis = llm_config.get("model_provider_apis")
if isinstance(model_provider_apis, dict):
provider_api = str(model_provider_apis.get(model) or provider_api)
preset_models = [
str(item)
for item in (llm_config.get("preset_models") or [])
if str(item).strip()
]
if not provider or not base_url or not model:
return {
"success": False,
"connected": False,
"message": "当前 provider/base_url/model 未完整配置。",
"mode": "lightweight_config",
}
if provider_api != "ollama-generate" and not api_key:
return {
"success": False,
"connected": False,
"message": "当前 provider 未配置 API Key。",
"mode": "lightweight_config",
}
if provider == "opencode-go":
url = _join_provider_url(base_url, "/models")
headers = {"Authorization": f"Bearer {api_key}"}
elif provider_api == "ollama-generate":
url = _join_provider_url(base_url, "/api/tags")
headers: dict[str, str] = {}
elif provider_api == "openai-completions":
url = _join_provider_url(base_url, "/models")
headers = {"Authorization": f"Bearer {api_key}"}
elif provider_api == "anthropic-messages":
url = _join_provider_url(base_url, "/models")
headers = {
"x-api-key": api_key,
"anthropic-version": str(llm_config.get("anthropic_version") or "2023-06-01"),
}
else:
return {
"success": False,
"connected": False,
"message": f"当前 provider_api 不支持轻量连通性测试: {provider_api}",
"mode": "lightweight_unsupported",
}
try:
async with httpx.AsyncClient(timeout=min(timeout_seconds, AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS)) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
payload = response.json()
except httpx.HTTPStatusError as exc:
detail = exc.response.text or exc.response.reason_phrase
if exc.response.status_code == 404 and _contains_model(preset_models, model):
return {
"success": True,
"connected": True,
"message": "轻量连通性测试通过;当前 provider 不提供可用的模型目录,已按内置模型预设确认。",
"mode": "lightweight_preset",
"provider": provider,
"provider_api": provider_api,
"model": model,
"url": url,
}
return {
"success": False,
"connected": False,
"message": f"轻量连通性测试失败: HTTP {exc.response.status_code} {detail}",
"mode": "lightweight_models",
"url": url,
}
except Exception as exc:
return {
"success": False,
"connected": False,
"message": f"轻量连通性测试失败: {exc}",
"mode": "lightweight_models",
"url": url,
}
model_ids = _extract_model_ids(payload)
if model_ids and not _contains_model(model_ids, model):
if _contains_model(preset_models, model):
return {
"success": True,
"connected": True,
"message": "轻量连通性测试通过provider 模型目录未返回当前别名,已按内置模型预设确认。",
"mode": "lightweight_models_with_preset_alias",
"provider": provider,
"provider_api": provider_api,
"model": model,
"models_count": len(model_ids),
"url": url,
}
return {
"success": False,
"connected": False,
"message": f"连接可用,但模型目录中没有当前模型: {model}",
"mode": "lightweight_models",
"provider": provider,
"model": model,
"models_count": len(model_ids),
"url": url,
}
return {
"success": True,
"connected": True,
"message": "轻量连通性测试通过",
"mode": "lightweight_models",
"provider": provider,
"provider_api": provider_api,
"model": model,
"models_count": len(model_ids),
"url": url,
}
def _web_search_provider_defaults(provider: str) -> dict:
return web_search_provider_defaults(provider).model_dump()
def _normalize_web_search_payload(web_search_payload: dict | None) -> dict:
raw = dict(web_search_payload or {})
default_provider = normalize_web_search_provider(
raw.get("default_provider") or raw.get("provider")
)
providers = {
normalize_web_search_provider(provider): dict(config or {})
for provider, config in (raw.get("providers") or {}).items()
if provider
}
legacy_fields = {
key: raw.get(key)
for key in (
"base_url",
"api_key",
"max_results",
"timeout_seconds",
"endpoint_path",
"search_depth",
"engine",
"include_answer",
"include_raw_content",
"include_text",
"categories",
"engines",
"search_path",
"scrape_path",
"scrape_formats",
)
if raw.get(key) not in (None, "")
}
if legacy_fields:
providers[default_provider] = {
**providers.get(default_provider, {}),
**legacy_fields,
}
normalized_providers: dict[str, dict] = {}
for provider, config in providers.items():
provider_id = normalize_web_search_provider(provider)
normalized_providers[provider_id] = {
**_web_search_provider_defaults(provider_id),
**dict(config or {}),
"provider": provider_id,
}
if default_provider not in normalized_providers:
normalized_providers[default_provider] = _web_search_provider_defaults(default_provider)
return {
"enabled": bool(raw.get("enabled", False)),
"default_provider": default_provider,
"providers": normalized_providers,
}
def _resolve_web_search_api_key(
provider: str,
provider_config: dict,
default_provider: str | None = None,
) -> tuple[str, str]:
saved_key = provider_config.get("api_key") or ""
if saved_key:
return str(saved_key), "runtime"
preset = get_web_search_provider_preset(provider)
value, source = _resolve_web_search_env_secret(preset.get("api_key_env") or "")
if value:
return value, source
if normalize_web_search_provider(provider) == normalize_web_search_provider(default_provider or "tavily"):
return _resolve_web_search_env_secret("WEB_SEARCH_API_KEY")
return "", ""
def _build_web_search_payload(
current_payload: dict,
update: WebSearchIntegrationUpdate | None,
) -> dict:
current_web_search = _normalize_web_search_payload(current_payload.get("web_search") or {})
if update is None:
return current_web_search
provider_id = normalize_web_search_provider(update.provider)
default_provider = (
normalize_web_search_provider(update.default_provider)
if update.default_provider is not None
else current_web_search["default_provider"]
)
current_providers = {
provider: dict(config or {})
for provider, config in current_web_search.get("providers", {}).items()
}
current_provider = current_providers.get(provider_id) or _web_search_provider_defaults(provider_id)
current_key, current_key_source = _resolve_web_search_api_key(
provider_id,
current_provider,
current_web_search["default_provider"],
)
current_key_preview = _mask_secret(current_key, current_key_source)["preview"]
provider_payload = {
**_web_search_provider_defaults(provider_id),
**current_provider,
"provider": provider_id,
"base_url": update.base_url.strip()
or current_provider.get("base_url")
or _web_search_provider_defaults(provider_id).get("base_url")
or "",
"max_results": update.max_results,
"timeout_seconds": update.timeout_seconds,
"endpoint_path": update.endpoint_path.strip() or current_provider.get("endpoint_path") or "",
"search_depth": update.search_depth.strip() or "basic",
"engine": update.engine.strip() or "google",
"include_answer": update.include_answer,
"include_raw_content": update.include_raw_content,
"include_text": update.include_text,
"categories": update.categories.strip() or "general",
"engines": update.engines,
"search_path": update.search_path.strip() or current_provider.get("search_path") or "",
"scrape_path": update.scrape_path.strip() or current_provider.get("scrape_path") or "",
"scrape_formats": update.scrape_formats or ["markdown"],
}
if not _is_secret_placeholder(update.api_key, current_key_preview):
provider_payload["api_key"] = str(update.api_key).strip()
elif current_provider.get("api_key"):
provider_payload["api_key"] = current_provider.get("api_key") or ""
else:
provider_payload["api_key"] = ""
current_providers[provider_id] = provider_payload
return {
"enabled": update.enabled,
"default_provider": default_provider,
"providers": current_providers,
}
def _runtime_config_from_web_search_payload(web_search_payload: dict) -> WebSearchConfig:
normalized = _normalize_web_search_payload(web_search_payload)
provider_id = normalized["default_provider"]
provider_config = normalized["providers"].get(provider_id) or _web_search_provider_defaults(provider_id)
api_key, _source = _resolve_web_search_api_key(provider_id, provider_config, provider_id)
provider_models = {
provider: WebSearchProviderConfig(**{
**config,
"api_key": (
api_key if provider == provider_id else _resolve_web_search_api_key(provider, config, provider_id)[0]
),
})
for provider, config in normalized["providers"].items()
}
return WebSearchConfig(
enabled=normalized["enabled"],
default_provider=provider_id,
provider=provider_id,
providers=provider_models,
)
def _normalize_ocr_payload(ocr_payload: dict | None) -> dict:
raw = dict(ocr_payload or {})
languages = raw.get("languages")
if not isinstance(languages, list) or not languages:
languages = ["zh", "en"]
return {
"enabled": bool(raw.get("enabled", False)),
"provider": str(raw.get("provider") or "paddleocr").strip().lower() or "paddleocr",
"base_url": str(raw.get("base_url") or "").strip(),
"api_key": str(raw.get("api_key") or "").strip(),
"model": str(raw.get("model") or "").strip(),
"languages": [str(item).strip() for item in languages if str(item).strip()],
"timeout_seconds": int(raw.get("timeout_seconds") or 30),
"max_file_size_mb": int(raw.get("max_file_size_mb") or 20),
"output_format": str(raw.get("output_format") or "markdown").strip() or "markdown",
}
def _resolve_ocr_api_key(ocr_config: dict) -> tuple[str, str]:
saved_key = ocr_config.get("api_key") or ""
if saved_key:
return str(saved_key), "runtime"
return _resolve_web_search_env_secret("OCR_API_KEY")
def _build_ocr_payload(
current_payload: dict,
update: OCRIntegrationUpdate | None,
) -> dict:
current_ocr = _normalize_ocr_payload(current_payload.get("ocr") or {})
if update is None:
return current_ocr
current_key, current_key_source = _resolve_ocr_api_key(current_ocr)
current_key_preview = _mask_secret(current_key, current_key_source)["preview"]
ocr_payload = {
"enabled": update.enabled,
"provider": update.provider.strip().lower() or current_ocr.get("provider") or "paddleocr",
"base_url": update.base_url.strip(),
"model": update.model.strip(),
"languages": [item.strip() for item in update.languages if item.strip()] or ["zh", "en"],
"timeout_seconds": update.timeout_seconds,
"max_file_size_mb": update.max_file_size_mb,
"output_format": update.output_format.strip() or "markdown",
}
if not _is_secret_placeholder(update.api_key, current_key_preview):
ocr_payload["api_key"] = str(update.api_key).strip()
elif current_ocr.get("api_key"):
ocr_payload["api_key"] = current_ocr.get("api_key") or ""
else:
ocr_payload["api_key"] = ""
return ocr_payload
async def get_runtime_web_search_config(db: AsyncSession) -> WebSearchConfig:
runtime_record = await get_setting_record(db, "external_integrations")
payload = merge_with_defaults(
"external_integrations",
runtime_record.payload if runtime_record else None,
)
return _runtime_config_from_web_search_payload(payload.get("web_search") or {})
async def get_web_search_client(db: AsyncSession) -> WebSearchClient:
return WebSearchClient(await get_runtime_web_search_config(db))
async def get_runtime_ai_provider_config(db: AsyncSession) -> dict:
runtime_record = await get_setting_record(db, "external_integrations")
payload = merge_with_defaults(
"external_integrations",
runtime_record.payload if runtime_record else None,
)
return _runtime_config_from_ai_payload(payload.get("ai_provider") or {})
async def get_barentswatch_config_record(db: AsyncSession) -> Optional[DataSourceConfig]:
return await get_barentswatch_datasource_record(db)
async def serialize_external_integrations(db: AsyncSession) -> dict:
ai_config = await get_runtime_ai_provider_config(db)
runtime_setting = await get_setting_record(db, "external_integrations")
raw_payload = merge_with_defaults(
"external_integrations",
runtime_setting.payload if runtime_setting else None,
)
normalized_ai = _normalize_ai_provider_payload(raw_payload.get("ai_provider") or {})
normalized_web_search = _normalize_web_search_payload(raw_payload.get("web_search") or {})
normalized_ocr = _normalize_ocr_payload(raw_payload.get("ocr") or {})
default_provider = normalized_ai["default_provider"]
providers_payload: dict[str, dict] = {}
for provider in sorted({
*FALLBACK_LLM_PROVIDER_PRESETS.keys(),
*normalized_ai["providers"].keys(),
default_provider,
}):
provider_id = _normalize_provider_id(provider)
provider_config = normalized_ai["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config)
providers_payload[provider_id] = {
"provider": provider_id,
"provider_api": provider_config.get("provider_api") or "openai-completions",
"base_url": provider_config.get("base_url") or "",
"model": provider_config.get("model") or "",
"api_key": _mask_secret(api_key, api_key_source),
"max_tokens": int(provider_config.get("max_tokens") or 1200),
"anthropic_version": provider_config.get("anthropic_version") or "2023-06-01",
"source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"),
}
display_llm_config = providers_payload.get(default_provider) or _provider_defaults(default_provider)
web_search_providers_payload: dict[str, dict] = {}
for provider in sorted({
*[preset["provider"] for preset in list_web_search_provider_presets()],
*normalized_web_search["providers"].keys(),
normalized_web_search["default_provider"],
}):
provider_id = normalize_web_search_provider(provider)
provider_config = (
normalized_web_search["providers"].get(provider_id)
or _web_search_provider_defaults(provider_id)
)
api_key, api_key_source = _resolve_web_search_api_key(
provider_id,
provider_config,
normalized_web_search["default_provider"],
)
web_search_providers_payload[provider_id] = {
**{
key: value
for key, value in provider_config.items()
if key != "api_key"
},
"provider": provider_id,
"api_key": _mask_secret(api_key, api_key_source),
"source": "runtime" if provider_config.get("api_key") else (api_key_source or "preset"),
}
display_web_search_config = (
web_search_providers_payload.get(normalized_web_search["default_provider"])
or _web_search_provider_defaults(normalized_web_search["default_provider"])
)
ocr_api_key, ocr_api_key_source = _resolve_ocr_api_key(normalized_ocr)
barentswatch_record = await get_barentswatch_config_record(db)
barentswatch_auth = barentswatch_record.auth_config if barentswatch_record else {}
barentswatch_auth = barentswatch_auth or {}
resolved_barentswatch = await resolve_barentswatch_config(db)
return {
"ai_provider": {
"service_url": ai_config["service_url"],
"service_token": _mask_secret(*_resolve_service_token(normalized_ai)),
"default_provider": default_provider,
"provider": default_provider,
"provider_api": display_llm_config.get("provider_api") or "anthropic-messages",
"base_url": display_llm_config.get("base_url") or "https://api.minimaxi.com/anthropic",
"model": display_llm_config.get("model") or "MiniMax-M2.7",
"api_key": display_llm_config.get("api_key") or _mask_secret(None),
"providers": providers_payload,
"max_tokens": int(display_llm_config.get("max_tokens") or 1200),
"anthropic_version": display_llm_config.get("anthropic_version") or "2023-06-01",
"timeout_seconds": ai_config["timeout_seconds"],
"retry_attempts": ai_config["retry_attempts"],
"source": "runtime" if runtime_setting else "env",
},
"barentswatch": {
"endpoint": resolved_barentswatch.endpoint,
"client_id": barentswatch_auth.get("client_id") or resolved_barentswatch.client_id,
"client_secret": _mask_secret(
barentswatch_auth.get("client_secret") or resolved_barentswatch.client_secret
),
"source": resolved_barentswatch.credential_source,
},
"web_search": {
"enabled": normalized_web_search["enabled"],
"default_provider": normalized_web_search["default_provider"],
"provider": normalized_web_search["default_provider"],
"base_url": display_web_search_config.get("base_url") or "",
"api_key": display_web_search_config.get("api_key") or _mask_secret(None),
"providers": web_search_providers_payload,
"max_results": int(display_web_search_config.get("max_results") or 5),
"timeout_seconds": int(display_web_search_config.get("timeout_seconds") or 20),
"endpoint_path": display_web_search_config.get("endpoint_path") or "",
"search_depth": display_web_search_config.get("search_depth") or "basic",
"engine": display_web_search_config.get("engine") or "google",
"include_answer": bool(display_web_search_config.get("include_answer", False)),
"include_raw_content": bool(display_web_search_config.get("include_raw_content", False)),
"include_text": bool(display_web_search_config.get("include_text", False)),
"categories": display_web_search_config.get("categories") or "general",
"engines": display_web_search_config.get("engines") or [],
"search_path": display_web_search_config.get("search_path") or "",
"scrape_path": display_web_search_config.get("scrape_path") or "",
"scrape_formats": display_web_search_config.get("scrape_formats") or ["markdown"],
"source": "runtime" if runtime_setting else "env",
},
"ocr": {
"enabled": normalized_ocr["enabled"],
"provider": normalized_ocr["provider"],
"base_url": normalized_ocr["base_url"],
"api_key": _mask_secret(ocr_api_key, ocr_api_key_source),
"model": normalized_ocr["model"],
"languages": normalized_ocr["languages"],
"timeout_seconds": normalized_ocr["timeout_seconds"],
"max_file_size_mb": normalized_ocr["max_file_size_mb"],
"output_format": normalized_ocr["output_format"],
"source": "runtime" if normalized_ocr.get("api_key") else (ocr_api_key_source or "default"),
},
}
async def save_external_integrations_payload(
db: AsyncSession,
update: ExternalIntegrationsUpdate,
) -> dict:
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _build_ai_provider_payload(current_payload, update.ai_provider)
web_search_payload = _build_web_search_payload(current_payload, update.web_search)
ocr_payload = _build_ocr_payload(current_payload, update.ocr)
await save_setting_payload(
db,
"external_integrations",
{"ai_provider": ai_payload, "web_search": web_search_payload, "ocr": ocr_payload},
)
default_endpoint = get_data_sources_config().get_yaml_url("barentswatch_vessels")
barentswatch_record = await get_barentswatch_config_record(db)
if barentswatch_record is None:
barentswatch_record = DataSourceConfig(
name="barentswatch_vessels",
description="BarentsWatch Live AIS credentials",
source_type="api",
endpoint=update.barentswatch.endpoint.strip() or default_endpoint,
auth_type="oauth_client",
auth_config={},
headers={},
config={},
is_active=True,
)
db.add(barentswatch_record)
current_auth = dict(barentswatch_record.auth_config or {})
if update.barentswatch.clear_client_secret:
current_auth.pop("client_secret", None)
elif update.barentswatch.client_secret not in (None, ""):
current_auth["client_secret"] = update.barentswatch.client_secret
current_auth["client_id"] = update.barentswatch.client_id.strip()
barentswatch_record.endpoint = update.barentswatch.endpoint.strip() or default_endpoint
barentswatch_record.auth_type = "oauth_client"
barentswatch_record.auth_config = current_auth
await db.commit()
return await serialize_external_integrations(db)
def format_frequency_label(minutes: int) -> str:
if minutes % 1440 == 0:
return f"{minutes // 1440}d"
if minutes % 60 == 0:
return f"{minutes // 60}h"
return f"{minutes}m"
async def get_ais_source_health_by_source(db: AsyncSession) -> dict[str, dict]:
result = await db.execute(select(AISSourceHealth))
return {item.source: item.to_dict() for item in result.scalars().all()}
def serialize_collector(datasource: DataSource, ais_health_by_source: dict[str, dict] | None = None) -> dict:
defaults = DEFAULT_DATASOURCES.get(datasource.source, {})
return {
"id": datasource.id,
"name": datasource.name,
"display_name": defaults.get("display_name") or datasource.name,
"source": datasource.source,
"module": datasource.module,
"priority": datasource.priority,
"frequency_minutes": datasource.frequency_minutes,
"frequency": format_frequency_label(datasource.frequency_minutes),
"is_active": datasource.is_active,
"last_run_at": to_iso8601_utc(datasource.last_run_at),
"last_status": datasource.last_status,
"next_run_at": to_iso8601_utc(datasource.next_run_at),
"is_free": bool(defaults.get("is_free", True)),
"requires_credentials": bool(defaults.get("requires_credentials", False)),
"credential_provider": defaults.get("credential_provider"),
"credential_status": defaults.get("credential_status", "none"),
"ais_health": (ais_health_by_source or {}).get(datasource.source),
}
@router.get("/system")
async def get_system_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"system": await get_setting_payload(db, "system")}
@router.put("/system")
async def update_system_settings(
settings: SystemSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
payload = await save_setting_payload(db, "system", settings.model_dump())
return {"status": "updated", "system": payload}
@router.get("/notifications")
async def get_notification_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"notifications": await get_setting_payload(db, "notifications")}
@router.put("/notifications")
async def update_notification_settings(
settings: NotificationSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
payload = await save_setting_payload(db, "notifications", settings.model_dump())
return {"status": "updated", "notifications": payload}
@router.get("/security")
async def get_security_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"security": await get_setting_payload(db, "security")}
@router.put("/security")
async def update_security_settings(
settings: SecuritySettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
payload = await save_setting_payload(db, "security", settings.model_dump())
return {"status": "updated", "security": payload}
def _serialize_smtp_payload(payload: dict) -> dict:
password = str(payload.get("password") or "")
return {
"host": payload.get("host") or "",
"port": int(payload.get("port") or 587),
"username": payload.get("username") or "",
"password": _mask_secret(password, "runtime" if password else ""),
"from_address": payload.get("from_address") or "",
"from_name": payload.get("from_name") or "Planet",
"use_tls": bool(payload.get("use_tls", False)),
"use_starttls": bool(payload.get("use_starttls", True)),
"timeout_seconds": int(payload.get("timeout_seconds") or 20),
"configured": bool(payload.get("host") and payload.get("from_address")),
}
def _build_smtp_payload(current_payload: dict, update: SMTPSettingsUpdate) -> dict:
current_password = str(current_payload.get("password") or "")
current_preview = _mask_secret(current_password, "runtime" if current_password else "")["preview"]
if update.clear_password:
password = ""
elif _is_secret_placeholder(update.password, current_preview):
password = current_password
else:
password = str(update.password).strip()
return {
"host": update.host.strip(),
"port": update.port,
"username": update.username.strip(),
"password": password,
"from_address": update.from_address.strip(),
"from_name": update.from_name.strip() or "Planet",
"use_tls": update.use_tls,
"use_starttls": update.use_starttls,
"timeout_seconds": update.timeout_seconds,
}
@router.get("/smtp")
async def get_smtp_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"smtp": _serialize_smtp_payload(await get_setting_payload(db, "smtp"))}
@router.put("/smtp")
async def update_smtp_settings(
payload: SMTPSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role not in ("admin", "super_admin"):
raise HTTPException(status_code=403, detail="Only administrators can change SMTP settings")
current = await get_setting_payload(db, "smtp")
merged = _build_smtp_payload(current, payload)
saved = await save_setting_payload(db, "smtp", merged)
return {"status": "updated", "smtp": _serialize_smtp_payload(saved)}
@router.post("/smtp/test")
async def test_smtp_settings(
payload: SMTPTestRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if current_user.role not in ("admin", "super_admin"):
raise HTTPException(status_code=403, detail="Only administrators can test SMTP settings")
from app.services.email import EmailError, send_email
current = await get_setting_payload(db, "smtp")
config = _build_smtp_payload(current, payload.settings) if payload.settings else current
if not config.get("host") or not config.get("from_address"):
raise HTTPException(
status_code=400,
detail="Host and from_address are required to send a test email",
)
try:
await send_email(
db,
to=payload.to,
subject="Planet SMTP test",
text_body="This is a test email from Planet SMTP settings.",
html_body="<p>This is a test email from Planet SMTP settings.</p>",
config=config,
)
except EmailError as exc:
return {"success": False, "message": str(exc), "code": exc.code}
return {"success": True, "message": "Test email sent"}
@router.get("/tv")
async def get_tv_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"tv": await get_tv_settings_payload(db)}
@router.put("/tv")
async def update_tv_settings(
settings: TVSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
payload = normalize_tv_settings(settings.model_dump())
saved = await save_setting_payload(db, "tv", payload)
return {"status": "updated", "tv": normalize_tv_settings(saved)}
@router.get("/integrations")
async def get_external_integrations(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return {"integrations": await serialize_external_integrations(db)}
@router.get("/ai-prompts")
async def get_ai_prompts(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
prompts = await list_effective_prompts(db)
return {"data": [serialize_effective_prompt(prompt) for prompt in prompts]}
@router.put("/ai-prompts/{task_key}")
async def update_ai_prompt(
task_key: str,
payload: AIPromptUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
prompt = await save_prompt_override(
db,
task_key,
system_prompt=payload.system_prompt,
prompt=payload.prompt,
)
except KeyError:
raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None
return {"data": serialize_effective_prompt(prompt)}
@router.post("/ai-prompts/{task_key}/reset")
async def reset_ai_prompt(
task_key: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
prompt = await reset_prompt_override(db, task_key)
except KeyError:
raise HTTPException(status_code=404, detail="未知 AI 提示词任务") from None
return {"data": serialize_effective_prompt(prompt)}
@router.get("/integrations/barentswatch/connectivity")
async def get_barentswatch_connectivity(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
return await check_barentswatch_connectivity(db)
@router.post("/integrations/barentswatch/connect")
async def connect_barentswatch_integration(
payload: BarentsWatchIntegrationUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current = await resolve_barentswatch_config(db)
config = BarentsWatchConfig(
endpoint=payload.endpoint.strip() or current.endpoint,
client_id=payload.client_id.strip() or current.client_id,
client_secret=(
""
if payload.clear_client_secret
else payload.client_secret or current.client_secret
),
credential_source="draft",
endpoint_source="draft",
)
result = await check_barentswatch_config(config)
if result.get("success"):
checksum, _context = await build_builtin_connectivity_checksum(
"barentswatch_vessels",
config.endpoint,
"none",
{},
{},
db,
credential_override={
"client_id": config.client_id,
"client_secret": config.client_secret,
},
)
validation = await save_connectivity_success(
db,
"barentswatch_vessels",
checksum,
result,
connected_by="connection_button",
)
await db.commit()
return {**result, "connected": True, "validation": validation}
return {**result, "connected": False}
@router.post("/integrations/ai-provider/connect")
async def connect_ai_provider_integration(
payload: AIProviderIntegrationUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
# Connection testing should validate the provider being edited, not the
# currently saved default provider. This is a transient draft only and is
# intentionally not persisted.
payload = payload.model_copy(update={"default_provider": payload.provider})
draft_ai_payload = _build_ai_provider_payload(current_payload, payload)
runtime_config = _runtime_config_from_ai_payload(draft_ai_payload)
quick_llm_config = {
**(runtime_config.get("llm_config") or {}),
"max_tokens": 1,
}
client = AIProviderClient(
service_url=runtime_config["service_url"],
service_token=runtime_config["service_token"],
timeout=min(int(runtime_config["timeout_seconds"] or 60), AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS),
retry_attempts=1,
llm_config=quick_llm_config,
)
await emit_business_log(
logger,
event="settings.ai_provider.connect.start",
message="AI provider connection test started",
category="ai",
service="api",
module=__name__,
user_id=current_user.id,
context={
"provider": payload.provider,
"model": payload.model,
"timeout_seconds": min(int(runtime_config["timeout_seconds"] or 60), AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS),
},
)
try:
status_result = await client.get_status()
if not status_result.configured:
await emit_business_log(
logger,
event="settings.ai_provider.connect.failed",
message="AI provider connection test failed because provider is incomplete",
category="ai",
level="warning",
service="api",
module=__name__,
user_id=current_user.id,
context={"provider": payload.provider, "model": payload.model, "configured": False},
)
return {
"success": False,
"connected": False,
"message": "AI Provider 可访问,但当前 provider/model/key 未完整配置。",
"status": status_result.model_dump(),
}
lightweight_result = await _check_ai_provider_lightweight(
quick_llm_config,
timeout_seconds=min(
int(runtime_config["timeout_seconds"] or 60),
AI_PROVIDER_QUICK_CONNECT_TIMEOUT_SECONDS,
),
)
await emit_business_log(
logger,
event="settings.ai_provider.connect.success",
message="AI provider connection test completed",
category="ai",
service="api",
module=__name__,
user_id=current_user.id,
context={
"provider": payload.provider,
"model": payload.model,
"configured": True,
"lightweight_status": lightweight_result.get("status"),
},
)
return {
**lightweight_result,
"status": status_result.model_dump(),
}
except HTTPException as exc:
return {
"success": False,
"connected": False,
"message": str(exc.detail),
}
except Exception as exc:
await emit_business_log(
logger,
event="settings.ai_provider.connect.failed",
message="AI provider connection test failed",
category="ai",
level="error",
service="api",
module=__name__,
user_id=current_user.id,
context=exception_context(exc, {"provider": payload.provider, "model": payload.model}),
)
return {
"success": False,
"connected": False,
"message": f"AI Provider 连接测试失败: {exc}",
}
@router.get("/integrations/ai-provider/secrets")
async def reveal_ai_provider_secrets(
request: Request,
provider: str = Query(default=""),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
requested_provider = _normalize_provider_id(provider) if provider else "default"
await _ensure_secret_reveal_allowed(
current_user=current_user,
request=request,
target_id=f"ai_provider:{requested_provider}",
details={"kind": "ai_provider", "provider": requested_provider},
)
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_id = _normalize_provider_id(provider or ai_payload["default_provider"])
provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, api_key_source = _resolve_provider_api_key(provider_id, provider_config)
service_token, service_token_source = _resolve_service_token(ai_payload)
await _record_integration_secret_reveal(
current_user=current_user,
request=request,
target_id=f"ai_provider:{provider_id}",
result="success",
details={
"kind": "ai_provider",
"provider": provider_id,
"api_key_configured": bool(api_key),
"api_key_source": api_key_source,
"service_token_configured": bool(service_token),
"service_token_source": service_token_source,
},
)
return {
"provider": provider_id,
"api_key": api_key,
"api_key_source": api_key_source,
"service_token": service_token,
"service_token_source": service_token_source,
}
@router.get("/integrations/web-search/presets")
async def get_web_search_presets(
current_user: User = Depends(get_current_user),
):
return {"data": list_web_search_provider_presets()}
@router.get("/integrations/web-search/secrets")
async def reveal_web_search_secrets(
request: Request,
provider: str = Query(default=""),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
requested_provider = normalize_web_search_provider(provider) if provider else "default"
await _ensure_secret_reveal_allowed(
current_user=current_user,
request=request,
target_id=f"web_search:{requested_provider}",
details={"kind": "web_search", "provider": requested_provider},
)
current_payload = await get_setting_payload(db, "external_integrations")
web_search_payload = _normalize_web_search_payload(current_payload.get("web_search") or {})
provider_id = normalize_web_search_provider(provider or web_search_payload["default_provider"])
provider_config = (
web_search_payload["providers"].get(provider_id)
or _web_search_provider_defaults(provider_id)
)
api_key, api_key_source = _resolve_web_search_api_key(
provider_id,
provider_config,
web_search_payload["default_provider"],
)
await _record_integration_secret_reveal(
current_user=current_user,
request=request,
target_id=f"web_search:{provider_id}",
result="success",
details={
"kind": "web_search",
"provider": provider_id,
"api_key_configured": bool(api_key),
"api_key_source": api_key_source,
},
)
return {
"provider": provider_id,
"api_key": api_key,
"api_key_source": api_key_source,
}
@router.get("/integrations/ocr/secrets")
async def reveal_ocr_secrets(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
await _ensure_secret_reveal_allowed(
current_user=current_user,
request=request,
target_id="ocr:default",
details={"kind": "ocr", "provider": "default"},
)
current_payload = await get_setting_payload(db, "external_integrations")
ocr_payload = _normalize_ocr_payload(current_payload.get("ocr") or {})
api_key, api_key_source = _resolve_ocr_api_key(ocr_payload)
await _record_integration_secret_reveal(
current_user=current_user,
request=request,
target_id=f"ocr:{ocr_payload['provider']}",
result="success",
details={
"kind": "ocr",
"provider": ocr_payload["provider"],
"api_key_configured": bool(api_key),
"api_key_source": api_key_source,
},
)
return {
"provider": ocr_payload["provider"],
"api_key": api_key,
"api_key_source": api_key_source,
}
@router.post("/integrations/web-search/connect")
async def connect_web_search_integration(
payload: WebSearchIntegrationUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_payload = await get_setting_payload(db, "external_integrations")
draft_web_search_payload = _build_web_search_payload(current_payload, payload)
runtime_config = _runtime_config_from_web_search_payload(draft_web_search_payload)
client = WebSearchClient(runtime_config)
await emit_business_log(
logger,
event="settings.web_search.connect.start",
message="WebSearch connection test started",
category="ai_tool",
service="api",
module=__name__,
user_id=current_user.id,
context={"provider": runtime_config.default_provider},
)
try:
results = await client.test_connection()
await emit_business_log(
logger,
event="settings.web_search.connect.success",
message="WebSearch connection test completed",
category="ai_tool",
service="api",
module=__name__,
user_id=current_user.id,
context={"provider": runtime_config.default_provider, "result_count": len(results)},
)
return {
"success": True,
"connected": True,
"message": "WebSearch 连接成功。",
"provider": runtime_config.default_provider,
"results": [item.model_dump(mode="json") for item in results[:3]],
}
except WebSearchConfigurationError as exc:
await emit_business_log(
logger,
event="settings.web_search.connect.failed",
message="WebSearch connection test failed because configuration is incomplete",
category="ai_tool",
level="warning",
service="api",
module=__name__,
user_id=current_user.id,
context=exception_context(exc, {"provider": runtime_config.default_provider}),
)
return {
"success": False,
"connected": False,
"message": str(exc),
}
except WebSearchError as exc:
await emit_business_log(
logger,
event="settings.web_search.connect.failed",
message="WebSearch connection test failed",
category="ai_tool",
level="error",
service="api",
module=__name__,
user_id=current_user.id,
context=exception_context(exc, {"provider": runtime_config.default_provider}),
)
return {
"success": False,
"connected": False,
"message": str(exc),
}
except Exception as exc:
await emit_business_log(
logger,
event="settings.web_search.connect.failed",
message="WebSearch connection test failed",
category="ai_tool",
level="error",
service="api",
module=__name__,
user_id=current_user.id,
context=exception_context(exc, {"provider": runtime_config.default_provider}),
)
return {
"success": False,
"connected": False,
"message": f"WebSearch 连接测试失败: {exc}",
}
@router.get("/credential-guides/{provider}")
async def read_credential_guide(
provider: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return {"guide": await get_credential_guide(db, provider)}
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.post("/credential-guides/{provider}/generate")
async def generate_provider_credential_guide(
provider: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
ai_client: AIProviderClient = Depends(get_ai_provider_client),
):
try:
await emit_business_log(
logger,
event="settings.credential_guide.generate.start",
message="Credential guide generation started",
category="ai",
service="api",
module=__name__,
user_id=current_user.id,
context={"provider": provider},
)
web_search_client = await get_web_search_client(db)
guide = await generate_credential_guide(
db,
provider,
ai_client,
web_search_client,
)
await emit_business_log(
logger,
event="settings.credential_guide.generate.success",
message="Credential guide generation completed",
category="ai",
service="api",
module=__name__,
user_id=current_user.id,
context={"provider": provider},
)
return {"guide": guide}
except ValueError as exc:
await emit_business_log(
logger,
event="settings.credential_guide.generate.failed",
message="Credential guide generation failed",
category="ai",
level="warning",
service="api",
module=__name__,
user_id=current_user.id,
context=exception_context(exc, {"provider": provider}),
)
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
await emit_business_log(
logger,
event="settings.credential_guide.generate.failed",
message="Credential guide generation failed",
category="ai",
level="error",
service="api",
module=__name__,
user_id=current_user.id,
context=exception_context(exc, {"provider": provider}),
)
raise
@router.post("/credential-guides/{provider}/reset")
async def reset_provider_credential_guide(
provider: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
return {"guide": await reset_credential_guide(db, provider)}
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@router.get("/integrations/ai-provider/presets")
async def get_ai_provider_presets(
current_user: User = Depends(get_current_user),
):
return {"data": list_fallback_llm_provider_presets()}
@router.post("/integrations/ai-provider/presets/{provider}/refresh")
async def refresh_ai_provider_preset(
provider: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
try:
provider_id = _normalize_provider_id(provider)
api_key = None
if provider_id == "opencode-go":
current_payload = await get_setting_payload(db, "external_integrations")
ai_payload = _normalize_ai_provider_payload(current_payload.get("ai_provider") or {})
provider_config = ai_payload["providers"].get(provider_id) or _provider_defaults(provider_id)
api_key, _api_key_source = _resolve_provider_api_key(provider_id, provider_config)
return {"data": await refresh_llm_provider_preset(provider_id, api_key=api_key)}
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except Exception as exc:
fallback = get_fallback_llm_provider_preset(provider)
fallback["refresh_error"] = str(exc)
return {"data": fallback}
@router.put("/integrations")
async def update_external_integrations(
payload: ExternalIntegrationsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
saved = await save_external_integrations_payload(db, payload)
return {"status": "updated", "integrations": saved}
@router.get("/collectors")
async def get_collector_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
datasources = result.scalars().all()
ais_health_by_source = await get_ais_source_health_by_source(db)
return {"collectors": [serialize_collector(datasource, ais_health_by_source) for datasource in datasources]}
@router.put("/collectors/{datasource_id}")
async def update_collector_settings(
datasource_id: int,
settings: CollectorSettingsUpdate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
datasource = await db.get(DataSource, datasource_id)
if not datasource:
raise HTTPException(status_code=404, detail="Data source not found")
datasource.is_active = settings.is_active
datasource.priority = settings.priority
datasource.frequency_minutes = settings.frequency_minutes
await db.commit()
await db.refresh(datasource)
await sync_datasource_job(datasource.id)
ais_health_by_source = await get_ais_source_health_by_source(db)
return {"status": "updated", "collector": serialize_collector(datasource, ais_health_by_source)}
@router.get("")
async def get_all_settings(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(DataSource).order_by(DataSource.module, DataSource.id))
datasources = result.scalars().all()
setting_payloads = await get_setting_payloads(
db,
["system", "notifications", "security"],
)
ais_health_by_source = await get_ais_source_health_by_source(db)
return {
"system": setting_payloads["system"],
"notifications": setting_payloads["notifications"],
"security": setting_payloads["security"],
"tv": await get_tv_settings_payload(db),
"integrations": await serialize_external_integrations(db),
"collectors": [serialize_collector(datasource, ais_health_by_source) for datasource in datasources],
"generated_at": to_iso8601_utc(datetime.now(UTC)),
}