using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Text.Json; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Api.Models; namespace ClawdDotNet.Services; public sealed class OpenRouterStatusService : IDisposable { private readonly HttpClient _http; private readonly System.Windows.Forms.Timer _timer; private readonly ConcurrentBag _usageRecords = new(); /// /// Preise kommen vom /models-Endpunkt statt aus einer fest verdrahteten Tabelle. /// Die alte Tabelle war veraltet und enthielt ausgerechnet das Standardmodell der /// Agenten nicht — die Anzeige meldete dafür stillschweigend 0 €. /// private readonly ModelPricingCatalog _pricing = new(); /// Modelle, für die keine Preise vorliegen — werden in der Anzeige benannt. private readonly ConcurrentDictionary _modelsWithoutPricing = new(); private const double UsdToEur = 0.92; public bool IsApiReachable { get; private set; } public string StatusText { get; private set; } = "Prüfe..."; public string CreditsText { get; private set; } = "—"; public string CreditsTooltip { get; private set; } = ""; public double? CreditBalance { get; private set; } public double? CreditRemaining { get; private set; } public event Action? OnStatusUpdated; public OpenRouterStatusService(string apiKey, string baseUrl = "https://openrouter.ai/api/v1/", int checkIntervalSeconds = 60) { _http = new HttpClient { BaseAddress = new Uri(baseUrl) }; _http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); _http.DefaultRequestHeaders.Add("HTTP-Referer", "ClawdDotNet"); _timer = new System.Windows.Forms.Timer { Interval = checkIntervalSeconds * 1000 }; _timer.Tick += async (_, _) => await CheckStatusAsync(); } public void Start() { _timer.Start(); _ = CheckStatusAsync(); } public void Stop() => _timer.Stop(); public void RecordUsage(string model, int promptTokens, int completionTokens) { var estimate = _pricing.Estimate(model, promptTokens, completionTokens); if (!estimate.IsKnown) _modelsWithoutPricing.TryAdd(model, 0); _usageRecords.Add(new UsageRecord( DateTime.Now, model, promptTokens, completionTokens, (double)estimate.Usd, estimate.IsKnown)); UpdateCreditsText(); OnStatusUpdated?.Invoke(); } /// /// Lädt die aktuellen Modellpreise. Ohne diesen Aufruf bleibt der Katalog leer und /// alle Kosten werden als unbekannt ausgewiesen. /// public async Task LoadPricingAsync(OpenRouterClient client, CancellationToken ct = default) { try { var models = await client.GetAvailableModelsAsync(ct); _pricing.Load(models); // Modelle, die bisher als unbekannt galten, sind jetzt vielleicht bekannt. foreach (var model in _modelsWithoutPricing.Keys) { if (_pricing.IsKnown(model)) _modelsWithoutPricing.TryRemove(model, out _); } UpdateCreditsText(); OnStatusUpdated?.Invoke(); } catch (Exception) { // Ohne Preise bleibt die Anzeige ehrlich unbekannt statt falsch null. } } private async Task CheckStatusAsync() { try { using var response = await _http.GetAsync("auth/key"); if (response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(); var doc = JsonDocument.Parse(body); IsApiReachable = true; if (doc.RootElement.TryGetProperty("data", out var data)) { if (data.TryGetProperty("limit", out var limit)) CreditBalance = limit.GetDouble(); if (data.TryGetProperty("usage", out var usage)) { var used = usage.GetDouble(); var remaining = (CreditBalance ?? 0) - used; CreditRemaining = remaining; StatusText = $"✓ API OK | Credits: ${remaining:F4} von ${CreditBalance:F2}"; } else { StatusText = "✓ API erreichbar"; } } else { StatusText = "✓ API erreichbar"; } } else { IsApiReachable = false; StatusText = $"✗ API Fehler: {(int)response.StatusCode}"; } } catch (HttpRequestException ex) { IsApiReachable = false; StatusText = $"✗ Nicht erreichbar: {ex.Message}"; } catch (TaskCanceledException) { IsApiReachable = false; StatusText = "✗ Timeout"; } catch (Exception ex) { IsApiReachable = false; StatusText = $"✗ Fehler: {ex.Message}"; } UpdateCreditsText(); OnStatusUpdated?.Invoke(); } private void UpdateCreditsText() { var now = DateTime.Now; var oneHourAgo = now.AddHours(-1); var oneDayAgo = now.AddHours(-24); var records = _usageRecords.ToArray(); var lastHour = records.Where(r => r.Timestamp >= oneHourAgo).ToArray(); var last24h = records.Where(r => r.Timestamp >= oneDayAgo).ToArray(); var tokensLastHour = lastHour.Sum(r => r.PromptTokens + r.CompletionTokens); var tokensLast24h = last24h.Sum(r => r.PromptTokens + r.CompletionTokens); var costLastHour = lastHour.Sum(r => r.CostUsd); var costLast24h = last24h.Sum(r => r.CostUsd); // Ein Hinweis, sobald Läufe dabei sind, deren Kosten nicht bezifferbar sind — // sonst liest sich eine zu niedrige Summe wie eine vollständige. var unpriced = last24h.Count(r => !r.CostIsKnown); var warning = unpriced > 0 ? " ⚠" : ""; CreditsText = $"1h: {tokensLastHour:N0} Tok (~{costLastHour * UsdToEur:F4}€) | " + $"24h: {tokensLast24h:N0} Tok (~{costLast24h * UsdToEur:F4}€){warning}"; // Detaillierter Tooltip: Pro-Model-Aufschlüsselung (letzte 24h) var modelGroups = last24h .GroupBy(r => r.Model) .OrderByDescending(g => g.Sum(r => r.CostUsd)) .ToList(); if (modelGroups.Count == 0) { CreditsTooltip = "Keine Token-Nutzung in den letzten 24h"; return; } var sb = new System.Text.StringBuilder(); sb.AppendLine("═══ Token-Verbrauch (24h) ═══"); sb.AppendLine(); foreach (var group in modelGroups) { var modelName = group.Key; var shortName = modelName.Contains('/') ? modelName[(modelName.IndexOf('/') + 1)..] : modelName; var prompt = group.Sum(r => r.PromptTokens); var completion = group.Sum(r => r.CompletionTokens); var total = prompt + completion; var cost = group.Sum(r => r.CostUsd); var runs = group.Count(); sb.AppendLine($"▸ {shortName}"); sb.AppendLine($" {runs}x Runs | {total:N0} Tokens ({prompt:N0} in / {completion:N0} out)"); if (_pricing.Get(modelName) is { } pricing) { sb.AppendLine($" Preis: ${pricing.InputPer1M:0.####}/1M in, ${pricing.OutputPer1M:0.####}/1M out"); sb.AppendLine($" Kosten: ${cost:F4} (~{cost * UsdToEur:F4}€)"); } else { sb.AppendLine(" Kosten: unbekannt — für dieses Modell liegen keine Preise vor"); } sb.AppendLine(); } var totalCost = last24h.Sum(r => r.CostUsd); sb.AppendLine($"═══ Gesamt: ${totalCost:F4} (~{totalCost * UsdToEur:F4}€) ═══"); if (unpriced > 0) { sb.AppendLine(); sb.AppendLine($"⚠ {unpriced} Lauf/Läufe ohne Preisangabe — die Summe ist unvollständig."); sb.AppendLine($" Betroffene Modelle: {string.Join(", ", _modelsWithoutPricing.Keys.Order())}"); } if (_pricing.LastUpdated is { } updated) sb.AppendLine($"\nPreise abgerufen: {updated:g} ({_pricing.Count} Modelle)"); else sb.AppendLine("\n⚠ Preise noch nicht geladen."); CreditsTooltip = sb.ToString().TrimEnd(); } public void Dispose() { _timer.Stop(); _timer.Dispose(); _http.Dispose(); } private sealed record UsageRecord( DateTime Timestamp, string Model, int PromptTokens, int CompletionTokens, double CostUsd, bool CostIsKnown); }