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.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 router = APIRouter() 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, }, "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) 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 {}, ) 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=["回复尽量简短。"], ) ) 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="

This is a test email from Planet SMTP settings.

", 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, ) try: status_result = await client.get_status() if not status_result.configured: 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, ), ) 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: 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) try: results = await client.test_connection() 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: return { "success": False, "connected": False, "message": str(exc), } except WebSearchError as exc: return { "success": False, "connected": False, "message": str(exc), } except Exception as exc: 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: web_search_client = await get_web_search_client(db) return { "guide": await generate_credential_guide( db, provider, ai_client, web_search_client, ) } except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @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)), }