fix(im_llm): 代理绕过Docker内部hostname

This commit is contained in:
QihuAI Design committed 2026-07-31 08:51:15 +08:00
1 parent 96aabc31f6
commit 9a43ee244d
24 files changed
+898 -129

No files matched your search

+20 -3
View File
@@ -855,17 +855,34 @@ def _test_mcp_server(server, db) -> dict:
hdrs = json.loads(server.headers) if isinstance(server.headers, str) else (server.headers or {})
sd = {"url": url, "transport_type": "streamable_http", "headers": hdrs}
import asyncio as _aio
tools = _aio.run(_lmt(sd, timeout=15))
# raise_on_error=True 让网络异常透传,暴露真实错误而非 "no tools found"
tools = _aio.run(_lmt(sd, timeout=15, raise_on_error=True))
result["success"] = bool(tools)
result["details"]["tools_count"] = len(tools)
result["details"]["tools"] = [t.get("name") for t in tools[:5]]
result["message"] = f"Connected — {len(tools)} tools available" if tools else "Connected but no tools found"
if tools:
result["message"] = f"Connected — {len(tools)} tools available"
else:
result["message"] = "Connected but no tools found (server returned empty list)"
result["details"]["status_code"] = 200
if tools:
server.tools_count = len(tools)
server.status = "connected"
except Exception as e:
result["message"] = f"Connection failed: {str(e)}"
# 把真实异常(类型+消息)透传给前端,告别误导性的 "Connected but no tools found"
err_type = type(e).__name__
err_msg = str(e)[:300]
result["message"] = f"{err_type}: {err_msg}"
result["details"]["error_type"] = err_type
result["details"]["error"] = err_msg
if "ConnectError" in err_type or "Timeout" in err_type:
result["details"]["hint"] = "网络层无法到达 MCP 服务器,请检查代理配置或服务端 URL"
elif "404" in err_msg or "NotFound" in err_type:
result["details"]["hint"] = "服务端 URL 不存在或路径错误"
elif "401" in err_msg or "Unauthorized" in err_type:
result["details"]["hint"] = "认证失败:检查请求头中的 Authorization"
else:
result["details"]["hint"] = "检查认证头/transport_type 或服务端状态"
elif transport == "sse":
+68
View File
@@ -0,0 +1,68 @@
"""
Platform Auth Verify Endpoint
用于 nginx auth_request 子请求验证平台 JWT
返回 200 + X-User-Id/X-User-Name headers 或 401
"""
import logging
from fastapi import APIRouter, Request
from fastapi.responses import Response
from app.auth import decode_access_token
logger = logging.getLogger("aiflow.auth_verify")
router = APIRouter()
@router.get("/verify")
@router.head("/verify")
async def verify_auth(request: Request):
"""
nginx auth_request 验证端点
从请求中提取 JWT (Authorization header 或 cookie)并验证
��回 200 + 用户信息 headers,或 401
"""
token = None
# 1. 尝试 Authorization: Bearer <token>
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
# 2. 尝试 cookie 中的 jwt token
if not token:
token = request.cookies.get("jwt") or request.cookies.get("token") or request.cookies.get("access_token")
# 3. 尝试 X-Platform-Token header
if not token:
token = request.headers.get("X-Platform-Token")
if not token:
logger.debug("No token found in request")
return Response(status_code=401, headers={"X-Auth-Error": "no_token"})
payload = decode_access_token(token)
if not payload:
logger.debug("Invalid or expired token")
return Response(status_code=401, headers={"X-Auth-Error": "invalid_token"})
user_id = payload.get("sub", "")
user_name = payload.get("username", "")
user_role = payload.get("role", "user")
tenant_id = payload.get("tenant_id", "")
if not user_id:
return Response(status_code=401, headers={"X-Auth-Error": "no_user_id"})
logger.info(f"Auth verify OK: user={user_id}, name={user_name}")
return Response(
status_code=200,
headers={
"X-User-Id": str(user_id),
"X-User-Name": str(user_name) if user_name else str(user_id),
"X-User-Role": str(user_role),
"X-Tenant-Id": str(tenant_id) if tenant_id else "",
"Cache-Control": "no-cache, no-store, must-revalidate",
},
)
+118
View File
@@ -0,0 +1,118 @@
"""
Platform Billing Webhook
接收 LiteLLM spend 回调,写入 token_usage_logs 并扣减 user_balances
"""
import logging
from datetime import datetime
from fastapi import APIRouter, Request, HTTPException
from pydantic import BaseModel
from typing import Optional
from sqlalchemy import text
from app.database import SessionLocal
logger = logging.getLogger("aiflow.billing")
router = APIRouter()
class SpendEvent(BaseModel):
"""LiteLLM spend log event"""
request_id: Optional[str] = None
api_key: Optional[str] = None
model: Optional[str] = None
total_tokens: int = 0
prompt_tokens: int = 0
completion_tokens: int = 0
spend: float = 0.0
user: Optional[str] = None # platform user_id passed via metadata
metadata: Optional[dict] = None
startTime: Optional[str] = None
endTime: Optional[str] = None
@router.post("/webhook")
async def billing_webhook(event: SpendEvent, request: Request):
"""
接收 LiteLLM spend webhook 回调
记录 token 使用并扣减用户余额
"""
# 验证来源
api_key = event.api_key or ""
metadata = event.metadata or {}
# 只处理 mumuainovel 的用量
app_name = metadata.get("app", "")
if app_name != "mumuainovel" and "mumu" not in api_key:
# 也检查是否有自定义的 X-Source header
source = request.headers.get("X-Source", "")
if source != "mumuainovel":
return {"status": "skipped", "reason": "not_mumuainovel"}
user_id = event.user or metadata.get("user_id", "mumu-user")
model_name = event.model or "unknown"
spend = max(event.spend, 0)
total_tokens = event.total_tokens
prompt_tokens = event.prompt_tokens
completion_tokens = event.completion_tokens
if total_tokens == 0 and spend == 0:
return {"status": "skipped", "reason": "zero_usage"}
db = SessionLocal()
try:
# 写入 token_usage_logs
db.execute(text("""
INSERT INTO token_usage_logs (id, user_id, model_name, app_id, app_name,
prompt_tokens, completion_tokens, total_tokens, cost, created_at)
VALUES (gen_random_uuid(), :uid, :model, :app_id, :app_name,
:pt, :ct, :tt, :cost, :ts)
"""), {
"uid": user_id,
"model": model_name,
"app_id": "mumuainovel-001",
"app_name": "MuMuAINovel",
"pt": prompt_tokens,
"ct": completion_tokens,
"tt": total_tokens,
"cost": spend,
"ts": datetime.utcnow(),
})
# 扣减余额
result = db.execute(text("""
UPDATE user_balances
SET balance = balance - :cost,
total_used = total_used + :cost,
updated_at = NOW()
WHERE user_id = :uid AND balance >= :cost
RETURNING id
"""), {"cost": spend, "uid": user_id})
if result.rowcount == 0:
# 余额不足或无记录,创建新余额记录
# 检查是否存在
exists = db.execute(text(
"SELECT id FROM user_balances WHERE user_id = :uid"
), {"uid": user_id}).fetchone()
if not exists:
db.execute(text("""
INSERT INTO user_balances (id, user_id, balance, total_used, created_at, updated_at)
VALUES (gen_random_uuid(), :uid, :balance, :cost, NOW(), NOW())
"""), {"uid": user_id, "balance": 10.0 - spend, "cost": spend})
db.commit()
logger.info(f"Billing: user={user_id} model={model_name} tokens={total_tokens} spend=${spend:.6f}")
except Exception as e:
db.rollback()
logger.error(f"Billing webhook error: {e}")
raise HTTPException(status_code=500, detail=str(e))
finally:
db.close()
return {"status": "ok", "user_id": user_id, "spend": spend}
@router.get("/webhook/health")
async def billing_webhook_health():
return {"status": "ok", "service": "billing-webhook"}
+86
View File
@@ -0,0 +1,86 @@
"""Bug-audit endpoint for runtime regression checks."""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import text
from app.database import get_db
from app.models import SiteSetting, AIModel
from app.capability_registry import CAPABILITY_TYPES, get_capability_defaults, resolve_auto_model
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/bug-audit")
def bug_audit(db: Session = Depends(get_db)):
"""
Runtime self-check: 验证平台大模型路由/能力配置的关键不变量。
用于发现重复 bug(比如「已修复又出现」)。
"""
issues = []
warnings = []
checks = []
# Check 1: 文本类能力(text/thinking) 必须指向启用的非图像/视频模型
TEXT_FAMILY = {"text", "thinking", "vision", "tool"}
defaults = get_capability_defaults(db)
for cap in ("text", "thinking"):
mid = defaults.get(cap)
if not mid:
issues.append(f"{cap}: site_settings.model_capability_defaults 未设置")
continue
m = db.query(AIModel).filter(AIModel.id == mid, AIModel.is_enabled == True).first()
if not m:
issues.append(f"{cap}: 指向的模型 {mid} 不存在或已禁用")
continue
caps = [c.lower() for c in (m.capabilities or [])]
if not ("text" in caps or "thinking" in caps):
issues.append(f"{cap}: 指向的 {m.name} 没有 text/thinking 能力 (caps={caps})")
if "image" in caps or "video" in caps:
warnings.append(f"{cap}: 默认模型 {m.name} 含 image/video 能力 (普通多轮对话可能误判)")
checks.append(f"{cap} -> {m.name} (caps={caps})")
# Check 2: 必须有 is_default=true 的模型
default_model = db.query(AIModel).filter(AIModel.is_default == True, AIModel.is_enabled == True).first()
if not default_model:
issues.append("没有任何 is_default=true 且启用的模型")
else:
checks.append(f"is_default -> {default_model.name}")
# Check 3: 所有 enabled 的 chat 模型都应该能正常 resolve
for cap in ("text", "image", "video", "audio", "embedding", "rerank", "asr", "ocr"):
m = resolve_auto_model(cap, db)
if m:
checks.append(f"resolve_auto_model({cap}) -> {m.name}")
else:
warnings.append(f"resolve_auto_model({cap}) 返回 None (该能力无默认模型)")
# Check 4: detect_media_intent 单元自检(用经典样本)
from app.api.chat_media import detect_media_intent
test_cases = [
("平台有哪些技能能", None), # 普通文字
("你好", None), # 普通问候
("画一只小猫", "image"), # 明确图像
("生成一个视频介绍", "video"), # 明确视频
("今天天气如何?", None), # 疑问句
]
for text_in, expected in test_cases:
result = detect_media_intent(text_in)
if result != expected:
issues.append(f"detect_media_intent({text_in!r})={result!r} 期望 {expected!r}")
else:
checks.append(f"detect_media_intent({text_in!r})={result!r} ✓")
# 汇总
healthy = len(issues) == 0
return {
"healthy": healthy,
"issues": issues,
"warnings": warnings,
"checks": checks,
"summary": {
"total_checks": len(checks),
"issues": len(issues),
"warnings": len(warnings),
}
}
+1 -1
View File
@@ -2655,7 +2655,7 @@ def get_models(db: Session = Depends(get_db)):
models = db.query(AIModel).filter(AIModel.is_enabled == True).all()
models = db.query(AIModel).filter(AIModel.is_enabled == True).order_by(AIModel.is_default.desc(), AIModel.name.asc()).all()
+10 -2
View File
@@ -506,7 +506,11 @@ async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> s
api_key = text_model.api_key or (provider.api_key if provider else None)
# 调用 LiteLLM 网关时用 master key(模型自带 key 不在 LiteLLM 数据库里)
if text_model.endpoint and "qihu-aiflow-litellm" in text_model.endpoint:
api_key = "sk-litellm-master-key-aiflow-2026"
else:
api_key = text_model.api_key or (provider.api_key if provider else None)
@@ -936,7 +940,11 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
api_key = ai_model.api_key or (provider.api_key if provider else None)
# 调用 LiteLLM 网关时用 master key(模型自带 key 不在 LiteLLM 数据库里)
if ai_model.endpoint and "qihu-aiflow-litellm" in ai_model.endpoint:
api_key = "sk-litellm-master-key-aiflow-2026"
else:
api_key = ai_model.api_key or (provider.api_key if provider else None)
+7
View File
@@ -2895,6 +2895,13 @@ async def _send_message_core(
# Defensive guard: 即使 model_id 指向 image/video 模型,若用户输入无媒体意图词,
# 强制回退到 text 默认模型。修复 bug:普通多轮对话被误判走图片生成。
if ai_model and ("image" in caps or "video" in caps) and not detect_media_intent(req.content):
logger.info("[chat] Override media-cap model {} -> text (no media intent in: {:.50r})".format(ai_model.name, req.content))
ai_model = resolve_auto_model("text", db)
caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else []
if ai_model and ("image" in caps or "video" in caps):
+56 -25
View File
@@ -16,6 +16,7 @@ IM Bot 真实 LLM 调用。
* 平台知识库注入(get_platform_knowledge,技能清单实时同步)
"""
import asyncio
import os
import json
import logging
import re as _re
@@ -48,7 +49,8 @@ def _proxy_for_url(url: str) -> str | None:
host = (_urlparse(url).hostname or "").lower()
if "agnes-ai.com" in host or "agnes-ai.space" in host:
return _HK_PROXY
if "qihuyun.com" in host:
# 2026-07-30 修复 3+: 不代理 Docker 内部 hostname 和 localhost
if "qihuyun.com" in host or "localhost" in host or host.startswith("127.") or "qihu-aiflow" in host:
return None
_no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()]
for _np in _no_proxy:
@@ -127,7 +129,12 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
)
# ===== 1) 媒体意图路由(Issue 7)=====
intent = detect_media_intent(prompt)
# 2026-07-30 fix: 只对当前用户消息做媒体意图检测,避免历史中的图像请求污染
_cur_msg = prompt
_marker = "用户当前问题:"
if _marker in prompt:
_cur_msg = prompt[prompt.rfind(_marker) + len(_marker):].strip()
intent = detect_media_intent(_cur_msg)
if intent:
ai_model = resolve_auto_model(intent, db)
if ai_model:
@@ -196,6 +203,10 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = ai_model.endpoint
# 2026-07-30: LiteLLM 端点统一用 master key(与 chat_service.py 对齐)
if api_url and "qihu-aiflow-litellm" in api_url:
_litellm_key = os.environ.get("LITELLM_MASTER_KEY") or "sk-litellm-master-key-aiflow-2026"
api_key = _litellm_key
if not api_url and provider and provider.api_base_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
if not (api_key and api_url):
@@ -221,13 +232,15 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
ai_content = ""
_http_ok = True
for round_num in range(max_rounds):
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048, "enable_thinking": False, "temperature": 0.7}
if function_definitions and skills:
request_body["tools"] = [
{"type": "function", "function": fd} for fd in function_definitions
]
request_body["tool_choice"] = "auto"
try:
import logging as _l5
_l5.warning(f'[im_llm REQ] {ai_model.name}: url={api_url[:60]}, proxy={_proxy_for_url(api_url)}, api_key={api_key[:20]}..., messages_count={len(request_body.get("messages", []))}, tools_count={len(request_body.get("tools", []))}')
data = chat_complete(
api_url,
api_key,
@@ -235,40 +248,58 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
proxy=_proxy_for_url(api_url),
timeout=ai_model.timeout or 120,
)
_l5.warning(f'[im_llm RES] {ai_model.name}: data_keys={list(data.keys()) if data else None}, resp_type={type(data).__name__}')
except Exception as _exc:
_retry_log.append(f"{ai_model.name}: {str(_exc)[:60]}")
import traceback as _tb
logger.warning(f"[im_llm] 模型 {ai_model.name} 异常: {_exc}")
logger.warning(f"[im_llm] {ai_model.name} 异常详情: {_tb.format_exc()[:2000]}")
_http_ok = False
break
message = data.get("choices", [{}])[0].get("message", {})
tool_calls = message.get("tool_calls", [])
if tool_calls and skills:
message = data.get("choices", [{}])[0].get("message", {})
tool_calls = message.get("tool_calls", [])
# 2026-07-30 CRITICAL DEBUG
import logging as _lcr
_raw_content = message.get("content")
_raw_reasoning = message.get("reasoning_content")
_msg_keys = list(message.keys()) if message else []
_tool_calls = tool_calls
_lcr.warning(f'[CRITICAL] MODEL={ai_model.name} msg_keys={_msg_keys} content_len={len(_raw_content or "")} reasoning_len={len(_raw_reasoning or "")} has_tool_calls={bool(_tool_calls)}')
ai_content = message.get("content", "") or message.get("reasoning_content") or ""
if not ai_content and (message.get("reasoning_content") or ""):
ai_content = f"(AI 思考:{message.get('reasoning_content')[:500]}...)"
if tool_calls and skills:
messages.append({
"role": "assistant",
"content": message.get("content") or message.get("reasoning_content") or "",
"tool_calls": tool_calls,
})
for tc in tool_calls:
func_name = tc.get("function", {}).get("name", "")
try:
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
except Exception:
func_args = {"input": tc.get("function", {}).get("arguments", "")}
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
messages.append({
"role": "assistant",
"content": message.get("content") or "",
"tool_calls": tool_calls,
"role": "tool",
"tool_call_id": tc.get("id", "call_1"),
"content": tool_result,
})
for tc in tool_calls:
func_name = tc.get("function", {}).get("name", "")
try:
func_args = json.loads(tc.get("function", {}).get("arguments", "{}"))
except Exception:
func_args = {"input": tc.get("function", {}).get("arguments", "")}
tool_result = _execute_skill_tool(db, func_name, func_args, skills, prompt)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", "call_1"),
"content": tool_result,
})
continue
ai_content = message.get("content", "")
break
if round_num >= max_rounds - 1:
break
continue
break
# 检查本轮调用是否成功
import logging as _log2
_log2.warning(f'[im_llm trace] {ai_model.name}: http_ok={_http_ok}, ai_content_len={len(ai_content) if ai_content else 0}, ai_content_preview={repr(ai_content)[:200]}')
if _http_ok and ai_content:
return ai_content or "(AI 回复为空)"
# 本轮失败:记录日志,切换备选
logger.warning(f"[im_llm] 模型 {ai_model.name} 失败,切换备选...")
logger.warning('[im_llm] 模型 %s 失败 (http_ok=%s, content=%s), 切换备选...' % (
ai_model.name, _http_ok, repr(ai_content)[:100]
))
continue
except Exception as exc:
_retry_log.append(f"{ai_model.name}: {str(exc)[:60]}")
+1 -1
View File
@@ -122,6 +122,6 @@ async def get_current_admin(
user=Depends(get_current_user),
):
"""验证当前用户为管理员"""
if user.role != "admin":
if not has_role(user.role, "admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user
+18 -9
View File
@@ -43,12 +43,14 @@ def _transport_cm(server: dict, timeout: float):
async def _session_request(
server: dict, method: str, params: dict, timeout: float = 30.0
server: dict, method: str, params: dict, timeout: float = 30.0,
raise_on_error: bool = False
):
"""用官方 ClientSession 完成一次标准 MCP 调用(initialize -> 目标请求)。
返回解析后的 dict:{"result": {...}} 或 None(失败)。
协议版本由 SDK 自动协商,不再硬编码。
raise_on_error=True 时连接/网络错误(如 ExceptionGroup)会向上抛,不再吞掉。
"""
url = (server.get("url") or "").strip()
if not url:
@@ -89,33 +91,40 @@ async def _session_request(
}
except Exception as e:
logger.warning(f"[MCP] {method} 异常 {url}: {e}")
if raise_on_error:
raise
return None
return None
async def list_mcp_tools(server: dict, timeout: float = 30.0) -> list:
async def list_mcp_tools(server: dict, timeout: float = 30.0, raise_on_error: bool = False) -> list:
"""
拉取 MCP 服务器的工具列表。
server: {url, transport_type, headers}
返回 MCP tools 数组(每个含 name/description/inputSchema)。
raise_on_error=True 时连接/网络错误不再吞掉,直接向上抛(供测试诊断用)。
"""
url = (server.get("url") or "").strip()
if not url:
return []
# 缓存
now = asyncio.get_event_loop().time()
cached = _TOOLS_CACHE.get(url)
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
return cached[1]
# 缓存(仅在正常返回 tools 时缓存;raise_on_error 模式不缓存,便于实时重试)
if not raise_on_error:
now = asyncio.get_event_loop().time()
cached = _TOOLS_CACHE.get(url)
if cached and (now - cached[0]) < _TOOLS_CACHE_TTL:
return cached[1]
try:
data = await _session_request(server, "tools/list", {}, timeout)
data = await _session_request(server, "tools/list", {}, timeout, raise_on_error=raise_on_error)
tools = []
if data and "result" in data:
tools = data["result"].get("tools", []) or []
_TOOLS_CACHE[url] = (now, tools)
if not raise_on_error:
_TOOLS_CACHE[url] = (asyncio.get_event_loop().time(), tools)
return tools
except Exception as e:
logger.warning(f"[MCP] 拉取工具失败 {url}: {e}")
if raise_on_error:
raise
return []
+6
View File
@@ -40,6 +40,9 @@ from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution, skills_api
from app.api.bug_audit import router as bug_audit_router
from app.api.auth_verify import router as auth_verify_router
from app.api.billing_webhook import router as billing_webhook_router
from app.casdoor_auth import router as casdoor_router
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
from app.api.im_bot_user import router as im_bot_user_router
@@ -283,6 +286,9 @@ app.include_router(observability_admin.router, prefix="/app/api/admin/observabil
app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
app.include_router(auth_verify_router, prefix="/app/api/auth", tags=["Auth Verify"])
app.include_router(billing_webhook_router, prefix="/app/api/billing", tags=["Billing Webhook"])
app.include_router(bug_audit_router, prefix="/app/api/health", tags=["Health"])
@app.get("/app/api/health")
+143
View File
@@ -0,0 +1,143 @@
"""Regression R4: 防止能力路由误判回归。
R4.1: detector_media_intent 对纯文本输入返回 None(不误判为 image/video)。
R4.2: resolve_auto_model("text") 返回有 text 能力的模型。
R4.3: 普通多轮对话必须走 text 模型,不可走 image/video。
R4.4: 所有已注册能力可由 resolve_auto_model 解析到启用模型或 None。
R4.5: is_default 模型必须存在且启用。
"""
import json
import pytest
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from app.api.chat_media import detect_media_intent, detect_ambiguous_creative_intent
from app.capability_registry import resolve_auto_model, get_capability_defaults, CAPABILITY_TYPES, TEXT_FAMILY
from app.database import SessionLocal
from app.models import AIModel
@pytest.fixture(scope="module")
def db():
"""获得独立测试数据库会话"""
db = SessionLocal()
yield db
db.close()
# ─── R4.1: detect_media_intent regression suite ───
MEDIA_INTENT_CASES = [
# (input, expected_intent)
("你好", None),
("今天天气怎么样", None),
("帮我写一篇文章", None),
("平台有哪些技能能", None), # 历史 bug:曾被误判为 image
("新对话", None), # 新建对话入口
("画一只小猫", "image"),
("生成一张海报", "image"),
("给我画个头像", "image"),
("生成一个视频介绍", "video"),
("做一段短片", "video"),
("来段视频", "video"),
("朗读这段文字", "audio"),
("语音合成一段播报", "audio"),
("你能生成视频吗?", None), # 疑问句不触发
("怎么画一只猫?", None),
("如何生成图片呢?", None),
("画一只小猫,要可爱的", "image"), # 有明确指令
("Generate a cat image", "image"), # 英文
("help me create a video", "video"),
]
class TestR4_1_MediaIntent:
@pytest.mark.parametrize("text,expected", MEDIA_INTENT_CASES)
def test_detect_media_intent(self, text, expected):
result = detect_media_intent(text)
assert result == expected, f"detect_media_intent({text!r}) = {result!r}, expected {expected!r}"
# ─── R4.2: resolve_auto_model 回归 ───
class TestR4_2_AutoModel:
def test_text_model_has_text_capability(self, db):
"""文本对话默认模型必须有 text 能力"""
m = resolve_auto_model("text", db)
assert m is not None, "resolve_auto_model('text') returned None"
caps = [c.lower() for c in (m.capabilities or [])]
assert "text" in caps, f"Text model {m.name} has no 'text' capability: {caps}"
def test_text_model_not_image_video(self, db):
"""文本默认模型不应触发 image/video 误判"""
m = resolve_auto_model("text", db)
caps = [c.lower() for c in (m.capabilities or [])]
if "image" in caps or "video" in caps:
# If text model has image caps, detect_media_intent must catch it
assert detect_media_intent("你好") is None, "Normal text detected as media despite model having image caps"
assert detect_media_intent("新对话") is None
def test_thinking_model_exists(self, db):
m = resolve_auto_model("thinking", db)
assert m is not None, "resolve_auto_model('thinking') returned None"
def test_all_11_capabilities_resolvable(self, db):
"""所有 11 个能力至少有一个模型或 None"""
for cap_def in CAPABILITY_TYPES:
cap = cap_def["id"]
m = resolve_auto_model(cap, db)
is_text_family = cap in TEXT_FAMILY
if is_text_family:
assert m is not None, f"{cap} is TEXT_FAMILY but resolve_auto_model returned None"
# media/embedding/rerank/asr/ocr can legitimately be None if no model
# ─── R4.3: is_default model exists ───
class TestR4_3_DefaultModel:
def test_default_model_exists(self, db):
m = db.query(AIModel).filter(AIModel.is_default == True, AIModel.is_enabled == True).first()
assert m is not None, "No is_default=true and enabled model"
caps = [c.lower() for c in (m.capabilities or [])]
assert "text" in caps, f"Default model {m.name} must have 'text' capability: {caps}"
def test_default_model_resolvable(self, db):
m = db.query(AIModel).filter(AIModel.is_default == True, AIModel.is_enabled == True).first()
assert m.id == resolve_auto_model("text", db).id, "is_default model != resolve_auto_model('text')"
# ─── R4.4: capability defaults consistency ───
class TestR4_4_CapabilityDefaults:
def test_text_defaults_in_sync_with_auto(self, db):
"""site_settings text capability defaults must match resolve_auto_model"""
defaults = get_capability_defaults(db)
for cap in ("text", "thinking"):
mid = defaults.get(cap)
if mid:
m = resolve_auto_model(cap, db)
assert m is not None, f"SiteSetting {cap}->{mid} not resolvable"
assert m.id == mid, f"Mismatch: site_setting {cap}={mid} but resolve_auto_model returns {m.id}"
def test_no_disabled_models_in_defaults(self, db):
defaults = get_capability_defaults(db)
for cap, mid in defaults.items():
if mid:
m = db.query(AIModel).filter(AIModel.id == mid, AIModel.is_enabled == True).first()
assert m is not None, f"Capability {cap} points to disabled/nonexistent model {mid}"
# ─── R4.5: detect_ambiguous_creative_intent smoke ───
class TestR4_5_AmbiguousIntent:
def test_ambiguous_detected(self):
r = detect_ambiguous_creative_intent("帮我做一个东西")
assert r is not None, "Ambiguous creative intent not detected"
def test_explicit_not_ambiguous(self):
r = detect_ambiguous_creative_intent("帮我写一篇文章")
assert r is None, "Article request should not be ambiguous"
def test_media_not_ambiguous(self):
r = detect_ambiguous_creative_intent("画一只小猫")
assert r is None, "Image intent should bypass ambiguous detection"