--- live-before +++ local-after @@ -218,10 +218,47 @@ class GatewayError(Exception): - def __init__(self, message: str, *, retriable: bool = False, response_data=None): + def __init__(self, message: str, *, retriable: bool = False, response_data=None, + code: str = "upstream_error"): super().__init__(message) self.retriable = retriable self.response_data = response_data + self.code = code + + +def _http_failure(status: int, data: object = None) -> tuple[str, str]: + """Return fixed diagnostics; provider errors can echo keys, URLs or prompts.""" + if status in (400, 422): + error = data.get("error") if isinstance(data, dict) else None + error = error if isinstance(error, dict) else {} + parameter = str(error.get("param") or "") + message = str(error.get("message") or "") + if "max_tokens" in parameter or "max_tokens" in message: + return "upstream_invalid_max_tokens", ( + f"上游返回 {status}:输出 Token 上限(max_tokens)不符合模型要求,请在管理端调整该模型参数" + ) + return "upstream_invalid_parameters", f"上游返回 {status}:模型请求参数不合法,请检查模型及参数配置" + if status in (401, 403): + return "upstream_auth_failed", f"上游返回 {status}:模型鉴权失败,请检查模型密钥与访问权限" + if status == 404: + return "upstream_not_found", "上游返回 404:模型或接口地址不存在,请检查模型配置" + if status == 429: + return "upstream_rate_limited", "上游返回 429:模型限流或额度不足,请稍后重试并检查服务商额度" + if status in (408, 504): + return "upstream_timeout", f"上游返回 {status}:模型响应超时,请稍后重试" + if status >= 500: + return "upstream_unavailable", f"上游返回 {status}:模型服务暂不可用,请稍后重试" + return "upstream_request_rejected", f"上游返回 {status}:模型请求被拒绝,请检查模型配置" + + +def _exception_failure(exc: Exception | None) -> dict: + if isinstance(exc, GatewayError): + return {"error": str(exc), "error_code": exc.code} + if isinstance(exc, httpx.TimeoutException): + return {"error": "模型请求超时,请稍后重试", "error_code": "upstream_timeout"} + if isinstance(exc, httpx.HTTPError): + return {"error": "无法连接模型服务,请检查上游网络", "error_code": "upstream_connection_failed"} + return {"error": "模型响应格式不兼容,请检查模型接口协议", "error_code": "upstream_invalid_response"} async def _post_once( @@ -237,21 +274,19 @@ url, headers=headers, json=json_body, timeout=timeout ) except (httpx.TimeoutException, httpx.TransportError) as exc: - raise GatewayError(f"{type(exc).__name__}: {exc}", retriable=True) from exc + failure = _exception_failure(exc) + raise GatewayError(failure["error"], code=failure["error_code"], retriable=True) from exc try: data = response.json() except ValueError: data = None - if response.status_code in RETRIABLE_STATUS: + if response.status_code >= 400: + code, detail = _http_failure(response.status_code, data) raise GatewayError( - f"上游返回 {response.status_code}", retriable=True, response_data=data - ) - if response.status_code >= 400: - raise GatewayError( - f"上游返回 {response.status_code}: {response.text[:200]}", retriable=False, response_data=data + detail, code=code, retriable=response.status_code in RETRIABLE_STATUS, response_data=data ) if data is None: - raise GatewayError("上游响应不是合法 JSON", retriable=False) + raise GatewayError("上游响应不是合法 JSON", retriable=False, code="upstream_invalid_response") return data @@ -280,7 +315,8 @@ "provider": outlet.name, "text": "", "latency_ms": 0, - "error": f"熔断中(连续失败 {outlet.breaker.failures} 次)", + "error": f"熔断中(连续失败 {outlet.breaker.failures} 次),请稍后重试并检查模型配置", + "error_code": "upstream_circuit_open", } config = outlet.config @@ -290,7 +326,7 @@ ) if config_error: return {"provider": outlet.name, "text": "", "latency_ms": 0, - "error": config_error} + "error": config_error, "error_code": "model_configuration_invalid"} timeout = min(float(config.get("timeout_ms") or 30000) / 1000.0, deadline) try: @@ -373,7 +409,7 @@ "provider": outlet.name, "text": "", "latency_ms": int((time.monotonic() - started) * 1000), - "error": str(last)[:200], + **_exception_failure(last), } except TimeoutError: outlet.breaker.record(False) @@ -382,6 +418,7 @@ "text": "", "latency_ms": int((time.monotonic() - started) * 1000), "error": f"超过单路时限 {deadline:.0f}s", + "error_code": "upstream_timeout", } except (GatewayError, httpx.HTTPError, ValueError) as exc: @@ -389,7 +426,7 @@ # concurrent model. Keep healthy candidates available to the caller. outlet.breaker.record(False) return {"provider": outlet.name, "text": "", "latency_ms": int((time.monotonic() - started) * 1000), - "error": str(exc)[:200] or type(exc).__name__} + **_exception_failure(exc)} async def _dify_message_files(client, outlet, messages, image_b64, timeout): @@ -750,6 +787,36 @@ knowledge_result = {**knowledge_trace, "query": redact(query)[:1000]} messages = inject_references(messages, []) + def failed_response(payload: dict) -> JSONResponse: + # Old desktop versions only read detail. Keep the existing shape, + # while making failures discoverable in the same model_calls table. + request_id = secrets.token_hex(12) + candidates = [ + {**item, "request_id": request_id, + "error": item.get("error") or "模型未返回可用回复,请检查模型输出设置", + "error_code": item.get("error_code") or "upstream_empty_response"} + for item in payload.get("candidates", []) + ] + reasons = list(dict.fromkeys(item["error"] for item in candidates)) + codes = {item["error_code"] for item in candidates} + payload.update({ + "candidates": candidates, "request_id": request_id, + "code": next(iter(codes)) if len(codes) == 1 else "upstream_all_failed", + "detail": "模型调用失败:" + ";".join(reasons[:3]) + f"(请求编号:{request_id})", + }) + try: + log_queue.put_nowait({ + "desktop_account_id": int(account["id"]), "tenant_id": tenant, + "device_id": x_device_id, "task_id": usage_recorder.context["task_id"], + "roles_version": version, "judge_mode": mode, "chosen": "", + "judge": payload["judge"], "candidates": candidates, "total_ms": payload["total_ms"], + "customer_text": str(body.get("customer_text") or ""), "reply_text": "", + "purpose": purpose, "knowledge_retrieval": knowledge_result, + }) + except asyncio.QueueFull: + pass + return JSONResponse(payload, status_code=502, headers={"X-Request-Id": request_id}) + if tools: # 工具轮:只问主出口,不并发也不评审。 # @@ -782,7 +849,7 @@ "total_ms": int((time.monotonic() - started) * 1000), } if single.get("error") and not payload["reply"] and not payload["tool_calls"]: - return JSONResponse(payload, status_code=502) + return failed_response(payload) if idempotency_key: idempotency[idempotency_key] = (now, payload) try: @@ -814,7 +881,7 @@ ) results = [ {"provider": outlet.name, "text": "", "latency_ms": 0, - "error": f"模型调用异常:{type(result).__name__}"} + "error": f"模型调用异常:{type(result).__name__}", "error_code": "upstream_call_failed"} if isinstance(result, BaseException) else result for outlet, result in zip(answers, results) ] @@ -826,7 +893,7 @@ "roles_version": version, "total_ms": int((time.monotonic() - started) * 1000), } - return JSONResponse(payload, status_code=502) + return failed_response(payload) if purpose == "guard": verdict = dict(_EMPTY_VERDICT)