ai-robot-core/ai-service/app/api/admin/llm.py

240 lines
6.7 KiB
Python
Raw Normal View History

"""
LLM Configuration Management API.
[AC-ASA-14, AC-ASA-15, AC-ASA-16, AC-ASA-17, AC-ASA-18] LLM provider management endpoints.
"""
import logging
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException
from app.services.llm.factory import (
LLMProviderFactory,
LLMUsageType,
LLM_USAGE_DISPLAY_NAMES,
LLM_USAGE_DESCRIPTIONS,
get_llm_config_manager,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/llm", tags=["LLM Management"])
def get_tenant_id(x_tenant_id: str = Header(..., alias="X-Tenant-Id")) -> str:
"""Extract tenant ID from header."""
if not x_tenant_id:
raise HTTPException(status_code=400, detail="X-Tenant-Id header is required")
return x_tenant_id
@router.get("/providers")
async def list_providers(
tenant_id: str = Depends(get_tenant_id),
) -> dict[str, Any]:
"""
List all available LLM providers.
[AC-ASA-15] Returns provider list with configuration schemas.
"""
logger.info(f"[AC-ASA-15] Listing LLM providers for tenant={tenant_id}")
providers = LLMProviderFactory.get_providers()
return {
"providers": [
{
"name": p.name,
"display_name": p.display_name,
"description": p.description,
"config_schema": p.config_schema,
}
for p in providers
],
}
@router.get("/usage-types")
async def list_usage_types(
tenant_id: str = Depends(get_tenant_id),
) -> dict[str, Any]:
"""
List all available LLM usage types.
"""
logger.info(f"Listing LLM usage types for tenant={tenant_id}")
return {
"usage_types": [
{
"name": ut.value,
"display_name": LLM_USAGE_DISPLAY_NAMES[ut],
"description": LLM_USAGE_DESCRIPTIONS[ut],
}
for ut in LLMUsageType
],
}
@router.get("/config")
async def get_config(
tenant_id: str = Depends(get_tenant_id),
usage_type: str | None = None,
) -> dict[str, Any]:
"""
Get current LLM configuration.
[AC-ASA-14] Returns current provider and config.
If usage_type is specified, returns config for that usage type.
Otherwise, returns all configs.
"""
logger.info(f"[AC-ASA-14] Getting LLM config for tenant={tenant_id}, usage_type={usage_type}")
manager = get_llm_config_manager()
if usage_type:
try:
ut = LLMUsageType(usage_type)
config = manager.get_current_config(ut)
masked_config = _mask_secrets(config.get("config", {}))
return {
"usage_type": config["usage_type"],
"provider": config["provider"],
"config": masked_config,
}
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid usage_type: {usage_type}")
all_configs = manager.get_current_config()
result = {}
for ut_key, config in all_configs.items():
result[ut_key] = {
"provider": config["provider"],
"config": _mask_secrets(config.get("config", {})),
}
return result
@router.put("/config")
async def update_config(
body: dict[str, Any],
tenant_id: str = Depends(get_tenant_id),
) -> dict[str, Any]:
"""
Update LLM configuration.
[AC-ASA-16] Updates provider and config with validation.
Request body format:
- For specific usage type:
{
"usage_type": "chat" | "kb_processing",
"provider": "openai",
"config": {...}
}
- For all usage types (backward compatible):
{
"provider": "openai",
"config": {...}
}
"""
provider = body.get("provider")
config = body.get("config", {})
usage_type_str = body.get("usage_type")
logger.info(f"[AC-ASA-16] Updating LLM config for tenant={tenant_id}, provider={provider}, usage_type={usage_type_str}")
if not provider:
return {
"success": False,
"message": "Provider is required",
}
try:
manager = get_llm_config_manager()
if usage_type_str:
try:
usage_type = LLMUsageType(usage_type_str)
await manager.update_usage_config(usage_type, provider, config)
return {
"success": True,
"message": f"LLM configuration updated for {usage_type_str} to {provider}",
}
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid usage_type: {usage_type_str}")
else:
await manager.update_config(provider, config)
return {
"success": True,
"message": f"LLM configuration updated to {provider}",
}
except ValueError as e:
logger.error(f"[AC-ASA-16] Invalid LLM config: {e}")
return {
"success": False,
"message": str(e),
}
@router.post("/test")
async def test_connection(
body: dict[str, Any] | None = None,
tenant_id: str = Depends(get_tenant_id),
) -> dict[str, Any]:
"""
Test LLM connection.
[AC-ASA-17, AC-ASA-18] Tests connection and returns response.
Request body format:
{
"test_prompt": "optional test prompt",
"provider": "optional provider to test",
"config": "optional config to test",
"usage_type": "optional usage type to test"
}
"""
body = body or {}
test_prompt = body.get("test_prompt", "你好,请简单介绍一下自己。")
provider = body.get("provider")
config = body.get("config")
usage_type_str = body.get("usage_type")
logger.info(
f"[AC-ASA-17] Testing LLM connection for tenant={tenant_id}, "
f"provider={provider or 'current'}, usage_type={usage_type_str or 'default'}"
)
manager = get_llm_config_manager()
usage_type = None
if usage_type_str:
try:
usage_type = LLMUsageType(usage_type_str)
except ValueError:
return {
"success": False,
"error": f"Invalid usage_type: {usage_type_str}",
}
result = await manager.test_connection(
test_prompt=test_prompt,
provider=provider,
config=config,
usage_type=usage_type,
)
return result
def _mask_secrets(config: dict[str, Any]) -> dict[str, Any]:
"""Mask secret fields in config for display."""
masked = {}
for key, value in config.items():
if key in ("api_key", "password", "secret"):
if value:
masked[key] = f"{str(value)[:4]}***"
else:
masked[key] = ""
else:
masked[key] = value
return masked