Files
ClawdDotNet/src/ClawdDotNet.Core/Api/OpenRouterClient.cs
RichardandClaude Opus 4.8 93f47aca3a Modellpreise live vom Anbieter beziehen
Zweiter Teil von B4. Die Preise standen fest im Code — eine Tabelle mit einer
Handvoll Modelle, die bereits veraltet war. Ausgerechnet das Standardmodell der
Agenten fehlte darin, und CalculateCost gab fuer unbekannte Modelle
stillschweigend 0 zurueck. Die Kostenanzeige war damit nicht bloss ungenau,
sondern blind: Sie meldete 0 Euro, waehrend echte Kosten anfielen.

ModelInfo traegt jetzt die Preisangaben aus dem /models-Endpunkt. OpenRouter
liefert sie als Text und pro einzelnem Token; die Umrechnung auf eine Million
Token laeuft ueber InvariantCulture, sonst wuerde eine deutsche Systemsprache
0.000003 als drei lesen.

Halbe Angaben werden verworfen: Ein Modell, bei dem nur der Eingabepreis
vorliegt, ergaebe eine plausibel aussehende, aber falsche Summe.

ModelPricingCatalog haelt die Preise und liefert eine Kostenschaetzung, die
ausweist, ob sie belastbar ist. Der Statusdienst laedt den Katalog beim Start und
markiert Laeufe ohne Preisangabe sichtbar — in der Zeile mit einem Warnzeichen,
im Tooltip mit den betroffenen Modellnamen und dem Hinweis, dass die Summe
unvollstaendig ist.

284 Tests gruen (136 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 10:09:39 +02:00

210 lines
7.9 KiB
C#

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;
/// <summary>Wartefunktion — in Tests ersetzbar, damit nicht wirklich gewartet wird.</summary>
private readonly Func<TimeSpan, CancellationToken, Task> _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<TimeSpan, CancellationToken, Task>? 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<ChatResponse> 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<ChatResponse>(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;
}
/// <summary>
/// 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.
/// </summary>
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, "");
}
/// <summary>
/// Ruft die verfügbaren Modelle von OpenRouter ab (/models Endpoint).
/// Gibt eine Liste von Modell-IDs zurück, sortiert nach Name.
/// </summary>
public async Task<List<ModelInfo>> 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<ModelInfo>();
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;
}
/// <summary>
/// Liest die Preisangaben eines Modell-Eintrags. OpenRouter liefert sie als Text
/// und als Preis pro einzelnem Token.
/// </summary>
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;
}