| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630 |
- using Microsoft.EntityFrameworkCore.Internal;
- using Microsoft.Extensions.Logging.Abstractions;
- using OASystem.API.OAMethodLib.TokenHubAI.Models;
- using System.Net;
- using System.Text.Json;
- namespace OASystem.API.OAMethodLib.TokenHubAI;
- /// <summary>
- /// TokenHub AI 服务实现
- /// </summary>
- public class TokenHubService : ITokenHubService, IDisposable
- {
- private readonly HttpClient _httpClient;
- private readonly string _apiKey;
- private readonly string _baseUrl;
- private readonly JsonSerializerOptions _jsonOptions;
- private readonly ILogger<TokenHubService> _logger;
- // 重试配置
- private int _maxRetries = 3;
- private TimeSpan _initialRetryDelay = TimeSpan.FromSeconds(1);
- private TimeSpan _maxRetryDelay = TimeSpan.FromSeconds(30);
- public TokenHubService(
- string apiKey,
- string? baseUrl = null,
- ILogger<TokenHubService>? logger = null)
- {
- if (string.IsNullOrWhiteSpace(apiKey))
- throw new ArgumentException("API Key 不能为空", nameof(apiKey));
- _apiKey = apiKey;
- _baseUrl = baseUrl ?? "https://tokenhub.tencentmaas.com/v1/chat/completions";
- _logger = logger ?? NullLogger<TokenHubService>.Instance;
- _httpClient = new HttpClient();
- _httpClient.DefaultRequestHeaders.Add("Authorization", $"Bearer {_apiKey}");
- _httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
- _httpClient.Timeout = TimeSpan.FromSeconds(120);
- _jsonOptions = new JsonSerializerOptions
- {
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
- };
- }
- /// <summary>
- /// 发送聊天请求(非流式)
- /// </summary>
- public async Task<ChatResponse> ChatAsync(ChatRequest request, CancellationToken cancellationToken = default)
- {
- try
- {
- return await SendChatRequestAsync(request, cancellationToken);
- }
- catch (TokenHubException ex)
- {
- _logger.LogWarning(ex, ex.ErrorDetail?.Message ?? "-");
- var message = TokenHubErrorExtensions.GetMessage(ex.ErrorCode);
- throw new TokenHubException(message, 400, ex.ErrorDetail);
- }
- catch (TaskCanceledException ex) when (ex.CancellationToken == cancellationToken)
- {
- _logger.LogWarning(ex, "请求被取消");
- throw new TokenHubException("请求已被取消", 499, new TokenHubError
- {
- Type = "server_error",
- Code = "499001",
- Message = "请求已被客户端取消,请检查客户端连接状态"
- });
- }
- catch (TaskCanceledException ex)
- {
- _logger.LogWarning(ex, "请求超时");
- throw new TokenHubException("请求超时", 504, new TokenHubError
- {
- Type = "timeout_error",
- Code = "504001",
- Message = "服务响应超时,请稍后重试"
- });
- }
- catch (Exception ex) when (ex is not TokenHubException)
- {
- _logger.LogError(ex, "发送请求时发生未预期错误");
- throw new TokenHubException($"请求失败: {ex.Message}", 500, new TokenHubError
- {
- Type = "server_error",
- Code = "500001",
- Message = "服务内部发生错误,请稍后重试或联系技术支持"
- });
- }
- }
- /// <summary>
- /// 发送聊天请求(流式)
- /// </summary>
- public async Task StreamChatAsync(ChatRequest request, Action<StreamChunk> onChunk, CancellationToken cancellationToken = default)
- {
- if (onChunk == null)
- throw new ArgumentNullException(nameof(onChunk));
- _httpClient.DefaultRequestHeaders.Remove("Accept");
- _httpClient.DefaultRequestHeaders.Add("Accept", "text/event-stream");
- try
- {
- await SendStreamRequestAsync(request, onChunk, cancellationToken);
- }
- catch (TokenHubException)
- {
- throw;
- }
- catch (TaskCanceledException ex) when (ex.CancellationToken == cancellationToken)
- {
- _logger.LogWarning(ex, "流式请求被取消");
- throw new TokenHubException("请求已被取消", 499, new TokenHubError
- {
- Type = "server_error",
- Code = "499001",
- Message = "request was canceled"
- });
- }
- catch (TaskCanceledException ex)
- {
- _logger.LogWarning(ex, "流式请求超时");
- throw new TokenHubException("请求超时", 504, new TokenHubError
- {
- Type = "timeout_error",
- Code = "504001",
- Message = "gateway timeout"
- });
- }
- catch (Exception ex) when (ex is not TokenHubException)
- {
- _logger.LogError(ex, "发送流式请求时发生未预期错误");
- throw new TokenHubException($"请求失败: {ex.Message}", 500, new TokenHubError
- {
- Type = "server_error",
- Code = "500001",
- Message = ex.Message
- });
- }
- finally
- {
- _httpClient.DefaultRequestHeaders.Remove("Accept");
- _httpClient.DefaultRequestHeaders.Add("Accept", "application/json");
- }
- }
- /// <summary>
- /// 带重试的聊天请求
- /// </summary>
- public async Task<ChatResponse> ChatWithRetryAsync(ChatRequest request, int maxRetries = 3, CancellationToken cancellationToken = default)
- {
- var retryCount = 0;
- var delay = _initialRetryDelay;
- while (true)
- {
- try
- {
- return await ChatAsync(request, cancellationToken);
- }
- catch (TokenHubException ex) when (ex.IsRetryable && retryCount < maxRetries)
- {
- retryCount++;
- _logger.LogWarning(ex,
- "请求失败 (尝试 {RetryCount}/{MaxRetries}),将在 {DelayMs}ms 后重试",
- retryCount, maxRetries, delay.TotalMilliseconds);
- await Task.Delay(delay, cancellationToken);
- // 指数退避
- delay = TimeSpan.FromSeconds(Math.Min(
- delay.TotalSeconds * 2,
- _maxRetryDelay.TotalSeconds
- ));
- }
- catch (TokenHubException ex) when (ex.ErrorCode == "429001" || ex.ErrorCode == "429002")
- {
- // 限流错误,使用更长的退避时间
- retryCount++;
- var retryAfter = TimeSpan.FromSeconds(Math.Min(
- delay.TotalSeconds * 3,
- _maxRetryDelay.TotalSeconds
- ));
- _logger.LogWarning(ex,
- "触发限流 (尝试 {RetryCount}/{MaxRetries}),将在 {DelayMs}ms 后重试",
- retryCount, maxRetries, retryAfter.TotalMilliseconds);
- await Task.Delay(retryAfter, cancellationToken);
- delay = retryAfter;
- }
- catch (TokenHubException)
- {
- // 不可重试的错误直接抛出
- throw;
- }
- }
- }
- /// <summary>
- /// 获取可用模型列表
- /// </summary>
- public async Task<List<string>> GetAvailableModelsAsync(CancellationToken cancellationToken = default)
- {
- // TokenHub 当前没有公开的模型列表 API
- return await Task.FromResult(new List<string>
- {
- "hunyuan-2.0-instruct-20251111"
- });
- }
- /// <summary>
- /// 设置超时时间
- /// </summary>
- public void SetTimeout(TimeSpan timeout)
- {
- _httpClient.Timeout = timeout;
- }
- /// <summary>
- /// 设置重试策略
- /// </summary>
- public void SetRetryPolicy(int maxRetries, TimeSpan? initialDelay = null, TimeSpan? maxDelay = null)
- {
- _maxRetries = maxRetries;
- if (initialDelay.HasValue)
- _initialRetryDelay = initialDelay.Value;
- if (maxDelay.HasValue)
- _maxRetryDelay = maxDelay.Value;
- }
- #region 私有方法
- /// <summary>
- /// 发送非流式请求
- /// </summary>
- private async Task<ChatResponse> SendChatRequestAsync(ChatRequest request, CancellationToken cancellationToken)
- {
- var requestBody = BuildRequestBody(request, stream: false);
- var json = System.Text.Json.JsonSerializer.Serialize(requestBody, _jsonOptions);
- var content = new StringContent(json, Encoding.UTF8, "application/json");
- _logger.LogDebug("发送请求到 TokenHub: Model={Model}, Messages={MessageCount}",
- request.Model, request.Messages.Count);
- var httpResponse = await _httpClient.PostAsync(_baseUrl, content, cancellationToken);
- var responseJson = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
- _logger.LogDebug("收到响应: StatusCode={StatusCode}, RequestId={RequestId}",
- httpResponse.StatusCode,
- ExtractRequestId(responseJson));
- if (!httpResponse.IsSuccessStatusCode)
- {
- throw CreateExceptionFromResponse(httpResponse.StatusCode, responseJson);
- }
- return ParseChatResponse(responseJson);
- }
- /// <summary>
- /// 发送流式请求
- /// </summary>
- private async Task SendStreamRequestAsync(
- ChatRequest request,
- Action<StreamChunk> onChunk,
- CancellationToken cancellationToken)
- {
- var requestBody = BuildRequestBody(request, stream: true);
- var json = System.Text.Json.JsonSerializer.Serialize(requestBody, _jsonOptions);
- var content = new StringContent(json, Encoding.UTF8, "application/json");
- _logger.LogDebug("发送流式请求到 TokenHub: Model={Model}", request.Model);
- var httpResponse = await _httpClient.PostAsync(_baseUrl, content, cancellationToken);
- if (!httpResponse.IsSuccessStatusCode)
- {
- var errorJson = await httpResponse.Content.ReadAsStringAsync(cancellationToken);
- throw CreateExceptionFromResponse(httpResponse.StatusCode, errorJson);
- }
- using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken);
- using var reader = new StreamReader(stream);
- string? line;
- while ((line = await reader.ReadLineAsync()) != null)
- {
- cancellationToken.ThrowIfCancellationRequested();
- if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
- continue;
- var data = line.Substring(6);
- if (data == "[DONE]")
- {
- onChunk(new StreamChunk { IsDone = true });
- break;
- }
- try
- {
- var chunk = ParseStreamChunk(data);
- if (chunk != null)
- {
- onChunk(chunk);
- if (chunk.IsDone)
- break;
- }
- }
- catch (System.Text.Json.JsonException ex)
- {
- _logger.LogWarning(ex, "解析流式数据失败: {Data}", data);
- // 跳过无法解析的数据
- }
- }
- }
- /// <summary>
- /// 构建请求体
- /// </summary>
- private object BuildRequestBody(ChatRequest request, bool stream)
- {
- var messages = new List<object>();
- if (!string.IsNullOrEmpty(request.SystemPrompt))
- {
- messages.Add(new { role = "system", content = request.SystemPrompt });
- }
- foreach (var msg in request.Messages)
- {
- messages.Add(new { role = msg.Role, content = msg.Content });
- }
- return new
- {
- model = request.Model,
- messages = messages,
- max_tokens = request.MaxTokens,
- temperature = request.Temperature,
- stream = stream
- };
- }
- /// <summary>
- /// 解析聊天响应
- /// </summary>
- private ChatResponse ParseChatResponse(string json)
- {
- using var document = JsonDocument.Parse(json);
- var root = document.RootElement;
- var response = new ChatResponse
- {
- RawJson = json,
- IsSuccess = true
- };
- if (root.TryGetProperty("id", out var id))
- response.Id = id.GetString();
- if (root.TryGetProperty("model", out var model))
- response.Model = model.GetString();
- if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
- {
- var choice = choices[0];
- if (choice.TryGetProperty("message", out var message))
- {
- if (message.TryGetProperty("content", out var content))
- response.Content = content.GetString();
- }
- if (choice.TryGetProperty("finish_reason", out var finishReason))
- {
- // 检查是否被内容安全过滤
- if (finishReason.GetString() == "content_filter")
- {
- response.IsSuccess = false;
- response.ErrorMessage = "内容被安全策略过滤";
- }
- }
- }
- if (root.TryGetProperty("usage", out var usage))
- {
- response.Usage = new TokenUsage
- {
- PromptTokens = usage.TryGetProperty("prompt_tokens", out var pt) ? pt.GetInt32() : 0,
- CompletionTokens = usage.TryGetProperty("completion_tokens", out var ct) ? ct.GetInt32() : 0,
- TotalTokens = usage.TryGetProperty("total_tokens", out var tt) ? tt.GetInt32() : 0
- };
- }
- return response;
- }
- /// <summary>
- /// 解析流式数据块
- /// </summary>
- private StreamChunk? ParseStreamChunk(string data)
- {
- using var document = JsonDocument.Parse(data);
- var root = document.RootElement;
- var chunk = new StreamChunk();
- if (root.TryGetProperty("model", out var model))
- chunk.Model = model.GetString();
- if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
- {
- var choice = choices[0];
- if (choice.TryGetProperty("delta", out var delta))
- {
- if (delta.TryGetProperty("content", out var content))
- {
- chunk.Content = content.GetString() ?? string.Empty;
- }
- }
- // 检查是否结束
- if (choice.TryGetProperty("finish_reason", out var finishReason))
- {
- var reason = finishReason.GetString();
- chunk.IsDone = reason == "stop" || reason == "content_filter";
- if (reason == "content_filter")
- {
- chunk.Content = "[内容被安全策略过滤]";
- }
- }
- }
- return chunk;
- }
- /// <summary>
- /// 从错误响应创建异常
- /// </summary>
- private TokenHubException CreateExceptionFromResponse(HttpStatusCode statusCode, string responseJson)
- {
- var statusCodeInt = (int)statusCode;
- var errorDetail = ParseErrorResponse(responseJson, statusCodeInt);
- var message = errorDetail?.Message ?? $"HTTP {(int)statusCode}: 请求失败";
- _logger.LogWarning("TokenHub API 返回错误: StatusCode={StatusCode}, Code={Code}, Type={Type}, RequestId={RequestId}",
- statusCodeInt,
- errorDetail?.Code,
- errorDetail?.Type,
- errorDetail?.RequestId);
- return new TokenHubException(message, statusCodeInt, errorDetail);
- }
- /// <summary>
- /// 解析错误响应
- /// </summary>
- private TokenHubError? ParseErrorResponse(string responseJson, int statusCode)
- {
- try
- {
- using var document = JsonDocument.Parse(responseJson);
- var root = document.RootElement;
- // 判断是 OpenAI 协议还是 Anthropic 协议
- if (root.TryGetProperty("error", out var errorElement))
- {
- // OpenAI 协议
- if (errorElement.ValueKind == JsonValueKind.Object)
- {
- return ParseOpenAiError(errorElement);
- }
- }
- // Anthropic 协议
- if (root.TryGetProperty("type", out var typeProp) && typeProp.GetString() == "error")
- {
- if (root.TryGetProperty("error", out var anthropicError))
- {
- return ParseAnthropicError(anthropicError);
- }
- }
- // 简化格式(链路短路等)
- if (root.TryGetProperty("error", out var simpleError) &&
- simpleError.ValueKind == JsonValueKind.Object)
- {
- return new TokenHubError
- {
- Type = simpleError.TryGetProperty("type", out var t) ? t.GetString() : null,
- Code = simpleError.TryGetProperty("code", out var c) ? c.GetString() : null,
- Message = simpleError.TryGetProperty("message", out var m) ? m.GetString() : null,
- RequestId = simpleError.TryGetProperty("request_id", out var r) ? r.GetString() : null
- };
- }
- return null;
- }
- catch (System.Text.Json.JsonException ex)
- {
- _logger.LogWarning(ex, "解析错误响应失败: {ResponseJson}", responseJson);
- return null;
- }
- }
- /// <summary>
- /// 解析 OpenAI 协议错误
- /// </summary>
- private TokenHubError ParseOpenAiError(JsonElement errorElement)
- {
- var error = new TokenHubError();
- if (errorElement.TryGetProperty("type", out var type))
- error.Type = type.GetString();
- if (errorElement.TryGetProperty("code", out var code))
- error.Code = code.GetString();
- if (errorElement.TryGetProperty("message", out var message))
- error.Message = message.GetString();
- if (errorElement.TryGetProperty("source", out var source))
- error.Source = source.GetString();
- if (errorElement.TryGetProperty("upstream_code", out var upstreamCode))
- error.UpstreamCode = upstreamCode.GetString();
- if (errorElement.TryGetProperty("upstream_status", out var upstreamStatus))
- error.UpstreamStatus = upstreamStatus.GetInt32();
- if (errorElement.TryGetProperty("request_id", out var requestId))
- error.RequestId = requestId.GetString();
- return error;
- }
- /// <summary>
- /// 解析 Anthropic 协议错误
- /// </summary>
- private TokenHubError ParseAnthropicError(JsonElement errorElement)
- {
- var error = new TokenHubError
- {
- Source = "upstream" // Anthropic 协议默认视为上游
- };
- if (errorElement.TryGetProperty("type", out var type))
- error.Type = type.GetString();
- if (errorElement.TryGetProperty("message", out var message))
- error.Message = message.GetString();
- if (errorElement.TryGetProperty("reqid", out var reqid))
- error.RequestId = reqid.GetString();
- // Anthropic 协议没有业务码,根据类型映射
- error.Code = MapAnthropicTypeToCode(error.Type);
- return error;
- }
- /// <summary>
- /// 将 Anthropic 错误类型映射到业务码
- /// </summary>
- private string? MapAnthropicTypeToCode(string? type)
- {
- return type switch
- {
- "invalid_request_error" => "400002",
- "authentication_error" => "401002",
- "permission_error" => "403001",
- "not_found_error" => "400004",
- "rate_limit_error" => "429001",
- "overloaded_error" => "502001",
- "api_error" => "500001",
- _ => null
- };
- }
- /// <summary>
- /// 从响应中提取 Request ID
- /// </summary>
- private string? ExtractRequestId(string responseJson)
- {
- try
- {
- using var document = JsonDocument.Parse(responseJson);
- var root = document.RootElement;
- if (root.TryGetProperty("error", out var error))
- {
- if (error.TryGetProperty("request_id", out var requestId))
- return requestId.GetString();
- }
- if (root.TryGetProperty("id", out var id))
- return id.GetString();
- }
- catch
- {
- // 忽略解析错误
- }
- return null;
- }
- #endregion
- /// <summary>
- /// 释放资源
- /// </summary>
- public void Dispose()
- {
- _httpClient?.Dispose();
- GC.SuppressFinalize(this);
- }
- }
|