Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag. Oberflaeche - Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml (eigene ControlThemes statt Fluent umzufaerben). - Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite statt eigenem Fenster, Texteditor-Fenster. - ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt. Rocket.Chat - Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird nur, wenn wirklich etwas anliegt. - send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist. - Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei Annahmen waren falsch und sind korrigiert. Deploymentcenter - DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json) erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke. - AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
290 lines
10 KiB
C#
290 lines
10 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net.Http.Headers;
|
|
using System.Text.Json;
|
|
using ClawdDotNet.Core.Api;
|
|
using ClawdDotNet.Core.Api.Models;
|
|
|
|
namespace ClawdDotNet.App.Services;
|
|
|
|
/// <summary>
|
|
/// Fragt regelmäßig Erreichbarkeit und Guthaben der OpenRouter-API ab.
|
|
///
|
|
/// Der Takt lief früher über einen <c>System.Windows.Forms.Timer</c> — der braucht eine
|
|
/// Nachrichtenschleife und damit ein Fenster. Jetzt <see cref="PeriodicTimer"/>: läuft
|
|
/// auch ohne Oberfläche, was der kopflose Betrieb voraussetzt.
|
|
///
|
|
/// <see cref="OnStatusUpdated"/> wird auf einem Hintergrundfaden ausgelöst. Wer daran
|
|
/// eine Oberfläche hängt, muss selbst auf den Oberflächenfaden wechseln — in Avalonia
|
|
/// über <c>Dispatcher.UIThread</c>.
|
|
/// </summary>
|
|
public sealed class OpenRouterStatusService : IAsyncDisposable
|
|
{
|
|
private readonly HttpClient _http;
|
|
private readonly TimeSpan _interval;
|
|
private readonly CancellationTokenSource _cts = new();
|
|
private Task? _loop;
|
|
|
|
private readonly ConcurrentBag<UsageRecord> _usageRecords = new();
|
|
|
|
/// <summary>
|
|
/// 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 €.
|
|
/// </summary>
|
|
private readonly ModelPricingCatalog _pricing = new();
|
|
|
|
/// <summary>Modelle, für die keine Preise vorliegen — werden in der Anzeige benannt.</summary>
|
|
private readonly ConcurrentDictionary<string, byte> _modelsWithoutPricing = new();
|
|
|
|
/// <summary>
|
|
/// Grober Umrechnungskurs fuer die Anzeige. Oeffentlich, damit die Tokennutzungs-
|
|
/// Seite denselben Wert verwendet — zwei Kopien eines veraltenden Kurses waeren
|
|
/// zwei Gelegenheiten, verschiedene Betraege fuer dieselben Laeufe zu zeigen.
|
|
/// </summary>
|
|
public 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");
|
|
|
|
_interval = TimeSpan.FromSeconds(checkIntervalSeconds);
|
|
}
|
|
|
|
public void Start()
|
|
{
|
|
_loop ??= RunLoopAsync(_cts.Token);
|
|
_ = CheckStatusAsync();
|
|
}
|
|
|
|
private async Task RunLoopAsync(CancellationToken ct)
|
|
{
|
|
using var timer = new PeriodicTimer(_interval);
|
|
|
|
while (await timer.WaitForNextTickAsync(ct))
|
|
{
|
|
// CheckStatusAsync fängt bereits alles ab und setzt IsApiReachable — hier
|
|
// muss nichts mehr behandelt werden.
|
|
await CheckStatusAsync();
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lädt die aktuellen Modellpreise. Ohne diesen Aufruf bleibt der Katalog leer und
|
|
/// alle Kosten werden als unbekannt ausgewiesen.
|
|
/// </summary>
|
|
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 async ValueTask DisposeAsync()
|
|
{
|
|
await _cts.CancelAsync();
|
|
|
|
if (_loop is not null)
|
|
{
|
|
try { await _loop; }
|
|
catch (OperationCanceledException) { /* erwartet */ }
|
|
}
|
|
|
|
_cts.Dispose();
|
|
_http.Dispose();
|
|
}
|
|
|
|
private sealed record UsageRecord(
|
|
DateTime Timestamp,
|
|
string Model,
|
|
int PromptTokens,
|
|
int CompletionTokens,
|
|
double CostUsd,
|
|
bool CostIsKnown);
|
|
}
|