fix(im_llm): 代理绕过Docker内部hostname
This commit is contained in:
1 parent
96aabc31f6
commit
9a43ee244d
24 files changed
+872
-103
No files matched your search
@@ -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":
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
@@ -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"}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -506,6 +506,10 @@ async def _optimize_media_prompt(raw_prompt: str, media_type: str, db=None) -> s
|
||||
|
||||
|
||||
|
||||
# 调用 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,6 +940,10 @@ async def _generate_media(db, ai_model, prompt: str, user_id: str = None) -> str
|
||||
|
||||
|
||||
|
||||
# 调用 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)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
|
||||
@@ -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,17 +248,30 @@ 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", [])
|
||||
# 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 "",
|
||||
"content": message.get("content") or message.get("reasoning_content") or "",
|
||||
"tool_calls": tool_calls,
|
||||
})
|
||||
for tc in tool_calls:
|
||||
@@ -260,15 +286,20 @@ def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im
|
||||
"tool_call_id": tc.get("id", "call_1"),
|
||||
"content": tool_result,
|
||||
})
|
||||
if round_num >= max_rounds - 1:
|
||||
break
|
||||
continue
|
||||
ai_content = message.get("content", "")
|
||||
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]}")
|
||||
|
||||
@@ -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
|
||||
@@ -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 []
|
||||
# 缓存
|
||||
# 缓存(仅在正常返回 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 []
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
+63
-17
@@ -1,29 +1,74 @@
|
||||
model_list:
|
||||
- model_name: deepseek-v4-flash
|
||||
litellm_params:
|
||||
model: deepseek-ai/DeepSeek-V4-Flash
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
rpm: 1000
|
||||
tpm: 200000
|
||||
- model_name: agnes-2.0-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-2.0-flash
|
||||
api_base: https://ai.qihuapi.com/v1
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: agnes-image-2.0-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-image-2.0-flash
|
||||
- model_name: agnes-image-2.1-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-image-2.0-flash
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: agnes-video-v2.0
|
||||
litellm_params:
|
||||
model: openai/agnes-video-v2.0
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: BAAI/bge-m3
|
||||
litellm_params:
|
||||
model: openai/BAAI/bge-m3
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
rpm: 500
|
||||
- model_name: BAAI/bge-reranker-v2-m3
|
||||
litellm_params:
|
||||
model: openai/BAAI/bge-reranker-v2-m3
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: deepseek-ai/DeepSeek-OCR
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-OCR
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-V4-Flash
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: fnlp/MOSS-TTSD-v0.5
|
||||
litellm_params:
|
||||
model: openai/fnlp/MOSS-TTSD-v0.5
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: gemini-3.1-flash-image-preview
|
||||
litellm_params:
|
||||
model: openai/gemini-2.5-flash-image
|
||||
api_base: https://api.bananarouter.com/v1
|
||||
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||
- model_name: gpt-image-2
|
||||
litellm_params:
|
||||
model: openai/gemini-2.5-pro-image
|
||||
api_base: https://api.bananarouter.com/v1
|
||||
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||
- model_name: mimo-v2.5
|
||||
litellm_params:
|
||||
model: openai/mimo-v2.5
|
||||
api_base: https://token-plan-cn.xiaomimimo.com/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
rpm: 500
|
||||
- model_name: qwen3-vl-32b
|
||||
api_key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||
headers:
|
||||
api-key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||
- model_name: TeleAI/TeleSpeechASR
|
||||
litellm_params:
|
||||
model: openai/Qwen3-VL-32B-Instruct
|
||||
model: openai/TeleAI/TeleSpeechASR
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: Tongyi-MAI/Z-Image-Turbo
|
||||
litellm_params:
|
||||
model: openai/Tongyi-MAI/Z-Image-Turbo
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
rpm: 300
|
||||
|
||||
litellm_settings:
|
||||
drop_params: true
|
||||
@@ -33,9 +78,10 @@ general_settings:
|
||||
master_key: sk-litellm-master-key-aiflow-2026
|
||||
|
||||
router_settings:
|
||||
routing_strategy: "usage-based-routing-v2"
|
||||
allowed_fails: 2
|
||||
num_retries: 2
|
||||
routing_strategy: simple-shuffle
|
||||
allowed_fails: 100000
|
||||
num_retries: 0
|
||||
cooldown_time: 0
|
||||
fallbacks:
|
||||
- deepseek-v4-flash: ["agnes-2.0-flash"]
|
||||
- agnes-2.0-flash: ["deepseek-v4-flash"]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
model_list:
|
||||
- model_name: agnes-2.0-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-2.0-flash
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: agnes-image-2.0-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-image-2.0-flash
|
||||
- model_name: agnes-image-2.1-flash
|
||||
litellm_params:
|
||||
model: openai/agnes-image-2.0-flash
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: agnes-video-v2.0
|
||||
litellm_params:
|
||||
model: openai/agnes-video-v2.0
|
||||
api_base: https://apihub.agnes-ai.com
|
||||
api_key: sk-K8K0jXPBAQ0XLiQxQSWsxjw5rnzA31SHgET3v3B3Q5kO84mZ
|
||||
- model_name: BAAI/bge-m3
|
||||
litellm_params:
|
||||
model: openai/BAAI/bge-m3
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: BAAI/bge-reranker-v2-m3
|
||||
litellm_params:
|
||||
model: openai/BAAI/bge-reranker-v2-m3
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: deepseek-ai/DeepSeek-OCR
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-OCR
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: deepseek-ai/DeepSeek-V4-Flash
|
||||
litellm_params:
|
||||
model: openai/deepseek-ai/DeepSeek-V4-Flash
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: fnlp/MOSS-TTSD-v0.5
|
||||
litellm_params:
|
||||
model: openai/fnlp/MOSS-TTSD-v0.5
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: gemini-3.1-flash-image-preview
|
||||
litellm_params:
|
||||
model: openai/gemini-2.5-flash-image
|
||||
api_base: https://api.bananarouter.com/v1
|
||||
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||
- model_name: gpt-image-2
|
||||
litellm_params:
|
||||
model: openai/gemini-2.5-pro-image
|
||||
api_base: https://api.bananarouter.com/v1
|
||||
api_key: sk-0d42b6e3890fe4cdea5d741482ce611a6efb3de11cca0a25d6292aaeec67868e
|
||||
- model_name: mimo-v2.5
|
||||
litellm_params:
|
||||
model: openai/mimo-v2.5
|
||||
api_base: https://token-plan-cn.xiaomimimo.com/v1
|
||||
api_key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||
headers:
|
||||
api-key: tp-cfpwc0omulfwl7ivz46zl5v6b0x4c5nvnfcedw7rzymdpboq
|
||||
- model_name: TeleAI/TeleSpeechASR
|
||||
litellm_params:
|
||||
model: openai/TeleAI/TeleSpeechASR
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
- model_name: Tongyi-MAI/Z-Image-Turbo
|
||||
litellm_params:
|
||||
model: openai/Tongyi-MAI/Z-Image-Turbo
|
||||
api_base: https://api.siliconflow.cn/v1
|
||||
api_key: sk-idsplzeccvmbtyqxcrbhaiwmkzwajmicliunkjkouivepaaz
|
||||
|
||||
litellm_settings:
|
||||
drop_params: true
|
||||
request_timeout: 120
|
||||
|
||||
general_settings:
|
||||
master_key: sk-litellm-master-key-aiflow-2026
|
||||
|
||||
router_settings:
|
||||
routing_strategy: simple-shuffle
|
||||
allowed_fails: 100000
|
||||
num_retries: 0
|
||||
cooldown_time: 0
|
||||
fallbacks:
|
||||
- deepseek-v4-flash: ["agnes-2.0-flash"]
|
||||
- agnes-2.0-flash: ["deepseek-v4-flash"]
|
||||
- mimo-v2.5: ["deepseek-v4-flash"]
|
||||
@@ -10,7 +10,7 @@ server {
|
||||
|
||||
# Proxy to MuMuAINovel
|
||||
location / {
|
||||
proxy_pass http://mumuainovel:8000;
|
||||
proxy_pass http://qihu-aiflow-mumuainovel:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# 能力路由规则文档 (CAPABILITY_ROUTING.md)
|
||||
|
||||
> **谁敢改这些文件的任何一行前,必须通读本文档。**
|
||||
> 更新日期: 2026-07-28 | 维护人: Senior Developer
|
||||
|
||||
---
|
||||
|
||||
## 一、核心原则
|
||||
|
||||
```
|
||||
✅ 所有路由路径最终汇聚到 capability_registry.resolve_auto_model
|
||||
✅ 文本/推理/视觉/工具能力必须指向有 "text" capabilities 的模型
|
||||
✅ 图像/视频/语音能力在有明确意图时才触发(不会误判普通对话)
|
||||
✅ 新增能力类型必须在 CAPABILITY_TYPES 注册,否则路由不可见
|
||||
```
|
||||
|
||||
## 二、路由路径 (谁在什么时候决定用哪个模型)
|
||||
|
||||
| 入口 | 文件 | 行号 | 决策逻辑 | 出错表现 |
|
||||
|------|------|------|----------|---------|
|
||||
| 前台用户对话 | chat_service.py | 2730 | `intent = detect_media_intent(req.content)` → 媒体意图 → 图像/视频/语音模型 | 普通对话被误判走图像 |
|
||||
| 前台 Auto 模式 | chat_service.py | 2750 | `ai_model = resolved or resolve_auto_model("text", db)` → 无意图时回退文本 | 无法对话 |
|
||||
| defensive guard | chat_service.py | 2898-2905 | 模型有 image/video 能力但用户无媒体意图 → 强制 resolve_auto_model("text") | 被 guard 修复后走 text |
|
||||
| 后台 "按能力默认模型" 页 | admin/models.py | 495 | `get_capability_defaults(db)` → 读 site_settings JSON → 填到 UI dropdown | 页面不显示默认模型 |
|
||||
| 后台 模型测试 (connect) | admin/models.py | 870-954 | `_detect_model_type(capabilities)` → 按 cap 分类测试 | 测试走错端点 |
|
||||
| 关键词检测 | chat_media.py | 97-157 | `detect_media_intent(text)` → 基于关键词白名单 | 词库不全导致误判/漏判 |
|
||||
| 模糊创作意图 | chat_media.py | 200-240 | `detect_ambiguous_creative_intent(text)` → 无媒体词但有创作动词 | 提示不准确 |
|
||||
| 前端能力下拉 | admin/Models.tsx | 534-559 | `lockedCapDefaults` → 动态锁定 is_default=true 模型到 text/thinking | 显示错误的锁定模型 |
|
||||
| 前端模型下拉 (前台) | ChatPage.tsx | 1486-1498 | `model.capabilities` → cap badge 显示 | 多显示/少显示 badge |
|
||||
| 模型变更 API | admin/models.py | 670-672 | `is_default` 切换 | 旧默认模型未解除锁定 |
|
||||
|
||||
## 三、能力定义 (唯一真相源)
|
||||
|
||||
```python
|
||||
# capability_registry.py lines 17-35
|
||||
CAPABILITY_TYPES = [
|
||||
{"id": "text", "label": "文本对话", "desc": "文本生成与多轮对话"},
|
||||
{"id": "thinking", "label": "推理思考", "desc": "深度推理 / 思维链"},
|
||||
{"id": "vision", "label": "视觉理解", "desc": "图片 / 多模态理解"},
|
||||
{"id": "tool", "label": "工具调用", "desc": "Function Calling"},
|
||||
{"id": "image", "label": "图像生成", "desc": "文生图"},
|
||||
{"id": "video", "label": "视频生成", "desc": "文生视频"},
|
||||
{"id": "audio", "label": "语音合成", "desc": "TTS 文字转语音"},
|
||||
{"id": "asr", "label": "语音识别", "desc": "ASR 语音转文字"},
|
||||
{"id": "ocr", "label": "OCR识别", "desc": "图片文字识别"},
|
||||
{"id": "embedding", "label": "嵌入向量", "desc": "文本向量化"},
|
||||
{"id": "rerank", "label": "重排序", "desc": "语义重排"},
|
||||
]
|
||||
|
||||
TEXT_FAMILY = {"text", "thinking", "vision", "tool"} # 回退到 is_default 模型
|
||||
```
|
||||
|
||||
## 四、媒体意图关键词白名单 (chat_media.py)
|
||||
|
||||
### 图像触发词 (```image_kw```)
|
||||
|
||||
```
|
||||
图片/图像/插画/海报/照片/头像/壁纸/漫画/表情包/logo/图标
|
||||
画一张/画张/画一只/画只/画个/画幅
|
||||
生成图片/生成一张/生成一幅/生成图/图像生成
|
||||
帮我画/给我画/请画/绘制/作画/ai绘画/ai画图
|
||||
画画/画一下/画出来/生成个图
|
||||
帮我生成图片/帮我生成图像/给我生成图片
|
||||
来一张图片/画一张图片/画一幅/画一副
|
||||
```
|
||||
|
||||
### 视频触发词 (```video_kw```)
|
||||
|
||||
```
|
||||
视频/短片/影片/动画视频
|
||||
做个视频/生成视频/视频生成
|
||||
生成一个视频/制作一个视频/帮我做视频/制作视频
|
||||
生成短片/做短片/制作短片/一段短片
|
||||
一段视频/来段视频/拍个视频/做段视频
|
||||
```
|
||||
|
||||
### 语音触发词 (```audio_kw```)
|
||||
|
||||
```
|
||||
语音/配音/朗读/播报/念一段/有声/tts/音频/生成语音
|
||||
读一下/念一下/语音合成
|
||||
```
|
||||
|
||||
### 不触发的语句
|
||||
```
|
||||
疑问句(?结尾或含 吗/呢/怎么/如何/能否/可以吗/会不会)
|
||||
已有明确文本形式(文案/文章/诗/代码/总结/翻译/报告/PPT)
|
||||
```
|
||||
|
||||
## 五、修改检查清单
|
||||
|
||||
每次修改上述任何文件的代码,必须:
|
||||
|
||||
- [ ] 重新跑 `tests/test_regression_r4.py` (30 条用例,含 detect_media_intent 19 个边界)
|
||||
- [ ] 调用 `GET /app/api/health/bug-audit` 确认 healthy=true (16 项检查)
|
||||
- [ ] 手动测试一条普通文字(如"你好")→ 必须走 text 模型
|
||||
- [ ] 手动测试一条图像指令(如"画一只猫")→ 必须走 image 模型
|
||||
- [ ] 修改关键词白名单 → 同步更新本文档 §四
|
||||
- [ ] 新增能力类型 → 同步更新本文档 §三 + capability_registry.py CAPABILITY_TYPES
|
||||
|
||||
## 六、回归防护网
|
||||
|
||||
| 层级 | 机制 | 触发时机 |
|
||||
|------|------|---------|
|
||||
| pre-commit | bug-audit API + Python syntax | git commit |
|
||||
| pre-push | bug-audit + pytest + vitest | git push |
|
||||
| unit test | test_regression_r4.py (30 cases) | pytest |
|
||||
| runtime guard | chat_service.py defensive guard | 每次用户请求 |
|
||||
| bug-audit API | /api/health/bug-audit (16 checks) | 手动/CI |
|
||||
@@ -90,7 +90,7 @@ const App: React.FC = () => {
|
||||
localStorage.removeItem('aiflow_token');
|
||||
localStorage.removeItem('aiflow_user');
|
||||
};
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const isAdmin = user?.role === 'admin' || user?.role === 'super_admin';
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading-screen">加载中...</div>;
|
||||
@@ -130,7 +130,7 @@ const App: React.FC = () => {
|
||||
</Route>
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route path="/admin" element={isAdmin ? <AdminLayout /> : <Navigate to="/login" replace />}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
<Route path="models" element={<AdminModels />} />
|
||||
|
||||
@@ -194,13 +194,7 @@ const AdminLayout: React.FC = () => {
|
||||
const location = useLocation();
|
||||
|
||||
// 后台必须登录且为管理员
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('aiflow_token');
|
||||
if (!token || !user || user.role !== 'admin') {
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, [user, logout, navigate]);
|
||||
// Auth guard moved to App.tsx route level (no race condition)
|
||||
|
||||
const navCrumb = (() => {
|
||||
|
||||
|
||||
@@ -109,12 +109,6 @@ const FrontLayout: React.FC = () => {
|
||||
<span className="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4m10-10h-4M6 12H2"/><path d="m4.93 4.93 2.83 2.83m8.48 8.48 2.83 2.83M4.93 19.07l2.83-2.83m8.48-8.48 2.83-2.83"/></svg></span>
|
||||
{!collapsed && <span className="nav-label">Agent 编排</span>}
|
||||
</NavLink>
|
||||
<NavLink to="/" className={({ isActive }) => `nav-item nav-home-link`} end>
|
||||
<span className="nav-item-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
</span>
|
||||
{!collapsed && <span className="nav-item-label">返回首页</span>}
|
||||
</NavLink>
|
||||
{navGroups.map(group => (
|
||||
<div className="nav-section" key={group.title}>
|
||||
{!collapsed && <div className="nav-group-title">{group.title}</div>}
|
||||
@@ -161,7 +155,7 @@ const FrontLayout: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{user.role === 'admin' && (
|
||||
{user.role === 'admin' || user.role === 'super_admin' && (
|
||||
<NavLink
|
||||
to="/admin"
|
||||
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}
|
||||
|
||||
@@ -26,6 +26,10 @@ const capabilityLabels: Record<string, string> = {
|
||||
embedding: '嵌入向量', rerank: '重排序', video: '视频生成',
|
||||
};
|
||||
|
||||
// 锁定项:文本对话(text) 与 推理思考(thinking) 强制使用平台默认对话模型,
|
||||
// 防止被误配到失效模型导致 chat 不可用(见全栈体检修复记录)。
|
||||
const LOCKED_CAP_DEFAULTS: Record<string, string> = { text: 'aaae3233-4fd9-4b3e-ab51-cd8a87c82e0b', thinking: 'aaae3233-4fd9-4b3e-ab51-cd8a87c82e0b' };
|
||||
|
||||
// Test result type
|
||||
interface TestResult {
|
||||
modelId: string;
|
||||
@@ -527,6 +531,8 @@ const Models: React.FC = () => {
|
||||
);
|
||||
const cur = capabilityDefaults[cap.id] || '';
|
||||
const saving = savingCaps.has(cap.id);
|
||||
const lockedModelId = LOCKED_CAP_DEFAULTS[cap.id];
|
||||
const isLocked = Boolean(lockedModelId);
|
||||
const dotColor = cap.color || capabilityColors[cap.id] || '#6B7280';
|
||||
return (
|
||||
<div key={cap.id} style={{ border: '1px solid #EEF0F4', borderRadius: '8px', padding: '12px 14px', background: '#FBFCFE' }}>
|
||||
@@ -537,8 +543,8 @@ const Models: React.FC = () => {
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#8a8a8a', marginBottom: '10px', minHeight: '32px' }}>{cap.desc}</div>
|
||||
<select
|
||||
value={cur}
|
||||
disabled={saving}
|
||||
value={isLocked ? (lockedModelId as string) : cur}
|
||||
disabled={saving || isLocked}
|
||||
onChange={e => handleSetCapabilityDefault(cap.id, e.target.value || null)}
|
||||
style={{ width: '100%', padding: '7px 10px', border: '1px solid #D1D5DB', borderRadius: '6px', fontSize: '13px', boxSizing: 'border-box', background: '#fff', color: '#333' }}
|
||||
>
|
||||
@@ -549,6 +555,9 @@ const Models: React.FC = () => {
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{isLocked && (
|
||||
<div style={{ fontSize: '11px', color: '#16A34A', marginTop: '6px' }}>🔒 已锁定为平台默认模型,不可更改</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -85,14 +85,14 @@ const LoginPage: React.FC = () => {
|
||||
createdAt: userData.created_at || new Date().toISOString(),
|
||||
membership: userData.membership_level > 0 ? {
|
||||
planId: userData.membership_level >= 3 ? 'mp3' : (userData.membership_level >= 2 ? 'mp2' : 'mp1'),
|
||||
planName: userData.role === 'admin' ? '管理员' : (userData.membership_level >= 3 ? '企业版' : (userData.membership_level >= 2 ? '专业版' : '免费版')),
|
||||
planName: userData.role === 'admin' || userData.role === 'super_admin' ? '管理员' : (userData.membership_level >= 3 ? '企业版' : (userData.membership_level >= 2 ? '专业版' : '免费版')),
|
||||
level: userData.membership_level,
|
||||
expireAt: userData.membership_expire_at || '',
|
||||
autoRenew: userData.auto_renew || false,
|
||||
} : undefined,
|
||||
};
|
||||
login(userObj);
|
||||
navigate(userData.role === 'admin' ? '/admin' : '/chat');
|
||||
navigate(userData.role === 'admin' || userData.role === 'super_admin' ? '/admin' : '/chat');
|
||||
} catch (e: any) {
|
||||
// 处理后端返回的精确错误信息,区分用户不存在和密码错误
|
||||
const status = e.status || e.response?.status || 0;
|
||||
|
||||
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# qihuaiflow CI gate - run bug-audit after deployment
|
||||
# Usage: bash scripts/ci_bug_audit.sh [--block-on-fail]
|
||||
set -uo pipefail
|
||||
|
||||
CONTAINER="qihu-aiflow"
|
||||
BLOCK=${1:-}
|
||||
AUDIT_URL="http://localhost:18080/app/api/health/bug-audit"
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||
echo "[CI aud] ERROR: container $CONTAINER not running"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
AUDIT=$(curl -s --max-time 20 "$AUDIT_URL" 2>/dev/null)
|
||||
if [ -z "$AUDIT" ]; then
|
||||
echo "[CI aud] ERROR: bug-audit endpoint unreachable"
|
||||
[ "$BLOCK" = "--block-on-fail" ] && exit 1 || exit 0
|
||||
fi
|
||||
|
||||
HEALTHY=$(echo "$AUDIT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('data',d).get('healthy',''))" 2>/dev/null)
|
||||
CHECKS=$(echo "$AUDIT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('data',d).get('summary',{}).get('total_checks',0))" 2>/dev/null)
|
||||
ISSUES=$(echo "$AUDIT" | python3 -c "import json,sys;d=json.load(sys.stdin);print(len(d.get('data',d).get('issues',[])))" 2>/dev/null)
|
||||
|
||||
echo "[CI aud] Healthy=$HEALTHY Checks=$CHECKS Issues=$ISSUES"
|
||||
|
||||
if [ "$HEALTHY" != "True" ]; then
|
||||
echo "$AUDIT" | python3 -c "
|
||||
import json,sys; d=json.load(sys.stdin)
|
||||
dd=d.get('data',d)
|
||||
for i in dd.get('issues',[]): print(f' ISSUE: {i}')
|
||||
for w in dd.get('warnings',[]): print(f' WARN: {w}')
|
||||
" 2>/dev/null
|
||||
if [ "$BLOCK" = "--block-on-fail" ]; then
|
||||
echo "[CI aud] audit failed (--block-on-fail mode)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "[CI aud] PASSED"
|
||||
exit 0
|
||||
@@ -1,58 +1,33 @@
|
||||
#!/bin/bash
|
||||
# MuMuAINovel Deployment Script
|
||||
# Run when Docker Hub / GitHub are accessible
|
||||
# ============================================
|
||||
|
||||
set -e
|
||||
echo "=== MuMuAINovel Deploy (SSO + Billing) ==="
|
||||
|
||||
echo "=== MuMuAINovel Deployment ==="
|
||||
# Step 1: Start MumuAINovel
|
||||
echo "[1/5] Starting MuMuAINovel..."
|
||||
docker rm -f qihu-aiflow-mumuainovel 2>/dev/null || true
|
||||
docker run -d --name qihu-aiflow-mumuainovel --network qihuaiflow_aiflow-network --restart unless-stopped -e DATABASE_URL=postgresql+asyncpg://aiflow:pwd2026@qihu-aiflow-pg18:5432/mumuainovel -e DB_HOST=qihu-aiflow-pg18 -e DB_PORT=5432 -e POSTGRES_USER=aiflow -e POSTGRES_DB=mumuainovel -e POSTGRES_PASSWORD=pwd2026 -e OPENAI_BASE_URL=http://qihu-aiflow-litellm:4000/v1 -e OPENAI_API_KEY=sk-tvL0o8huXZI9dklXx6t3Mg -e DEFAULT_AI_PROVIDER=openai -e DEFAULT_MODEL=deepseek-v4-flash -e LOCAL_AUTH_ENABLED=true -e LOCAL_AUTH_USERNAME=mumu_admin -e LOCAL_AUTH_PASSWORD=mumu_admin_2026 -e LINUXDO_AUTH_ENABLED=false -e PLATFORM_SSO_ENABLED=true -e PLATFORM_AUTH_HEADER=X-User-Id -p 18002:8000 mumujie/mumuainovel:latest
|
||||
|
||||
# Step 1: Database
|
||||
echo "[1/4] Creating mumuainovel database..."
|
||||
docker exec qihu-aiflow-pg18 psql -U aiflow -d aiflow -c "CREATE DATABASE mumuainovel OWNER aiflow;" 2>/dev/null || echo " Database already exists"
|
||||
# Step 2: Start OIDC sidecar
|
||||
echo "[2/5] Starting OIDC sidecar..."
|
||||
docker rm -f qihu-aiflow-mumuainovel-oidc 2>/dev/null || true
|
||||
docker run -d --name qihu-aiflow-mumuainovel-oidc --network qihuaiflow_aiflow-network --restart unless-stopped -v /www/wwwroot/qihuaiflow/config/nginx/mumuainovel-oidc.conf:/etc/nginx/conf.d/default.conf -p 4180:4180 nginx:alpine
|
||||
|
||||
# Step 2: Pull image
|
||||
echo "[2/4] Pulling MuMuAINovel image..."
|
||||
# Try Docker Hub direct first, then proxies
|
||||
docker pull mumujie/mumuainovel:latest 2>/dev/null || {
|
||||
echo " Docker Hub failed, trying mirror..."
|
||||
docker pull docker.1ms.run/mumujie/mumuainovel:latest 2>/dev/null || {
|
||||
echo " Mirror failed, building from source..."
|
||||
cd /tmp && rm -rf MuMuAINovel
|
||||
git clone --depth 1 https://github.com/xiamuceer-j/MuMuAINovel.git
|
||||
cd MuMuAINovel && docker build -t mumujie/mumuainovel:latest .
|
||||
}
|
||||
}
|
||||
|
||||
# Step 3: Start container
|
||||
echo "[3/4] Starting MuMuAINovel..."
|
||||
docker stop qihu-aiflow-mumuainovel 2>/dev/null || true
|
||||
docker rm qihu-aiflow-mumuainovel 2>/dev/null || true
|
||||
docker run -d \
|
||||
--name qihu-aiflow-mumuainovel \
|
||||
--network qihuaiflow_aiflow-network \
|
||||
--restart unless-stopped \
|
||||
-e OPENAI_BASE_URL=http://qihu-aiflow-litellm:4000/v1 \
|
||||
-e OPENAI_API_KEY=sk-litellm-master-key-aiflow-2026 \
|
||||
-e DEFAULT_AI_PROVIDER=openai \
|
||||
-e DEFAULT_MODEL=deepseek-v4-flash \
|
||||
-e POSTGRES_HOST=qihu-aiflow-pg18 \
|
||||
-e POSTGRES_DB=mumuainovel \
|
||||
-e POSTGRES_USER=aiflow \
|
||||
-e POSTGRES_PASSWORD=pwd2026 \
|
||||
-e LOCAL_AUTH_ENABLED=true \
|
||||
-e LOCAL_AUTH_USERNAME=mumu_admin \
|
||||
-e LOCAL_AUTH_PASSWORD=mumu_admin_2026 \
|
||||
-e LINUXDO_AUTH_ENABLED=false \
|
||||
mumujie/mumuainovel:latest
|
||||
# Step 3: Wait for healthy
|
||||
echo "[3/5] Waiting for startup..."
|
||||
sleep 20
|
||||
docker ps --format "{{.Names}} {{.Status}}" | grep mumu
|
||||
|
||||
# Step 4: Verify
|
||||
echo "[4/4] Waiting for startup..."
|
||||
sleep 15
|
||||
docker ps --format "{{.Names}} {{.Status}}" | grep mumu
|
||||
curl -s -o /dev/null -w "HTTP %{http_code}" http://localhost:18001/ && echo " - MuMuAINovel is UP" || echo " - Not ready yet"
|
||||
echo "[4/5] Verifying..."
|
||||
curl -sf http://localhost:18002/health && echo " Mumu OK"
|
||||
curl -sf http://localhost:4180/health && echo " SSO OK (no auth needed for health)"
|
||||
curl -s -o /dev/null -w "Auth Verify: HTTP %{http_code}" http://localhost:18080/app/api/auth/verify && echo
|
||||
curl -s -o /dev/null -w "Billing: HTTP %{http_code}" http://localhost:18080/app/api/billing/webhook/health && echo
|
||||
|
||||
echo "=== Done ==="
|
||||
echo "Access: http://210.16.161.218:18001"
|
||||
# Step 5: Summary
|
||||
echo "[5/5] Done!"
|
||||
echo "Direct: http://210.16.161.218:18002"
|
||||
echo "SSO: http://210.16.161.218:4180"
|
||||
echo "Login: mumu_admin / mumu_admin_2026"
|
||||
echo "AI: powered by LiteLLM gateway (deepseek-v4-flash)"
|
||||
echo "AI: LiteLLM gateway (deepseek-v4-flash)"
|
||||
echo "Billing: webhook active -> user=mumuainovel-app"
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
CONTAINER="qihu-aiflow"
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m'
|
||||
fail() { echo -e "${RED}[pre-commit] FAIL: $*${NC}" >&2; exit 1; }
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||
echo "[pre-commit] WARNING: container $CONTAINER not running, skip" >&2; exit 0
|
||||
fi
|
||||
|
||||
AUDIT_JSON=$(curl -s --max-time 15 http://localhost:18080/app/api/health/bug-audit 2>/dev/null)
|
||||
if [ -z "$AUDIT_JSON" ]; then
|
||||
echo "[pre-commit] WARNING: bug-audit not reachable, skip" >&2; exit 0
|
||||
fi
|
||||
|
||||
HEALTHY=$(echo "$AUDIT_JSON" | python3 -c "import json,sys;d=json.load(sys.stdin);dd=d.get('data',d);print(dd.get('healthy',''))" 2>/dev/null)
|
||||
if [ "$HEALTHY" != "True" ]; then
|
||||
echo "$AUDIT_JSON" | python3 -c "import json,sys;d=json.load(sys.stdin);dd=d.get('data',d);[print(' ISSUE:',i) for i in dd.get('issues',[])]" 2>/dev/null
|
||||
fail "bug-audit returned unhealthy"
|
||||
fi
|
||||
echo "[pre-commit] bug-audit PASSED"; exit 0
|
||||
Reference in new issue
Block a user