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;
}