Import des bestehenden Projektstands in Git. - .NET 10 WinForms Anwendung (Multi-Agent / Tool-System) - .gitignore fuer Build-Artefakte, Secrets und Runtime-Daten ergaenzt Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
213 lines
7.5 KiB
C#
213 lines
7.5 KiB
C#
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<UsageRecord> _usageRecords = new();
|
|
|
|
// Preise pro 1M Token (Input / Output) in USD — gängige OpenRouter-Modelle
|
|
private static readonly Dictionary<string, (double InputPer1M, double OutputPer1M)> 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);
|
|
}
|