package com.yunhai.agent.llm; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.yunhai.agent.config.AgentSettings; import com.yunhai.agent.llm.dto.ChatMessage; import com.yunhai.agent.llm.dto.ChatResult; import com.yunhai.agent.llm.dto.ToolCall; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import java.util.ArrayList; import java.util.List; /** * 大模型客户端(OpenAI 兼容协议):默认对接 DeepSeek 官方 * {@code https://api.deepseek.com/chat/completions},模型 {@code deepseek-flash}。 *
模型标识以官方实时清单为准({@code GET https://api.deepseek.com/models})。 * *
协议要点: *
换供应商只要改 {@code agent.llm.host}(豆包方舟 / 千问兼容模式 / 自有网关都遵循该协议)。
*/
@Slf4j
@Component
public class DeepSeekClient implements LlmClient {
private static final String TOOL_CHOICE_AUTO = "auto";
private final RestTemplate restTemplate;
private final AgentSettings settings;
public DeepSeekClient(@Qualifier("yhRestTemplate") RestTemplate restTemplate, AgentSettings settings) {
this.restTemplate = restTemplate;
this.settings = settings;
}
@Override
public ChatResult chat(List 典型场景:密钥无效时服务端回 401,而请求体是流式发送的,JDK 的 {@code HttpURLConnection}
* 会抛出 {@code HttpRetryException: cannot retry due to server authentication, in streaming mode}
* —— 这句话直接抛给前端毫无信息量,需要还原成「密钥无效」。
*/
private static String humanize(Throwable e, String url) {
Integer status = findStatus(e);
if (status != null && (status == 401 || status == 403)) {
return "大模型鉴权失败(HTTP " + status + "):api-key 无效、已过期或无权限,"
+ "请检查 agent.llm.api-key(或环境变量 DEEPSEEK_API_KEY)";
}
if (status != null && status == 402) {
return "大模型账户余额不足(HTTP 402):请前往大模型平台充值后重试";
}
if (status != null && status == 429) {
return "大模型请求过于频繁或账户余额不足(HTTP 429),请稍后重试或检查账户额度";
}
if (status != null && status >= 500) {
return "大模型服务端异常(HTTP " + status + "),请稍后重试";
}
String m = e.getMessage() == null ? e.toString() : e.getMessage();
if (m.contains("cannot retry due to server authentication")) {
return "大模型鉴权失败:api-key 无效或未配置,请检查 agent.llm.api-key";
}
if (m.contains("UnknownHost")) {
return "无法解析大模型地址 " + url + ",请检查网络或 agent.llm.host 配置";
}
if (m.contains("onnection refused") || m.contains("connect timed out")) {
return "无法连接大模型地址 " + url + ",请检查网络与代理设置";
}
if (m.contains("imed out") || m.contains("imeout")) {
return "调用大模型超时,请稍后重试(可调大 yh.http.read-timeout-ms)";
}
return "大模型服务不可用:" + m;
}
/**
* 把大模型返回体里的 {@code error.message} 翻成人话。
*
* 该分支拿不到 HTTP 状态码(平台 RestTemplate 对 4xx 不回抛),只能按文案特征识别。
* 典型:余额为 0 时 DeepSeek 回 {@code Insufficient Balance}(对应 HTTP 402),
* 原样抛给使用者等于没说清「要充值」。
*/
private static String humanizeApiError(String msg) {
String m = msg == null ? "" : msg;
String lower = m.toLowerCase();
if (lower.contains("insufficient balance") || lower.contains("insufficient_quota")
|| lower.contains("exceeded your current quota") || lower.contains("payment required")) {
return "大模型账户余额不足(" + m + "):请前往大模型平台充值后再试";
}
if (lower.contains("authentication fails") || lower.contains("invalid api key")
|| lower.contains("invalid_api_key") || lower.contains("unauthorized")) {
return "大模型鉴权失败(" + m + "):api-key 无效或已过期,请检查 agent.llm.api-key";
}
if (lower.contains("rate limit") || lower.contains("too many requests")) {
return "大模型请求过于频繁(" + m + "),请稍后重试";
}
if (lower.contains("model not exist") || lower.contains("model_not_found")
|| lower.contains("invalid model") || lower.contains("does not exist")) {
return "大模型模型标识无效(" + m + "):请用官方实时清单 GET https://api.deepseek.com/models "
+ "核对 agent.llm.model(当前应为 deepseek-flash 或 deepseek-v4-pro)";
}
return "大模型错误:" + m;
}
/** 从异常链里挖出 HTTP 状态码(HttpRetryException / HttpStatusCodeException 两种常见载体) */
private static Integer findStatus(Throwable e) {
for (Throwable t = e; t != null && t != t.getCause(); t = t.getCause()) {
if (t instanceof java.net.HttpRetryException) {
int code = ((java.net.HttpRetryException) t).responseCode();
if (code > 0) {
return code;
}
} else if (t instanceof org.springframework.web.client.HttpStatusCodeException) {
return ((org.springframework.web.client.HttpStatusCodeException) t).getRawStatusCode();
}
}
return null;
}
private boolean isReasoner(String model) {
return model != null && model.toLowerCase().contains("reasoner");
}
}