TokenHubService.cs 21 KB

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