2133 lines
79 KiB
Python
2133 lines
79 KiB
Python
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)),
|
||
}
|