TokenHubService.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. using Microsoft.EntityFrameworkCore.Internal;
  2. using Microsoft.Extensions.Logging.Abstractions;
  3. using OASystem.API.OAMethodLib.TokenHubAI.Models;
  4. using System.Net;
  5. using System.Text.Json;
  6. namespace OASystem.API.OAMethodLib.TokenHubAI;
  7. /// <summary>
  8. /// TokenHub AI 服务实现
  9. /// </summary>
  10. public class TokenHubService : ITokenHubService, IDisposable
  11. {
  12. private readonly HttpClient _httpClient;
  13. private readonly string _apiKey;
  14. private readonly string _baseUrl;
  15. private readonly JsonSerializerOptions _jsonOptions;
  16. private readonly ILogger<TokenHubService> _logger;
  17. // 重试配置
  18. private int _maxRetries = 3;
  19. private TimeSpan _initialRetryDelay = TimeSpan.FromSeconds(1);
  20. private TimeSpan _maxRetryDelay = TimeSpan.FromSeconds(30);
  21. public TokenHubService(
  22. string apiKey,
  23. string? baseUrl = null,
  24. ILogger<TokenHubService>? logger = null)
  25. {
  26. if (string.IsNullOrWhiteSpace(apiKey))
  27. throw new ArgumentException("API Key 不能为空", nameof(apiKey));
  28. _apiKey = apiKey;
  29. _baseUrl = baseUrl ?? "https://tokenhub.tencentmaas.com/v1/chat/completions";
  30. _logger = logger ?? NullLogger<TokenHubService>.Instance;
  31. _httpClient = new HttpClient();
  32. _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_apiKey}");
  33. _httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
  34. _httpClient.Timeout = TimeSpan.FromSeconds(120);
  35. _jsonOptions = new JsonSerializerOptions
  36. {
  37. PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
  38. DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
  39. };
  40. }
  41. /// <summary>
  42. /// 发送聊天请求(非流式)
  43. /// </summary>
  44. public async Task<ChatResponse> ChatAsync(ChatRequest request, CancellationToken cancellationToken = default)
  45. {
  46. try
  47. {
  48. return await SendChatRequestAsync(request, cancellationToken);
  49. }
  50. catch (TokenHubException ex)
  51. {
  52. _logger.LogWarning(ex, ex.ErrorDetail?.Message ?? "-");
  53. var message = ex.ErrorDetail?.Message_ZH
  54. ?? TokenHubErrorExtensions.GetMessage(ex.ErrorCode);
  55. throw new TokenHubException(message, 400, ex.ErrorDetail);
  56. }
  57. catch (TaskCanceledException ex) when (ex.CancellationToken == cancellationToken)
  58. {
  59. _logger.LogWarning(ex, "请求被取消");
  60. throw new TokenHubException("请求已被取消", 499, new TokenHubError
  61. {
  62. Type = "server_error",
  63. Code = "499001",
  64. Message = "请求已被客户端取消,请检查客户端连接状态"
  65. });
  66. }
  67. catch (TaskCanceledException ex)
  68. {
  69. _logger.LogWarning(ex, "请求超时");
  70. throw new TokenHubException("请求超时", 504, new TokenHubError
  71. {
  72. Type = "timeout_error",
  73. Code = "504001",
  74. Message = "服务响应超时,请稍后重试"
  75. });
  76. }
  77. catch (Exception ex) when (ex is not TokenHubException)
  78. {
  79. _logger.LogError(ex, "发送请求时发生未预期错误");
  80. throw new TokenHubException($"请求失败: {ex.Message}", 500, new TokenHubError
  81. {
  82. Type = "server_error",
  83. Code = "500001",
  84. Message = "服务内部发生错误,请稍后重试或联系技术支持"
  85. });
  86. }
  87. }
  88. /// <summary>
  89. /// 发送聊天请求(流式)
  90. /// </summary>
  91. public async Task StreamChatAsync(ChatRequest request, Action<StreamChunk> onChunk, CancellationToken cancellationToken = default)
  92. {
  93. if (onChunk == null)
  94. throw new ArgumentNullException(nameof(onChunk));
  95. _httpClient.DefaultRequestHeaders.Remove("Accept");
  96. _httpClient.DefaultRequestHeaders.Add("Accept", "text/event-stream");
  97. try
  98. {
  99. await SendStreamRequestAsync(request, onChunk, cancellationToken);
  100. }
  101. catch (TokenHubException ex)
  102. {
  103. var message = ex.ErrorDetail?.Message_ZH
  104. ?? TokenHubErrorExtensions.GetMessage(ex.ErrorCode);
  105. throw new TokenHubException(message, 400, ex.ErrorDetail);
  106. }
  107. catch (TaskCanceledException ex) when (ex.CancellationToken == cancellationToken)
  108. {
  109. _logger.LogWarning(ex, "流式请求被取消");
  110. throw new TokenHubException("请求已被取消", 499, new TokenHubError
  111. {
  112. Type = "server_error",
  113. Code = "499001",
  114. Message = "request was canceled"
  115. });
  116. }
  117. catch (TaskCanceledException ex)
  118. {
  119. _logger.LogWarning(ex, "流式请求超时");
  120. throw new TokenHubException("请求超时", 504, new TokenHubError
  121. {
  122. Type = "timeout_error",
  123. Code = "504001",
  124. Message = "gateway timeout"
  125. });
  126. }
  127. catch (Exception ex) when (ex is not TokenHubException)
  128. {
  129. _logger.LogError(ex, "发送流式请求时发生未预期错误");
  130. throw new TokenHubException($"请求失败: {ex.Message}", 500, new TokenHubError
  131. {
  132. Type = "server_error",
  133. Code = "500001",
  134. Message = ex.Message
  135. });
  136. }
  137. finally
  138. {
  139. _httpClient.DefaultRequestHeaders.Remove("Accept");
  140. _httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
  141. }
  142. }
  143. /// <summary>
  144. /// 带重试的聊天请求
  145. /// </summary>
  146. public async Task<ChatResponse> ChatWithRetryAsync(ChatRequest request, int maxRetries = 3, CancellationToken cancellationToken = default)
  147. {
  148. var retryCount = 0;
  149. var delay = _initialRetryDelay;
  150. while (true)
  151. {
  152. try
  153. {
  154. return await ChatAsync(request, cancellationToken);
  155. }
  156. catch (TokenHubException ex) when (ex.IsRetryable && retryCount < maxRetries)
  157. {
  158. retryCount++;
  159. _logger.LogWarning(ex,
  160. "请求失败 (尝试 {RetryCount}/{MaxRetries}),将在 {DelayMs}ms 后重试",
  161. retryCount, maxRetries, delay.TotalMilliseconds);
  162. await Task.Delay(delay, cancellationToken);
  163. // 指数退避
  164. delay = TimeSpan.FromSeconds(Math.Min(
  165. delay.TotalSeconds * 2,
  166. _maxRetryDelay.TotalSeconds
  167. ));
  168. }
  169. catch (TokenHubException ex) when (ex.ErrorCode == "429001" || ex.ErrorCode == "429002")
  170. {
  171. // 限流错误,使用更长的退避时间
  172. retryCount++;
  173. var retryAfter = TimeSpan.FromSeconds(Math.Min(
  174. delay.TotalSeconds * 3,
  175. _maxRetryDelay.TotalSeconds
  176. ));
  177. _logger.LogWarning(ex,
  178. "触发限流 (尝试 {RetryCount}/{MaxRetries}),将在 {DelayMs}ms 后重试",
  179. retryCount, maxRetries, retryAfter.TotalMilliseconds);
  180. await Task.Delay(retryAfter, cancellationToken);
  181. delay = retryAfter;
  182. }
  183. catch (TokenHubException)
  184. {
  185. // 不可重试的错误直接抛出
  186. throw;
  187. }
  188. }
  189. }
  190. /// <summary>
  191. /// 获取可用模型列表
  192. /// </summary>
  193. public async Task<List<string>> GetAvailableModelsAsync(CancellationToken cancellationToken = default)
  194. {
  195. // TokenHub 当前没有公开的模型列表 API
  196. return await Task.FromResult(new List<string>
  197. {
  198. "hunyuan-2.0-instruct-20251111"
  199. });
  200. }
  201. /// <summary>
  202. /// 设置超时时间
  203. /// </summary>
  204. public void SetTimeout(TimeSpan timeout)
  205. {
  206. _httpClient.Timeout = timeout;
  207. }
  208. /// <summary>
  209. /// 设置重试策略
  210. /// </summary>
  211. public void SetRetryPolicy(int maxRetries, TimeSpan? initialDelay = null, TimeSpan? maxDelay = null)
  212. {
  213. _maxRetries = maxRetries;
  214. if (initialDelay.HasValue)
  215. _initialRetryDelay = initialDelay.Value;
  216. if (maxDelay.HasValue)
  217. _maxRetryDelay = maxDelay.Value;
  218. }
  219. #region 私有方法
  220. /// <summary>
  221. /// 发送非流式请求
  222. /// </summary>
  223. private async Task<ChatResponse> SendChatRequestAsync(ChatRequest request, CancellationToken cancellationToken)
  224. {
  225. var requestBody = BuildRequestBody(request, stream: false);
  226. var json = System.Text.Json.JsonSerializer.Serialize(requestBody, _jsonOptions);
  227. var content = new StringContent(json, Encoding.UTF8, "application/json");
  228. _logger.LogDebug("发送请求到 TokenHub: Model={Model}, Messages={MessageCount}",
  229. request.Model, request.Messages.Count);
  230. var httpResponse = await _httpClient.PostAsync(_baseUrl, content, cancellationToken);
  231. var responseJson = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
  232. _logger.LogDebug("收到响应: StatusCode={StatusCode}, RequestId={RequestId}",
  233. httpResponse.StatusCode,
  234. ExtractRequestId(responseJson));
  235. if (!httpResponse.IsSuccessStatusCode)
  236. {
  237. throw CreateExceptionFromResponse(httpResponse.StatusCode, responseJson);
  238. }
  239. return ParseChatResponse(responseJson);
  240. }
  241. /// <summary>
  242. /// 发送流式请求
  243. /// </summary>
  244. private async Task SendStreamRequestAsync(
  245. ChatRequest request,
  246. Action<StreamChunk> onChunk,
  247. CancellationToken cancellationToken)
  248. {
  249. var requestBody = BuildRequestBody(request, stream: true);
  250. var json = System.Text.Json.JsonSerializer.Serialize(requestBody, _jsonOptions);
  251. var content = new StringContent(json, Encoding.UTF8, "application/json");
  252. _logger.LogDebug("发送流式请求到 TokenHub: Model={Model}", request.Model);
  253. var httpResponse = await _httpClient.PostAsync(_baseUrl, content, cancellationToken);
  254. if (!httpResponse.IsSuccessStatusCode)
  255. {
  256. var errorJson = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
  257. throw CreateExceptionFromResponse(httpResponse.StatusCode, errorJson);
  258. }
  259. using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken);
  260. using var reader = new StreamReader(stream);
  261. string? line;
  262. while ((line = await reader.ReadLineAsync()) != null)
  263. {
  264. cancellationToken.ThrowIfCancellationRequested();
  265. if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
  266. continue;
  267. var data = line.Substring(6);
  268. if (data == "[DONE]")
  269. {
  270. onChunk(new StreamChunk { IsDone = true });
  271. break;
  272. }
  273. try
  274. {
  275. var chunk = ParseStreamChunk(data);
  276. if (chunk != null)
  277. {
  278. onChunk(chunk);
  279. if (chunk.IsDone)
  280. break;
  281. }
  282. }
  283. catch (System.Text.Json.JsonException ex)
  284. {
  285. _logger.LogWarning(ex, "解析流式数据失败: {Data}", data);
  286. // 跳过无法解析的数据
  287. }
  288. }
  289. }
  290. /// <summary>
  291. /// 构建请求体
  292. /// </summary>
  293. private object BuildRequestBody(ChatRequest request, bool stream)
  294. {
  295. var messages = new List<object>();
  296. if (!string.IsNullOrEmpty(request.SystemPrompt))
  297. {
  298. messages.Add(new { role = "system", content = request.SystemPrompt });
  299. }
  300. foreach (var msg in request.Messages)
  301. {
  302. messages.Add(new { role = msg.Role, content = msg.Content });
  303. }
  304. return new
  305. {
  306. model = request.Model,
  307. messages = messages,
  308. max_tokens = request.MaxTokens,
  309. temperature = request.Temperature,
  310. stream = stream
  311. };
  312. }
  313. /// <summary>
  314. /// 解析聊天响应
  315. /// </summary>
  316. private ChatResponse ParseChatResponse(string json)
  317. {
  318. using var document = JsonDocument.Parse(json);
  319. var root = document.RootElement;
  320. var response = new ChatResponse
  321. {
  322. RawJson = json,
  323. IsSuccess = true
  324. };
  325. if (root.TryGetProperty("id", out var id))
  326. response.Id = id.GetString();
  327. if (root.TryGetProperty("model", out var model))
  328. response.Model = model.GetString();
  329. if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
  330. {
  331. var choice = choices[0];
  332. if (choice.TryGetProperty("message", out var message))
  333. {
  334. if (message.TryGetProperty("content", out var content))
  335. response.Content = content.GetString();
  336. }
  337. if (choice.TryGetProperty("finish_reason", out var finishReason))
  338. {
  339. // 检查是否被内容安全过滤
  340. if (finishReason.GetString() == "content_filter")
  341. {
  342. response.IsSuccess = false;
  343. response.ErrorMessage = "内容被安全策略过滤";
  344. }
  345. }
  346. }
  347. if (root.TryGetProperty("usage", out var usage))
  348. {
  349. response.Usage = new TokenUsage
  350. {
  351. PromptTokens = usage.TryGetProperty("prompt_tokens", out var pt) ? pt.GetInt32() : 0,
  352. CompletionTokens = usage.TryGetProperty("completion_tokens", out var ct) ? ct.GetInt32() : 0,
  353. TotalTokens = usage.TryGetProperty("total_tokens", out var tt) ? tt.GetInt32() : 0
  354. };
  355. }
  356. return response;
  357. }
  358. /// <summary>
  359. /// 解析流式数据块
  360. /// </summary>
  361. private StreamChunk? ParseStreamChunk(string data)
  362. {
  363. using var document = JsonDocument.Parse(data);
  364. var root = document.RootElement;
  365. var chunk = new StreamChunk();
  366. if (root.TryGetProperty("model", out var model))
  367. chunk.Model = model.GetString();
  368. if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
  369. {
  370. var choice = choices[0];
  371. if (choice.TryGetProperty("delta", out var delta))
  372. {
  373. if (delta.TryGetProperty("content", out var content))
  374. {
  375. chunk.Content = content.GetString() ?? string.Empty;
  376. }
  377. }
  378. // 检查是否结束
  379. if (choice.TryGetProperty("finish_reason", out var finishReason))
  380. {
  381. var reason = finishReason.GetString();
  382. chunk.IsDone = reason == "stop" || reason == "content_filter";
  383. if (reason == "content_filter")
  384. {
  385. chunk.Content = "[内容被安全策略过滤]";
  386. }
  387. }
  388. }
  389. return chunk;
  390. }
  391. /// <summary>
  392. /// 从错误响应创建异常
  393. /// </summary>
  394. private TokenHubException CreateExceptionFromResponse(HttpStatusCode statusCode, string responseJson)
  395. {
  396. var statusCodeInt = (int)statusCode;
  397. var errorDetail = ParseErrorResponse(responseJson, statusCodeInt);
  398. var message = errorDetail?.Message ?? $"HTTP {(int)statusCode}: 请求失败";
  399. _logger.LogWarning("TokenHub API 返回错误: StatusCode={StatusCode}, Code={Code}, Type={Type}, RequestId={RequestId}",
  400. statusCodeInt,
  401. errorDetail?.Code,
  402. errorDetail?.Type,
  403. errorDetail?.RequestId);
  404. return new TokenHubException(message, statusCodeInt, errorDetail);
  405. }
  406. /// <summary>
  407. /// 解析错误响应
  408. /// </summary>
  409. private TokenHubError? ParseErrorResponse(string responseJson, int statusCode)
  410. {
  411. try
  412. {
  413. using var document = JsonDocument.Parse(responseJson);
  414. var root = document.RootElement;
  415. // 判断是 OpenAI 协议还是 Anthropic 协议
  416. if (root.TryGetProperty("error", out var errorElement))
  417. {
  418. // OpenAI 协议
  419. if (errorElement.ValueKind == JsonValueKind.Object)
  420. {
  421. return ParseOpenAiError(errorElement);
  422. }
  423. }
  424. // Anthropic 协议
  425. if (root.TryGetProperty("type", out var typeProp) && typeProp.GetString() == "error")
  426. {
  427. if (root.TryGetProperty("error", out var anthropicError))
  428. {
  429. return ParseAnthropicError(anthropicError);
  430. }
  431. }
  432. // 简化格式(链路短路等)
  433. if (root.TryGetProperty("error", out var simpleError) &&
  434. simpleError.ValueKind == JsonValueKind.Object)
  435. {
  436. return new TokenHubError
  437. {
  438. Type = simpleError.TryGetProperty("type", out var t) ? t.GetString() : null,
  439. Code = simpleError.TryGetProperty("code", out var c) ? c.GetString() : null,
  440. Message = simpleError.TryGetProperty("message", out var m) ? m.GetString() : null,
  441. Message_ZH = simpleError.TryGetProperty("message_zh", out var m_zh) ? m_zh.GetString() : null,
  442. RequestId = simpleError.TryGetProperty("request_id", out var r) ? r.GetString() : null
  443. };
  444. }
  445. return null;
  446. }
  447. catch (System.Text.Json.JsonException ex)
  448. {
  449. _logger.LogWarning(ex, "解析错误响应失败: {ResponseJson}", responseJson);
  450. return null;
  451. }
  452. }
  453. /// <summary>
  454. /// 解析 OpenAI 协议错误
  455. /// </summary>
  456. private TokenHubError ParseOpenAiError(JsonElement errorElement)
  457. {
  458. var error = new TokenHubError();
  459. if (errorElement.TryGetProperty("type", out var type))
  460. error.Type = type.GetString();
  461. if (errorElement.TryGetProperty("code", out var code))
  462. error.Code = code.GetString();
  463. if (errorElement.TryGetProperty("message", out var message))
  464. error.Message = message.GetString();
  465. if (errorElement.TryGetProperty("message_zh", out var message_zh))
  466. error.Message_ZH = message_zh.GetString();
  467. if (errorElement.TryGetProperty("source", out var source))
  468. error.Source = source.GetString();
  469. if (errorElement.TryGetProperty("upstream_code", out var upstreamCode))
  470. error.UpstreamCode = upstreamCode.GetString();
  471. if (errorElement.TryGetProperty("upstream_status", out var upstreamStatus))
  472. error.UpstreamStatus = upstreamStatus.GetInt32();
  473. if (errorElement.TryGetProperty("request_id", out var requestId))
  474. error.RequestId = requestId.GetString();
  475. return error;
  476. }
  477. /// <summary>
  478. /// 解析 Anthropic 协议错误
  479. /// </summary>
  480. private TokenHubError ParseAnthropicError(JsonElement errorElement)
  481. {
  482. var error = new TokenHubError
  483. {
  484. Source = "upstream" // Anthropic 协议默认视为上游
  485. };
  486. if (errorElement.TryGetProperty("type", out var type))
  487. error.Type = type.GetString();
  488. if (errorElement.TryGetProperty("message", out var message))
  489. error.Message = message.GetString();
  490. if (errorElement.TryGetProperty("message_zh", out var message_zh))
  491. error.Message_ZH = message_zh.GetString();
  492. if (errorElement.TryGetProperty("reqid", out var reqid))
  493. error.RequestId = reqid.GetString();
  494. // Anthropic 协议没有业务码,根据类型映射
  495. error.Code = MapAnthropicTypeToCode(error.Type);
  496. return error;
  497. }
  498. /// <summary>
  499. /// 将 Anthropic 错误类型映射到业务码
  500. /// </summary>
  501. private string? MapAnthropicTypeToCode(string? type)
  502. {
  503. return type switch
  504. {
  505. "invalid_request_error" => "400002",
  506. "authentication_error" => "401002",
  507. "permission_error" => "403001",
  508. "not_found_error" => "400004",
  509. "rate_limit_error" => "429001",
  510. "overloaded_error" => "502001",
  511. "api_error" => "500001",
  512. _ => null
  513. };
  514. }
  515. /// <summary>
  516. /// 从响应中提取 Request ID
  517. /// </summary>
  518. private string? ExtractRequestId(string responseJson)
  519. {
  520. try
  521. {
  522. using var document = JsonDocument.Parse(responseJson);
  523. var root = document.RootElement;
  524. if (root.TryGetProperty("error", out var error))
  525. {
  526. if (error.TryGetProperty("request_id", out var requestId))
  527. return requestId.GetString();
  528. }
  529. if (root.TryGetProperty("id", out var id))
  530. return id.GetString();
  531. }
  532. catch
  533. {
  534. // 忽略解析错误
  535. }
  536. return null;
  537. }
  538. #endregion
  539. /// <summary>
  540. /// 释放资源
  541. /// </summary>
  542. public void Dispose()
  543. {
  544. _httpClient?.Dispose();
  545. GC.SuppressFinalize(this);
  546. }
  547. }