fix(agent): 外部Dify Agent 手动跟随http->https 301重定向并保持POST(测试连接+运行期);qihuyun.com直连
This commit is contained in:
1 parent
d274733fb3
commit
4d57987aa5
2 files changed
+111
-46
No files matched your search
@@ -146,6 +146,9 @@ def _proxy_for_url(url: str) -> str | None:
|
|||||||
host = (urlparse(url).hostname or "").lower()
|
host = (urlparse(url).hostname or "").lower()
|
||||||
if "agnes-ai.com" in host or "agnes-ai.space" in host:
|
if "agnes-ai.com" in host or "agnes-ai.space" in host:
|
||||||
return None
|
return None
|
||||||
|
# 平台自有域名(含自建 Dify/n8n 等)必须直连,不经出网代理。
|
||||||
|
if "qihuyun.com" in host:
|
||||||
|
return None
|
||||||
return _outbound_proxy()
|
return _outbound_proxy()
|
||||||
|
|
||||||
|
|
||||||
@@ -352,52 +355,73 @@ async def _call_dify_agent(endpoint: str, api_key: str, query: str,
|
|||||||
new_conv_id = ext_conv_id
|
new_conv_id = ext_conv_id
|
||||||
err_msg = None
|
err_msg = None
|
||||||
wf_outputs = None
|
wf_outputs = None
|
||||||
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client:
|
# httpx 默认 follow_redirects=False;stream POST 若遇 301/302 会直接返回该状态码。
|
||||||
async with client.stream("POST", url, headers=headers, json=payload) as resp:
|
# 且 httpx 自动跟随会把 POST 降级为 GET(对 /v1/chat-messages 变 405)。
|
||||||
if resp.status_code != 200:
|
# 因此手动跟随 301/302/307/308,保持 POST 与请求体不变(最多 4 跳)。
|
||||||
body = (await resp.aread()).decode("utf-8", "replace")
|
async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False,
|
||||||
raise RuntimeError(f"Dify HTTP {resp.status_code}: {body[:300]}")
|
follow_redirects=False) as client:
|
||||||
async for line in resp.aiter_lines():
|
cur_url = url
|
||||||
if not line:
|
for _redir in range(4):
|
||||||
|
async with client.stream("POST", cur_url, headers=headers, json=payload) as resp:
|
||||||
|
if resp.status_code in (301, 302, 307, 308):
|
||||||
|
loc = resp.headers.get("location") or resp.headers.get("Location")
|
||||||
|
await resp.aclose()
|
||||||
|
if not loc:
|
||||||
|
raise RuntimeError(f"Dify HTTP {resp.status_code}: 缺少重定向地址")
|
||||||
|
# 处理相对地址
|
||||||
|
if loc.startswith("/"):
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
p = urlsplit(cur_url)
|
||||||
|
loc = urlunsplit((p.scheme, p.netloc, loc, "", ""))
|
||||||
|
cur_url = loc
|
||||||
continue
|
continue
|
||||||
line = line.strip()
|
if resp.status_code != 200:
|
||||||
if not line.startswith("data:"):
|
body = (await resp.aread()).decode("utf-8", "replace")
|
||||||
continue
|
raise RuntimeError(f"Dify HTTP {resp.status_code}: {body[:300]}")
|
||||||
data_str = line[5:].strip()
|
async for line in resp.aiter_lines():
|
||||||
if not data_str or data_str == "[DONE]":
|
if not line:
|
||||||
continue
|
continue
|
||||||
try:
|
line = line.strip()
|
||||||
evt = json.loads(data_str)
|
if not line.startswith("data:"):
|
||||||
except Exception:
|
continue
|
||||||
continue
|
data_str = line[5:].strip()
|
||||||
ev = evt.get("event")
|
if not data_str or data_str == "[DONE]":
|
||||||
if evt.get("conversation_id"):
|
continue
|
||||||
new_conv_id = evt["conversation_id"]
|
try:
|
||||||
data = evt.get("data") or {}
|
evt = json.loads(data_str)
|
||||||
# 兼容三类 Dify 应用:
|
except Exception:
|
||||||
# 1) Chat/Agent 应用: message / agent_message 事件带增量 answer
|
continue
|
||||||
# 2) Chatflow/Workflow 应用: text_chunk 事件带 data.text;
|
ev = evt.get("event")
|
||||||
# 结束时 workflow_finished 带 status / outputs / error
|
if evt.get("conversation_id"):
|
||||||
if ev in ("message", "agent_message"):
|
new_conv_id = evt["conversation_id"]
|
||||||
answer_parts.append(evt.get("answer", "") or "")
|
data = evt.get("data") or {}
|
||||||
elif ev == "message_replace":
|
# 兼容三类 Dify 应用:
|
||||||
# 内容审查替换:以替换后的完整 answer 为准
|
# 1) Chat/Agent 应用: message / agent_message 事件带增量 answer
|
||||||
answer_parts = [evt.get("answer", "") or ""]
|
# 2) Chatflow/Workflow 应用: text_chunk 事件带 data.text;
|
||||||
elif ev == "text_chunk":
|
# 结束时 workflow_finished 带 status / outputs / error
|
||||||
answer_parts.append((data.get("text") or ""))
|
if ev in ("message", "agent_message"):
|
||||||
elif ev == "node_finished":
|
answer_parts.append(evt.get("answer", "") or "")
|
||||||
# answer / end 节点的 outputs 可能含最终文本
|
elif ev == "message_replace":
|
||||||
if data.get("node_type") in ("answer", "end") and data.get("outputs"):
|
# 内容审查替换:以替换后的完整 answer 为准
|
||||||
wf_outputs = data.get("outputs")
|
answer_parts = [evt.get("answer", "") or ""]
|
||||||
if data.get("status") == "failed" and data.get("error"):
|
elif ev == "text_chunk":
|
||||||
err_msg = str(data.get("error"))
|
answer_parts.append((data.get("text") or ""))
|
||||||
elif ev == "workflow_finished":
|
elif ev == "node_finished":
|
||||||
if data.get("status") == "failed":
|
# answer / end 节点的 outputs 可能含最终文本
|
||||||
err_msg = str(data.get("error") or "Dify 工作流执行失败")
|
if data.get("node_type") in ("answer", "end") and data.get("outputs"):
|
||||||
elif data.get("outputs"):
|
wf_outputs = data.get("outputs")
|
||||||
wf_outputs = data.get("outputs")
|
if data.get("status") == "failed" and data.get("error"):
|
||||||
elif ev == "error":
|
err_msg = str(data.get("error"))
|
||||||
err_msg = evt.get("message") or evt.get("code") or "Dify 流式返回错误"
|
elif ev == "workflow_finished":
|
||||||
|
if data.get("status") == "failed":
|
||||||
|
err_msg = str(data.get("error") or "Dify 工作流执行失败")
|
||||||
|
elif data.get("outputs"):
|
||||||
|
wf_outputs = data.get("outputs")
|
||||||
|
elif ev == "error":
|
||||||
|
err_msg = evt.get("message") or evt.get("code") or "Dify 流式返回错误"
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise RuntimeError("Dify 重定向次数过多(>4),请直接填写重定向后的地址")
|
||||||
answer = "".join(answer_parts).strip()
|
answer = "".join(answer_parts).strip()
|
||||||
# 流式无增量文本时,尝试从 workflow 结束 outputs 兜底提取
|
# 流式无增量文本时,尝试从 workflow 结束 outputs 兜底提取
|
||||||
if not answer and wf_outputs:
|
if not answer and wf_outputs:
|
||||||
|
|||||||
@@ -35,6 +35,29 @@ def _short_err(e: Exception) -> str:
|
|||||||
return str(e)[:160]
|
return str(e)[:160]
|
||||||
|
|
||||||
|
|
||||||
|
def _post_follow(url: str, headers: dict, json_body: dict, timeout: float = 15.0):
|
||||||
|
"""POST 并手动跟随 301/302/307/308 重定向(**保持 POST 方法与请求体**)。
|
||||||
|
|
||||||
|
背景:Dify 若填写 http:// 地址会 301 永久重定向到 https://;httpx 内建
|
||||||
|
follow_redirects 对 301/302 会把 POST 降级为 GET(浏览器行为),导致对
|
||||||
|
/v1/chat-messages 变成 GET → 405 method_not_allowed。故手动跟随并保持 POST。
|
||||||
|
容器内 env 有坏代理(502),必须 trust_env=False 直连。
|
||||||
|
"""
|
||||||
|
cur = url
|
||||||
|
last = None
|
||||||
|
for _ in range(4):
|
||||||
|
last = httpx.post(cur, headers=headers, json=json_body, timeout=timeout,
|
||||||
|
trust_env=False, follow_redirects=False)
|
||||||
|
if last.status_code in (301, 302, 307, 308):
|
||||||
|
loc = last.headers.get("location")
|
||||||
|
if not loc:
|
||||||
|
return last
|
||||||
|
cur = loc if loc.startswith("http") else str(httpx.URL(cur).join(loc))
|
||||||
|
continue
|
||||||
|
return last
|
||||||
|
return last
|
||||||
|
|
||||||
|
|
||||||
def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dict:
|
def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dict:
|
||||||
"""
|
"""
|
||||||
接入 Agent 前的连通性 + 鉴权校验。
|
接入 Agent 前的连通性 + 鉴权校验。
|
||||||
@@ -58,7 +81,7 @@ def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dic
|
|||||||
}
|
}
|
||||||
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
||||||
try:
|
try:
|
||||||
r = httpx.post(url, headers=headers, json=payload, timeout=15.0, trust_env=False)
|
r = _post_follow(url, headers, payload, timeout=15.0)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "verified": False, "detail": f"无法连接 Dify 地址:{_short_err(e)}"}
|
return {"ok": False, "verified": False, "detail": f"无法连接 Dify 地址:{_short_err(e)}"}
|
||||||
if r.status_code == 200:
|
if r.status_code == 200:
|
||||||
@@ -68,6 +91,24 @@ def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dic
|
|||||||
if r.status_code == 404:
|
if r.status_code == 404:
|
||||||
return {"ok": False, "verified": False,
|
return {"ok": False, "verified": False,
|
||||||
"detail": "地址路径不正确(404),应为 .../v1/chat-messages"}
|
"detail": "地址路径不正确(404),应为 .../v1/chat-messages"}
|
||||||
|
if r.status_code in (301, 302, 307, 308):
|
||||||
|
loc = r.headers.get("location") or ""
|
||||||
|
return {"ok": False, "verified": False,
|
||||||
|
"detail": f"地址被重定向({r.status_code})到 {loc or '未知'};"
|
||||||
|
f"请直接填写重定向后的地址(通常把 http:// 改为 https://)"}
|
||||||
|
if r.status_code == 400:
|
||||||
|
# 400 需区分两种情况:
|
||||||
|
# (a) 到达应用且鉴权通过,但 Dify 应用/工作流内部执行报错(invalid_param + Run failed)
|
||||||
|
# (b) 纯参数错误
|
||||||
|
body = (r.text or "")
|
||||||
|
low = body.lower()
|
||||||
|
if ("run failed" in low or "error code" in low or "workflow" in low
|
||||||
|
or "node" in low or "-500" in body):
|
||||||
|
return {"ok": True, "verified": True,
|
||||||
|
"detail": "连通且 API Key 有效;但你的 Dify 应用内部执行报错:"
|
||||||
|
f"{body[:180]}。这是你自建 Dify 应用/工作流本身的问题"
|
||||||
|
"(例如代码节点沙箱缺少 python3),请在 Dify 侧修复。"}
|
||||||
|
return {"ok": False, "verified": False, "detail": f"参数错误(400):{body[:180]}"}
|
||||||
return {"ok": False, "verified": False, "detail": f"Dify 返回状态码 {r.status_code}"}
|
return {"ok": False, "verified": False, "detail": f"Dify 返回状态码 {r.status_code}"}
|
||||||
|
|
||||||
# n8n / coze / 其他:仅做可达性探测(HEAD,避免触发 webhook 副作用)
|
# n8n / coze / 其他:仅做可达性探测(HEAD,避免触发 webhook 副作用)
|
||||||
|
|||||||
Reference in new issue
Block a user