using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Text.Json; 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 pro 1M Token (Input / Output) in USD — gängige OpenRouter-Modelle private static readonly Dictionary ModelPricing = new(StringComparer.OrdinalIgnoreCase) { ["anthropic/claude-sonnet-4"] = (3.00, 15.00), ["anthropic/claude-haiku-4.5"] = (0.80, 4.00), ["anthropic/claude-opus-4"] = (15.00, 75.00), ["openai/gpt-4o"] = (2.50, 10.00), ["openai/gpt-4o-mini"] = (0.15, 0.60), ["openai/gpt-4.1"] = (2.00, 8.00), ["openai/gpt-4.1-mini"] = (0.40, 1.60), ["openai/gpt-4.1-nano"] = (0.10, 0.40), ["google/gemini-2.5-flash"] = (0.15, 0.60), ["google/gemini-2.5-pro"] = (1.25, 10.00), ["google/gemini-3.1-flash-lite"] = (0.00, 0.00), ["deepseek/deepseek-chat-v3-0324"] = (0.14, 0.28), }; 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 cost = CalculateCost(model, promptTokens, completionTokens); _usageRecords.Add(new UsageRecord(DateTime.Now, model, promptTokens, completionTokens, cost)); UpdateCreditsText(); OnStatusUpdated?.Invoke(); } private static double CalculateCost(string model, int promptTokens, int completionTokens) { if (!ModelPricing.TryGetValue(model, out var pricing)) return 0; return (promptTokens / 1_000_000.0 * pricing.InputPer1M) + (completionTokens / 1_000_000.0 * pricing.OutputPer1M); } 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); CreditsText = $"1h: {tokensLastHour:N0} Tok (~{costLastHour * UsdToEur:F4}€) | " + $"24h: {tokensLast24h:N0} Tok (~{costLast24h * UsdToEur:F4}€)"; // 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 (ModelPricing.TryGetValue(modelName, out var pricing)) sb.AppendLine($" Preis: ${pricing.InputPer1M}/1M in, ${pricing.OutputPer1M}/1M out"); sb.AppendLine($" Kosten: ${cost:F4} (~{cost * UsdToEur:F4}€)"); sb.AppendLine(); } var totalCost = last24h.Sum(r => r.CostUsd); sb.AppendLine($"═══ Gesamt: ${totalCost:F4} (~{totalCost * UsdToEur:F4}€) ═══"); 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); }