fix(im_llm): 代理绕过Docker内部hostname
This commit is contained in:
1 parent
96aabc31f6
commit
9a43ee244d
24 files changed
+898
-129
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,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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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,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]}")
|
||||
|
||||
@@ -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 []
|
||||
# 缓存
|
||||
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 []
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user