using System.Net.Http.Headers; using System.Text; using System.Text.Json; using ClawdDotNet.Core.Api.Models; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Api; public sealed class OpenRouterClient : IChatCompletionClient, IDisposable { private const string BaseUrl = "https://openrouter.ai/api/v1/"; private readonly HttpClient _http; private readonly ILogger _logger; private readonly RetryPolicy _retry; /// Wartefunktion — in Tests ersetzbar, damit nicht wirklich gewartet wird. private readonly Func _delay; private static readonly JsonSerializerOptions JsonOptions = new() { DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; public OpenRouterClient( string apiKey, ILogger logger, HttpClient? httpClient = null, RetryPolicy? retryPolicy = null, Func? delay = null) { _logger = logger; _retry = retryPolicy ?? RetryPolicy.Default; _delay = delay ?? ((d, ct) => Task.Delay(d, ct)); _http = httpClient ?? new HttpClient(); _http.BaseAddress = new Uri(BaseUrl); _http.Timeout = TimeSpan.FromMinutes(5); // LLM-Calls können bei großen Prompts lange dauern _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); _http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet"); _http.DefaultRequestHeaders.Add("X-Title", "ClawdDotNet"); } public async Task CompleteAsync(ChatRequest request, CancellationToken ct) { request.Stream = false; var json = JsonSerializer.Serialize(request, JsonOptions); _logger.LogDebug("Sending request to OpenRouter: model={Model}, messages={Count}", request.Model, request.Messages.Count); var (statusCode, responseBody) = await SendWithRetryAsync(json, ct); if (statusCode is < 200 or > 299) { _logger.LogError("OpenRouter API error {StatusCode}: {Body}", statusCode, responseBody); throw new OpenRouterException( $"API request failed with status {statusCode}", statusCode, responseBody); } var result = JsonSerializer.Deserialize(responseBody, JsonOptions) ?? throw new OpenRouterException("Empty response from OpenRouter", 0, responseBody); if (result.Error is not null) { _logger.LogError("OpenRouter returned error: {Error}", result.Error.Message); throw new OpenRouterException(result.Error.Message, result.Error.Code ?? 0, responseBody); } _logger.LogDebug("OpenRouter response received: tokens={Tokens}", result.Usage?.TotalTokens ?? 0); return result; } /// /// Sendet die Anfrage und wiederholt sie bei vorübergehenden Störungen. /// /// Ohne das beendet ein einzelnes HTTP 429 einen kompletten geplanten Lauf — /// Rate-Limits und kurzzeitige 5xx sind bei OpenRouter Normalbetrieb. /// private async Task<(int StatusCode, string Body)> SendWithRetryAsync(string json, CancellationToken ct) { Exception? lastNetworkError = null; for (var attempt = 1; attempt <= _retry.MaxAttempts; attempt++) { ct.ThrowIfCancellationRequested(); // Content muss je Versuch neu erzeugt werden — ein bereits gesendeter // HttpContent lässt sich nicht erneut verwenden. using var content = new StringContent(json, Encoding.UTF8, "application/json"); try { using var response = await _http.PostAsync("chat/completions", content, ct); var body = await response.Content.ReadAsStringAsync(ct); var status = (int)response.StatusCode; if (response.IsSuccessStatusCode || !_retry.ShouldRetry(response.StatusCode)) return (status, body); if (attempt == _retry.MaxAttempts) { _logger.LogWarning( "OpenRouter antwortete {Status} — letzter von {Max} Versuchen aufgebraucht.", status, _retry.MaxAttempts); return (status, body); } var wait = _retry.GetDelay(attempt, response.Headers.RetryAfter?.Delta); _logger.LogWarning( "OpenRouter antwortete {Status}. Versuch {Attempt}/{Max}, erneut in {Delay:N1}s.", status, attempt, _retry.MaxAttempts, wait.TotalSeconds); await _delay(wait, ct); } catch (HttpRequestException ex) { lastNetworkError = ex; if (attempt == _retry.MaxAttempts) break; var wait = _retry.GetDelay(attempt); _logger.LogWarning(ex, "Netzwerkfehler beim Aufruf von OpenRouter. Versuch {Attempt}/{Max}, erneut in {Delay:N1}s.", attempt, _retry.MaxAttempts, wait.TotalSeconds); await _delay(wait, ct); } } throw new OpenRouterException( $"OpenRouter nach {_retry.MaxAttempts} Versuchen nicht erreichbar: {lastNetworkError?.Message}", 0, ""); } /// /// Ruft die verfügbaren Modelle von OpenRouter ab (/models Endpoint). /// Gibt eine Liste von Modell-IDs zurück, sortiert nach Name. /// public async Task> GetAvailableModelsAsync(CancellationToken ct = default) { _logger.LogDebug("Fetching available models from OpenRouter..."); using var response = await _http.GetAsync("models", ct); var body = await response.Content.ReadAsStringAsync(ct); if (!response.IsSuccessStatusCode) { _logger.LogError("Failed to fetch models: {StatusCode} {Body}", (int)response.StatusCode, body); return []; } var doc = JsonDocument.Parse(body); if (!doc.RootElement.TryGetProperty("data", out var dataArray)) return []; var models = new List(); foreach (var item in dataArray.EnumerateArray()) { var id = item.GetProperty("id").GetString() ?? ""; var name = item.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? id : id; models.Add(new ModelInfo { Id = id, Name = name, Pricing = ReadPricing(item) }); } models.Sort((a, b) => string.Compare(a.Id, b.Id, StringComparison.OrdinalIgnoreCase)); _logger.LogDebug("Fetched {Count} models from OpenRouter", models.Count); return models; } /// /// Liest die Preisangaben eines Modell-Eintrags. OpenRouter liefert sie als Text /// und als Preis pro einzelnem Token. /// internal static ModelPricing? ReadPricing(JsonElement item) { if (!item.TryGetProperty("pricing", out var pricing) || pricing.ValueKind != JsonValueKind.Object) return null; var input = ModelPricing.ParsePerToken( pricing.TryGetProperty("prompt", out var p) ? p.GetString() : null); var output = ModelPricing.ParsePerToken( pricing.TryGetProperty("completion", out var c) ? c.GetString() : null); // Nur wenn beide Werte vorliegen, ist eine Kostenrechnung belastbar. return input is { } i && output is { } o ? new ModelPricing(i, o) : null; } public void Dispose() { _http.Dispose(); } } public sealed class OpenRouterException(string message, int statusCode, string responseBody) : Exception(message) { public int StatusCode { get; } = statusCode; public string ResponseBody { get; } = responseBody; }