using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using TaskTrackingSystem.Shared.Localization; namespace TaskTrackingSystem.WebApi.Features.Translation; /// /// Server-only OpenRouter Chat Completions provider. Credentials never leave the API process. /// public sealed class OpenRouterContentTranslationService : IContentTranslationService { private const string Provider = "OpenRouter"; private const string DefaultModel = "openrouter/free"; private readonly IHttpClientFactory httpClientFactory; private readonly IConfiguration configuration; private readonly ILogger logger; public OpenRouterContentTranslationService( IHttpClientFactory httpClientFactory, IConfiguration configuration, ILogger logger) { this.httpClientFactory = httpClientFactory; this.configuration = configuration; this.logger = logger; } public Task TranslateAsync( string sourceText, string sourceLanguage, string targetLanguage, CancellationToken cancellationToken = default) => GenerateAsync(sourceText, sourceLanguage, targetLanguage, isName: false, cancellationToken); public Task TransliterateNameAsync( string sourceName, string sourceLanguage, string targetLanguage, CancellationToken cancellationToken = default) => GenerateAsync(sourceName, sourceLanguage, targetLanguage, isName: true, cancellationToken); private async Task GenerateAsync( string sourceText, string sourceLanguage, string targetLanguage, bool isName, CancellationToken cancellationToken) { var endpoint = configuration["Translation:Endpoint"]; var apiKey = configuration["Translation:ApiKey"]; var model = configuration["Translation:Model"] ?? DefaultModel; if (string.IsNullOrWhiteSpace(endpoint) || string.IsNullOrWhiteSpace(apiKey)) { return NotConfigured(); } var systemPrompt = isName ? "You are a professional name transliterator. Return ONLY the transliterated name." : "You are a professional translator. Return ONLY the translated text."; var userPrompt = isName ? $"Transliterate the personal name by pronunciation from {sourceLanguage} to {targetLanguage}: {sourceText}" : $"Translate from {sourceLanguage} to {targetLanguage}: {sourceText}"; var requestBody = new OpenRouterChatCompletionRequest { Model = model, Messages = [ new OpenRouterMessage { Role = "system", Content = systemPrompt }, new OpenRouterMessage { Role = "user", Content = userPrompt } ], Temperature = 0.2 }; try { using var request = new HttpRequestMessage(HttpMethod.Post, endpoint); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); request.Headers.TryAddWithoutValidation("HTTP-Referer", "https://huggingface.co"); request.Headers.TryAddWithoutValidation("X-Title", "TaskTrackingSystem"); request.Content = JsonContent.Create(requestBody, options: OpenRouterJson.Options); var client = httpClientFactory.CreateClient(); using var response = await client.SendAsync(request, cancellationToken); var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) { return FailureForHttpStatus(response.StatusCode, responseBody); } OpenRouterChatCompletionResponse? parsed; try { parsed = JsonSerializer.Deserialize(responseBody, OpenRouterJson.Options); } catch (JsonException) { return Failure("OpenRouter returned invalid JSON."); } var translatedText = parsed?.Choices?.FirstOrDefault()?.Message?.Content; if (string.IsNullOrWhiteSpace(translatedText)) { return Failure("OpenRouter returned empty translated text."); } var cleanedText = CleanTranslatedText(translatedText); if (string.IsNullOrWhiteSpace(cleanedText)) { return Failure("OpenRouter returned empty translated text."); } if (string.Equals(cleanedText, userPrompt, StringComparison.Ordinal) || string.Equals(cleanedText, systemPrompt, StringComparison.Ordinal)) { return Failure("OpenRouter returned the prompt instead of a translation."); } return TranslationResult.Generated(cleanedText, Provider); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (TaskCanceledException) { return Failure("OpenRouter translation request timed out."); } catch (HttpRequestException) { return Failure("OpenRouter translation request failed."); } catch (JsonException) { return Failure("OpenRouter returned invalid JSON."); } catch (Exception ex) { logger.LogWarning("OpenRouter translation request failed with {ExceptionType}.", ex.GetType().Name); return Failure("OpenRouter translation request failed."); } } private TranslationResult FailureForHttpStatus(HttpStatusCode statusCode, string responseBody) { var providerMessage = TryReadErrorMessage(responseBody); var message = statusCode switch { HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => $"OpenRouter rejected the API key (HTTP {(int)statusCode}).", (HttpStatusCode)429 => $"OpenRouter rate limit exceeded (HTTP {(int)statusCode}). Please try again later.", _ => $"OpenRouter returned HTTP {(int)statusCode}." }; if (!string.IsNullOrWhiteSpace(providerMessage)) { message = $"{message} {providerMessage}"; } logger.LogWarning("OpenRouter returned HTTP {StatusCode}.", (int)statusCode); return Failure(message); } private static string? TryReadErrorMessage(string responseBody) { try { var error = JsonSerializer.Deserialize(responseBody, OpenRouterJson.Options)?.Error; return string.IsNullOrWhiteSpace(error?.Message) ? null : error.Message.Trim(); } catch (JsonException) { return null; } } private static string CleanTranslatedText(string text) { var cleaned = text.Trim(); if (cleaned.StartsWith("```") && cleaned.EndsWith("```")) { cleaned = cleaned[3..^3].Trim(); } while (cleaned.Length >= 2 && ((cleaned[0] == '"' && cleaned[^1] == '"') || (cleaned[0] == '“' && cleaned[^1] == '”') || (cleaned[0] == '\'' && cleaned[^1] == '\''))) { cleaned = cleaned[1..^1].Trim(); } return cleaned; } private static TranslationResult NotConfigured() => new(false, null, "Translation provider is not configured.", Provider, false); private static TranslationResult Failure(string message) => new(false, null, message, Provider, false); } internal static class OpenRouterJson { internal static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true }; } internal sealed class OpenRouterChatCompletionRequest { [JsonPropertyName("model")] public string Model { get; set; } = string.Empty; [JsonPropertyName("messages")] public List Messages { get; set; } = []; [JsonPropertyName("temperature")] public double Temperature { get; set; } } internal sealed class OpenRouterMessage { [JsonPropertyName("role")] public string Role { get; set; } = string.Empty; [JsonPropertyName("content")] public string Content { get; set; } = string.Empty; } internal sealed class OpenRouterChatCompletionResponse { [JsonPropertyName("choices")] public List? Choices { get; set; } } internal sealed class OpenRouterChoice { [JsonPropertyName("message")] public OpenRouterMessage? Message { get; set; } } internal sealed class OpenRouterErrorEnvelope { [JsonPropertyName("error")] public OpenRouterError? Error { get; set; } } internal sealed class OpenRouterError { [JsonPropertyName("message")] public string? Message { get; set; } }