< Summary

Information
Class: Orchestrator.Commands.Observability.Cost.CostCommand
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/Cost/CostCommand.cs
Line coverage
96%
Covered lines: 462
Uncovered lines: 19
Coverable lines: 481
Total lines: 774
Line coverage: 96%
Branch coverage
94%
Covered branches: 304
Total branches: 322
Branch coverage: 94.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)100%11100%
ExecuteAsync()97.17%10610696.55%
<ExecuteAsync()100%3434100%
ParseMatchdays(...)100%88100%
FormatMatchdayFilter(...)75%44100%
GetMatchCostsByRepredictionIndexAsync()91.67%121295.24%
CreateReport(...)95%2020100%
WriteJsonReportAsync()100%22100%
ParseModels(...)100%1010100%
ParseReasoningEfforts(...)75%151271.43%
ResolveModelConfigsAsync()100%88100%
ParseCommunityContexts(...)100%1010100%
LoadConfigurationFromFile()66.67%6695.45%
MergeConfigurations(...)86.54%575287.5%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%
.ctor(...)100%11100%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Observability/Cost/CostCommand.cs

#LineLine coverage
 1using Microsoft.Extensions.Logging;
 2using Spectre.Console.Cli;
 3using Spectre.Console;
 4using System.Globalization;
 5using System.Text.Json;
 6using System.Text.Json.Serialization;
 7using EHonda.KicktippAi.Core;
 8using Orchestrator.Infrastructure.Factories;
 9
 10namespace Orchestrator.Commands.Observability.Cost;
 11
 12public class CostCommand : AsyncCommand<CostSettings>
 13{
 14    private const int FirestoreInFilterLimit = 30;
 15
 116    private static readonly JsonSerializerOptions OutputJsonOptions = new(JsonSerializerDefaults.Web)
 117    {
 118        WriteIndented = true,
 119        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 120    };
 21
 22    private readonly IAnsiConsole _console;
 23    private readonly IFirebaseServiceFactory _firebaseServiceFactory;
 24    private readonly ILogger<CostCommand> _logger;
 25
 126    public CostCommand(
 127        IAnsiConsole console,
 128        IFirebaseServiceFactory firebaseServiceFactory,
 129        ILogger<CostCommand> logger)
 30    {
 131        _console = console;
 132        _firebaseServiceFactory = firebaseServiceFactory;
 133        _logger = logger;
 134    }
 35
 36    protected override async Task<int> ExecuteAsync(CommandContext context, CostSettings settings, CancellationToken can
 37    {
 38
 39        try
 40        {
 41            // Load configuration from file if specified
 142            if (!string.IsNullOrWhiteSpace(settings.ConfigFile))
 43            {
 144                var fileConfig = await LoadConfigurationFromFile(settings.ConfigFile);
 145                settings = MergeConfigurations(fileConfig, settings);
 46            }
 47
 48            // Create Firebase services using factory (factory handles env var loading)
 149            var predictionRepository = _firebaseServiceFactory.CreatePredictionRepository();
 50
 151            _console.MarkupLine($"[green]Cost command initialized[/]");
 52
 153            if (settings.Verbose)
 54            {
 155                _console.MarkupLine("[dim]Verbose mode enabled[/]");
 56            }
 57
 158            if (settings.All)
 59            {
 160                _console.MarkupLine("[blue]All mode enabled - aggregating over all available data[/]");
 61            }
 62
 63            // Parse filter parameters
 164            var matchdays = ParseMatchdays(settings);
 165            var models = ParseModels(settings);
 166            var reasoningEfforts = ParseReasoningEfforts(settings);
 167            var communityContexts = ParseCommunityContexts(settings);
 68
 69            // Get available model configurations and community contexts if not specified
 170            var availableModelConfigs = await ResolveModelConfigsAsync(predictionRepository, models, reasoningEfforts, c
 171            var availableCommunityContexts = communityContexts ?? await predictionRepository.GetAvailableCommunityContex
 172            var queryMatchdays = matchdays;
 73
 174            if (settings.Verbose)
 75            {
 176                _console.MarkupLine($"[dim]Filters:[/]");
 177                _console.MarkupLine($"[dim]  Matchdays: {FormatMatchdayFilter(queryMatchdays)}[/]");
 178                _console.MarkupLine($"[dim]  Model Configs: {(models?.Any() == true || reasoningEfforts?.Any() == true ?
 179                _console.MarkupLine($"[dim]  Community Contexts: {(communityContexts?.Any() == true ? string.Join(", ", 
 180                _console.MarkupLine($"[dim]  Include Bonus: {settings.Bonus || settings.All}[/]");
 81            }
 82
 83            // Calculate costs
 184            var totalCost = 0.0;
 185            var matchPredictionCost = 0.0;
 186            var bonusPredictionCost = 0.0;
 187            var matchPredictionCount = 0;
 188            var bonusPredictionCount = 0;
 89
 90            // Structure to store detailed breakdown data with reprediction index support
 191            var costRows = new List<CostReportRow>();
 92
 193            await _console.Status()
 194                .Spinner(Spinner.Known.Dots)
 195                .StartAsync("Calculating costs...", async ctx =>
 196                {
 197                    foreach (var modelConfig in availableModelConfigs)
 198                    {
 199                        foreach (var communityContext in availableCommunityContexts)
 1100                        {
 1101                            ctx.Status($"Processing {modelConfig.DisplayName} - {communityContext}...");
 1102
 1103                            if (settings.Verbose)
 1104                            {
 1105                                _console.MarkupLine($"[dim]  Processing model config: {modelConfig.DisplayName}, communi
 1106                            }
 1107
 1108                            // Get match prediction costs by reprediction index
 1109                            var matchCostsByIndex = await GetMatchCostsByRepredictionIndexAsync(
 1110                                predictionRepository,
 1111                                modelConfig,
 1112                                communityContext,
 1113                                queryMatchdays,
 1114                                cancellationToken);
 1115
 1116                            foreach (var kvp in matchCostsByIndex)
 1117                            {
 1118                                var repredictionIndex = kvp.Key;
 1119                                var (cost, count) = kvp.Value;
 1120
 1121                                matchPredictionCost += cost;
 1122                                matchPredictionCount += count;
 1123
 1124                                // Store detailed data for breakdown
 1125                                if (cost > 0 || count > 0)
 1126                                {
 1127                                    costRows.Add(new CostReportRow(communityContext, modelConfig.Model, modelConfig.Reas
 1128                                }
 1129
 1130                                if (settings.Verbose && (cost > 0 || count > 0))
 1131                                {
 1132                                    _console.MarkupLine($"[dim]    Match predictions (reprediction {repredictionIndex}):
 1133                                }
 1134                            }
 1135
 1136                            // Get bonus prediction costs if requested or if all mode is enabled
 1137                            if (settings.Bonus || settings.All)
 1138                            {
 1139                                var bonusCostsByIndex = await predictionRepository.GetBonusPredictionCostsByReprediction
 1140                                    modelConfig,
 1141                                    communityContext,
 1142                                    cancellationToken);
 1143
 1144                                foreach (var kvp in bonusCostsByIndex)
 1145                                {
 1146                                    var repredictionIndex = kvp.Key;
 1147                                    var (cost, count) = kvp.Value;
 1148
 1149                                    bonusPredictionCost += cost;
 1150                                    bonusPredictionCount += count;
 1151
 1152                                    // Store detailed data for breakdown
 1153                                    if (cost > 0 || count > 0)
 1154                                    {
 1155                                        costRows.Add(new CostReportRow(communityContext, modelConfig.Model, modelConfig.
 1156                                    }
 1157
 1158                                    if (settings.Verbose && (cost > 0 || count > 0))
 1159                                    {
 1160                                        _console.MarkupLine($"[dim]    Bonus predictions (reprediction {repredictionInde
 1161                                    }
 1162                                }
 1163                            }
 1164                        }
 1165                    }
 1166                });
 167
 1168            totalCost = matchPredictionCost + bonusPredictionCost;
 169
 170            // Display results
 1171            var table = new Table();
 1172            table.Border(TableBorder.Rounded);
 173
 1174            if (settings.DetailedBreakdown)
 175            {
 176                // Add columns for detailed breakdown with reprediction support
 1177                table.AddColumn("Community Context");
 1178                table.AddColumn("Model");
 1179                table.AddColumn("Category");
 1180                table.AddColumn("Index 0", col => col.RightAligned());
 1181                table.AddColumn("Index 1", col => col.RightAligned());
 1182                table.AddColumn("Index 2+", col => col.RightAligned());
 1183                table.AddColumn("Total Count", col => col.RightAligned());
 1184                table.AddColumn("Total Cost (USD)", col => col.RightAligned());
 185
 186                // Group data by community context, model, and category to aggregate reprediction indices
 1187                var groupedData = costRows
 1188                    .GroupBy(d => new { d.CommunityContext, d.Model, d.ReasoningEffort, d.Category })
 1189                    .Select(g => new
 1190                    {
 1191                        g.Key.CommunityContext,
 1192                        g.Key.Model,
 1193                        ReasoningEffort = g.Key.ReasoningEffort ?? "model-default",
 1194                        ModelConfigDisplayName = PredictionModelConfig.Create(g.Key.Model, g.Key.ReasoningEffort).Displa
 1195                        g.Key.Category,
 1196                        Index0Count = g.Where(x => x.RepredictionIndex == 0).Sum(x => x.Count),
 1197                        Index0Cost = g.Where(x => x.RepredictionIndex == 0).Sum(x => x.Cost),
 1198                        Index1Count = g.Where(x => x.RepredictionIndex == 1).Sum(x => x.Count),
 1199                        Index1Cost = g.Where(x => x.RepredictionIndex == 1).Sum(x => x.Cost),
 1200                        Index2PlusCount = g.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Count),
 1201                        Index2PlusCost = g.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Cost),
 1202                        TotalCount = g.Sum(x => x.Count),
 1203                        TotalCost = g.Sum(x => x.Cost)
 1204                    })
 1205                    .OrderBy(g => g.CommunityContext)
 1206                    .ThenBy(g => g.Model)
 1207                    .ThenBy(g => g.ReasoningEffort)
 1208                    .ThenBy(g => g.Category)
 1209                    .ToList();
 210
 211                // Add rows for detailed breakdown with alternating styling
 1212                for (int i = 0; i < groupedData.Count; i++)
 213                {
 1214                    var data = groupedData[i];
 1215                    var isEvenRow = i % 2 == 0;
 216
 1217                    var index0Text = data.Index0Count > 0 ? $"{data.Index0Count} (${data.Index0Cost.ToString("F2", Cultu
 1218                    var index1Text = data.Index1Count > 0 ? $"{data.Index1Count} (${data.Index1Cost.ToString("F2", Cultu
 1219                    var index2PlusText = data.Index2PlusCount > 0 ? $"{data.Index2PlusCount} (${data.Index2PlusCost.ToSt
 220
 1221                    if (isEvenRow)
 222                    {
 223                        // Even rows - normal styling
 1224                        table.AddRow(
 1225                            data.CommunityContext,
 1226                            data.ModelConfigDisplayName,
 1227                            data.Category,
 1228                            index0Text,
 1229                            index1Text,
 1230                            index2PlusText,
 1231                            data.TotalCount.ToString(CultureInfo.InvariantCulture),
 1232                            $"${data.TotalCost.ToString("F4", CultureInfo.InvariantCulture)}"
 1233                        );
 234                    }
 235                    else
 236                    {
 237                        // Odd rows - subtle blue tint for visual differentiation
 1238                        table.AddRow(
 1239                            $"[blue]{data.CommunityContext}[/]",
 1240                            $"[blue]{data.ModelConfigDisplayName}[/]",
 1241                            $"[blue]{data.Category}[/]",
 1242                            $"[blue]{index0Text}[/]",
 1243                            $"[blue]{index1Text}[/]",
 1244                            $"[blue]{index2PlusText}[/]",
 1245                            $"[blue]{data.TotalCount.ToString(CultureInfo.InvariantCulture)}[/]",
 1246                            $"[blue]${data.TotalCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 1247                        );
 248                    }
 249                }
 250
 251                // Add total row
 1252                if (costRows.Any())
 253                {
 254                    // Calculate totals by reprediction index
 1255                    var totalIndex0Count = costRows.Where(x => x.RepredictionIndex == 0).Sum(x => x.Count);
 1256                    var totalIndex0Cost = costRows.Where(x => x.RepredictionIndex == 0).Sum(x => x.Cost);
 1257                    var totalIndex1Count = costRows.Where(x => x.RepredictionIndex == 1).Sum(x => x.Count);
 1258                    var totalIndex1Cost = costRows.Where(x => x.RepredictionIndex == 1).Sum(x => x.Cost);
 1259                    var totalIndex2PlusCount = costRows.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Count);
 1260                    var totalIndex2PlusCost = costRows.Where(x => x.RepredictionIndex >= 2).Sum(x => x.Cost);
 261
 1262                    var totalIndex0Text = totalIndex0Count > 0 ? $"{totalIndex0Count} (${totalIndex0Cost.ToString("F2", 
 1263                    var totalIndex1Text = totalIndex1Count > 0 ? $"{totalIndex1Count} (${totalIndex1Cost.ToString("F2", 
 1264                    var totalIndex2PlusText = totalIndex2PlusCount > 0 ? $"{totalIndex2PlusCount} (${totalIndex2PlusCost
 265
 1266                    table.AddEmptyRow();
 1267                    table.AddRow(
 1268                        "[bold]Total[/]",
 1269                        "",
 1270                        "",
 1271                        $"[bold]{totalIndex0Text}[/]",
 1272                        $"[bold]{totalIndex1Text}[/]",
 1273                        $"[bold]{totalIndex2PlusText}[/]",
 1274                        $"[bold]{(matchPredictionCount + bonusPredictionCount).ToString(CultureInfo.InvariantCulture)}[/
 1275                        $"[bold]${totalCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 1276                    );
 277                }
 278            }
 279            else
 280            {
 281                // Standard summary table
 1282                table.AddColumn("Category");
 1283                table.AddColumn("Count", col => col.RightAligned());
 1284                table.AddColumn("Cost (USD)", col => col.RightAligned());
 285
 1286                var rowIndex = 0;
 287
 288                // Add Match row
 1289                var isEvenRow = rowIndex % 2 == 0;
 1290                if (isEvenRow)
 291                {
 1292                    table.AddRow("Match", matchPredictionCount.ToString(CultureInfo.InvariantCulture), $"${matchPredicti
 293                }
 294                else
 295                {
 0296                    table.AddRow(
 0297                        "[blue]Match[/]",
 0298                        $"[blue]{matchPredictionCount.ToString(CultureInfo.InvariantCulture)}[/]",
 0299                        $"[blue]${matchPredictionCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 0300                    );
 301                }
 1302                rowIndex++;
 303
 304                // Add Bonus row if applicable
 1305                if (settings.Bonus || settings.All)
 306                {
 1307                    isEvenRow = rowIndex % 2 == 0;
 1308                    if (isEvenRow)
 309                    {
 0310                        table.AddRow("Bonus", bonusPredictionCount.ToString(CultureInfo.InvariantCulture), $"${bonusPred
 311                    }
 312                    else
 313                    {
 1314                        table.AddRow(
 1315                            "[blue]Bonus[/]",
 1316                            $"[blue]{bonusPredictionCount.ToString(CultureInfo.InvariantCulture)}[/]",
 1317                            $"[blue]${bonusPredictionCost.ToString("F4", CultureInfo.InvariantCulture)}[/]"
 1318                        );
 319                    }
 320                }
 321
 1322                table.AddEmptyRow();
 1323                table.AddRow("[bold]Total[/]", $"[bold]{(matchPredictionCount + bonusPredictionCount).ToString(CultureIn
 324            }
 325
 1326            _console.Write(table);
 327
 1328            if (!string.IsNullOrWhiteSpace(settings.OutputJson))
 329            {
 1330                var report = CreateReport(
 1331                    settings,
 1332                    queryMatchdays,
 1333                    models,
 1334                    reasoningEfforts,
 1335                    communityContexts,
 1336                    costRows,
 1337                    matchPredictionCount,
 1338                    matchPredictionCost,
 1339                    bonusPredictionCount,
 1340                    bonusPredictionCost,
 1341                    totalCost);
 1342                await WriteJsonReportAsync(settings.OutputJson, report, cancellationToken);
 1343                _console.MarkupLine($"[green]✓ Cost JSON written to[/] [yellow]{Path.GetFullPath(settings.OutputJson)}[/
 344            }
 345
 1346            _console.MarkupLine($"[green]✓ Cost calculation completed[/]");
 347
 1348            return 0;
 349        }
 1350        catch (Exception ex)
 351        {
 1352            _logger.LogError(ex, "Failed to calculate costs");
 1353            _console.MarkupLine($"[red]✗ Failed to calculate costs: {ex.Message}[/]");
 1354            return 1;
 355        }
 1356    }
 357
 358    private List<int>? ParseMatchdays(CostSettings settings)
 359    {
 1360        if (settings.All || string.IsNullOrWhiteSpace(settings.Matchdays))
 1361            return null; // null means all matchdays
 362
 1363        if (settings.Matchdays.Trim().ToLowerInvariant() == "all")
 1364            return null;
 365
 366        try
 367        {
 1368            return settings.Matchdays
 1369                .Split(',', StringSplitOptions.RemoveEmptyEntries)
 1370                .Select(md => int.Parse(md.Trim()))
 1371                .ToList();
 372        }
 1373        catch (FormatException)
 374        {
 1375            throw new ArgumentException($"Invalid matchday format: {settings.Matchdays}. Use comma-separated numbers (e.
 376        }
 1377    }
 378
 379    private static string FormatMatchdayFilter(List<int>? matchdays)
 380    {
 1381        if (matchdays is null)
 382        {
 1383            return "all (unfiltered; no matchday discovery)";
 384        }
 385
 1386        return matchdays.Count == 0
 1387            ? "none"
 1388            : string.Join(", ", matchdays);
 389    }
 390
 391    private static async Task<Dictionary<int, (double cost, int count)>> GetMatchCostsByRepredictionIndexAsync(
 392        IPredictionRepository predictionRepository,
 393        PredictionModelConfig modelConfig,
 394        string communityContext,
 395        List<int>? matchdays,
 396        CancellationToken cancellationToken)
 397    {
 1398        if (matchdays is { Count: 0 })
 399        {
 0400            return [];
 401        }
 402
 1403        if (matchdays is null || matchdays.Count <= FirestoreInFilterLimit)
 404        {
 1405            return await predictionRepository.GetMatchPredictionCostsByRepredictionIndexAsync(
 1406                modelConfig,
 1407                communityContext,
 1408                matchdays,
 1409                cancellationToken);
 410        }
 411
 1412        var aggregate = new Dictionary<int, (double cost, int count)>();
 413
 1414        foreach (var chunk in matchdays.Chunk(FirestoreInFilterLimit))
 415        {
 1416            var chunkCosts = await predictionRepository.GetMatchPredictionCostsByRepredictionIndexAsync(
 1417                modelConfig,
 1418                communityContext,
 1419                chunk.ToList(),
 1420                cancellationToken);
 421
 1422            foreach (var kvp in chunkCosts)
 423            {
 1424                var (existingCost, existingCount) = aggregate.GetValueOrDefault(kvp.Key);
 1425                var (chunkCost, chunkCount) = kvp.Value;
 1426                aggregate[kvp.Key] = (existingCost + chunkCost, existingCount + chunkCount);
 427            }
 428        }
 429
 1430        return aggregate;
 1431    }
 432
 433    private static CostReport CreateReport(
 434        CostSettings settings,
 435        List<int>? matchdays,
 436        List<string>? models,
 437        List<string?>? reasoningEfforts,
 438        List<string>? communityContexts,
 439        IReadOnlyList<CostReportRow> rows,
 440        int matchPredictionCount,
 441        double matchPredictionCost,
 442        int bonusPredictionCount,
 443        double bonusPredictionCost,
 444        double totalCost)
 445    {
 1446        var categoryTotals = rows
 1447            .GroupBy(row => new { row.Category, row.RepredictionIndex })
 1448            .Select(group => new CostReportCategoryTotal(
 1449                group.Key.Category,
 1450                group.Key.RepredictionIndex,
 1451                group.Sum(row => row.Count),
 1452                group.Sum(row => row.Cost)))
 1453            .OrderBy(total => total.Category, StringComparer.Ordinal)
 1454            .ThenBy(total => total.RepredictionIndex)
 1455            .ToList();
 456
 1457        return new CostReport(
 1458            new CostReportFilters(
 1459                matchdays,
 1460                models,
 1461                reasoningEfforts,
 1462                communityContexts,
 1463                settings.Bonus || settings.All,
 1464                settings.All),
 1465            rows
 1466                .OrderBy(row => row.CommunityContext, StringComparer.Ordinal)
 1467                .ThenBy(row => row.Model, StringComparer.Ordinal)
 1468                .ThenBy(row => row.ReasoningEffort ?? string.Empty, StringComparer.Ordinal)
 1469                .ThenBy(row => row.Category, StringComparer.Ordinal)
 1470                .ThenBy(row => row.RepredictionIndex)
 1471                .ToList(),
 1472            categoryTotals,
 1473            new CostReportTotal(
 1474                matchPredictionCount,
 1475                matchPredictionCost,
 1476                bonusPredictionCount,
 1477                bonusPredictionCost,
 1478                matchPredictionCount + bonusPredictionCount,
 1479                totalCost));
 480    }
 481
 482    private static async Task WriteJsonReportAsync(
 483        string outputPath,
 484        CostReport report,
 485        CancellationToken cancellationToken)
 486    {
 1487        var resolvedPath = Path.GetFullPath(outputPath);
 1488        var directory = Path.GetDirectoryName(resolvedPath);
 489
 1490        if (!string.IsNullOrWhiteSpace(directory))
 491        {
 1492            Directory.CreateDirectory(directory);
 493        }
 494
 1495        await File.WriteAllTextAsync(
 1496            resolvedPath,
 1497            JsonSerializer.Serialize(report, OutputJsonOptions),
 1498            cancellationToken);
 1499    }
 500
 501    private List<string>? ParseModels(CostSettings settings)
 502    {
 1503        if (settings.All || string.IsNullOrWhiteSpace(settings.Models))
 1504            return null; // null means all models
 505
 1506        if (settings.Models.Trim().ToLowerInvariant() == "all")
 1507            return null;
 508
 1509        return settings.Models
 1510            .Split(',', StringSplitOptions.RemoveEmptyEntries)
 1511            .Select(m => m.Trim())
 1512            .Where(m => !string.IsNullOrWhiteSpace(m))
 1513            .ToList();
 514    }
 515
 516    private static List<string?>? ParseReasoningEfforts(CostSettings settings)
 517    {
 1518        if (settings.All || string.IsNullOrWhiteSpace(settings.ReasoningEfforts))
 519        {
 1520            return null;
 521        }
 522
 1523        if (settings.ReasoningEfforts.Trim().Equals("all", StringComparison.OrdinalIgnoreCase))
 524        {
 0525            return null;
 526        }
 527
 1528        var efforts = new List<string?>();
 1529        foreach (var segment in settings.ReasoningEfforts.Split(',', StringSplitOptions.RemoveEmptyEntries))
 530        {
 1531            var trimmed = segment.Trim();
 1532            if (trimmed.Equals("model-default", StringComparison.OrdinalIgnoreCase))
 533            {
 0534                efforts.Add(null);
 0535                continue;
 536            }
 537
 1538            if (!PredictionModelConfig.IsValidReasoningEffort(trimmed))
 539            {
 0540                throw new ArgumentException("--reasoning-efforts must contain only: model-default, none, minimal, low, m
 541            }
 542
 1543            efforts.Add(PredictionModelConfig.NormalizeReasoningEffort(trimmed));
 544        }
 545
 1546        return efforts.Distinct().ToList();
 547    }
 548
 549    private static async Task<List<PredictionModelConfig>> ResolveModelConfigsAsync(
 550        IPredictionRepository predictionRepository,
 551        List<string>? models,
 552        List<string?>? reasoningEfforts,
 553        CancellationToken cancellationToken)
 554    {
 1555        var availableModelConfigs = await predictionRepository.GetAvailableModelConfigsAsync(cancellationToken);
 556
 1557        var filtered = availableModelConfigs.AsEnumerable();
 1558        if (models is not null)
 559        {
 1560            var requestedModels = models.ToHashSet(StringComparer.Ordinal);
 1561            filtered = filtered.Where(config => requestedModels.Contains(config.Model));
 562        }
 563
 1564        if (reasoningEfforts is not null)
 565        {
 1566            var requestedEfforts = reasoningEfforts.ToHashSet(StringComparer.Ordinal);
 1567            filtered = filtered.Where(config => requestedEfforts.Contains(config.ReasoningEffort));
 568        }
 569
 1570        return filtered
 1571            .OrderBy(config => config.Model, StringComparer.Ordinal)
 1572            .ThenBy(config => config.ReasoningEffort ?? string.Empty, StringComparer.Ordinal)
 1573            .ToList();
 1574    }
 575
 576    private List<string>? ParseCommunityContexts(CostSettings settings)
 577    {
 1578        if (settings.All || string.IsNullOrWhiteSpace(settings.CommunityContexts))
 1579            return null; // null means all community contexts
 580
 1581        if (settings.CommunityContexts.Trim().ToLowerInvariant() == "all")
 1582            return null;
 583
 1584        return settings.CommunityContexts
 1585            .Split(',', StringSplitOptions.RemoveEmptyEntries)
 1586            .Select(cc => cc.Trim())
 1587            .Where(cc => !string.IsNullOrWhiteSpace(cc))
 1588            .ToList();
 589    }
 590
 591    private async Task<CostConfiguration> LoadConfigurationFromFile(string configFilePath)
 592    {
 593        try
 594        {
 595            // Resolve relative paths
 1596            var resolvedPath = Path.IsPathRooted(configFilePath)
 1597                ? configFilePath
 1598                : Path.Combine(Directory.GetCurrentDirectory(), configFilePath);
 599
 1600            if (!File.Exists(resolvedPath))
 601            {
 1602                throw new FileNotFoundException($"Configuration file not found: {resolvedPath}");
 603            }
 604
 1605            _logger.LogInformation("Loading configuration from: {ConfigPath}", resolvedPath);
 606
 1607            var jsonContent = await File.ReadAllTextAsync(resolvedPath);
 1608            var options = new JsonSerializerOptions
 1609            {
 1610                PropertyNameCaseInsensitive = true,
 1611                AllowTrailingCommas = true,
 1612                ReadCommentHandling = JsonCommentHandling.Skip
 1613            };
 614
 1615            var config = JsonSerializer.Deserialize<CostConfiguration>(jsonContent, options);
 1616            if (config == null)
 617            {
 0618                throw new InvalidOperationException($"Failed to deserialize configuration from: {resolvedPath}");
 619            }
 620
 1621            return config;
 622        }
 1623        catch (JsonException ex)
 624        {
 1625            throw new InvalidOperationException($"Invalid JSON in configuration file: {configFilePath}. {ex.Message}", e
 626        }
 1627        catch (Exception ex)
 628        {
 1629            throw new InvalidOperationException($"Failed to load configuration from file: {configFilePath}. {ex.Message}
 630        }
 1631    }
 632
 633    private CostSettings MergeConfigurations(CostConfiguration fileConfig, CostSettings cliSettings)
 634    {
 1635        _logger.LogInformation("Merging file configuration with command line options (CLI options take precedence)");
 636
 637        // Create a new settings object with file config as base, CLI overrides
 1638        var mergedSettings = new CostSettings();
 639
 640        // Apply file config first (if values are not null/default)
 1641        if (!string.IsNullOrWhiteSpace(fileConfig.Matchdays))
 1642            mergedSettings.Matchdays = fileConfig.Matchdays;
 643
 1644        if (fileConfig.Bonus.HasValue)
 1645            mergedSettings.Bonus = fileConfig.Bonus.Value;
 646
 1647        if (!string.IsNullOrWhiteSpace(fileConfig.Models))
 1648            mergedSettings.Models = fileConfig.Models;
 649
 1650        if (!string.IsNullOrWhiteSpace(fileConfig.ReasoningEfforts))
 0651            mergedSettings.ReasoningEfforts = fileConfig.ReasoningEfforts;
 652
 1653        if (!string.IsNullOrWhiteSpace(fileConfig.CommunityContexts))
 1654            mergedSettings.CommunityContexts = fileConfig.CommunityContexts;
 655
 1656        if (fileConfig.All.HasValue)
 1657            mergedSettings.All = fileConfig.All.Value;
 658
 1659        if (fileConfig.Verbose.HasValue)
 1660            mergedSettings.Verbose = fileConfig.Verbose.Value;
 661
 1662        if (fileConfig.DetailedBreakdown.HasValue)
 1663            mergedSettings.DetailedBreakdown = fileConfig.DetailedBreakdown.Value;
 664
 1665        if (!string.IsNullOrWhiteSpace(fileConfig.OutputJson))
 1666            mergedSettings.OutputJson = fileConfig.OutputJson;
 667
 668        // Override with CLI settings (non-default values)
 1669        if (!string.IsNullOrWhiteSpace(cliSettings.Matchdays))
 670        {
 1671            mergedSettings.Matchdays = cliSettings.Matchdays;
 1672            if (mergedSettings.Verbose)
 1673                _logger.LogInformation("CLI override: Matchdays = {Value}", cliSettings.Matchdays);
 674        }
 675
 1676        if (cliSettings.Bonus) // Only override if explicitly set to true
 677        {
 1678            mergedSettings.Bonus = cliSettings.Bonus;
 1679            if (mergedSettings.Verbose)
 1680                _logger.LogInformation("CLI override: Bonus = {Value}", cliSettings.Bonus);
 681        }
 682
 1683        if (!string.IsNullOrWhiteSpace(cliSettings.Models))
 684        {
 1685            mergedSettings.Models = cliSettings.Models;
 1686            if (mergedSettings.Verbose)
 1687                _logger.LogInformation("CLI override: Models = {Value}", cliSettings.Models);
 688        }
 689
 1690        if (!string.IsNullOrWhiteSpace(cliSettings.ReasoningEfforts))
 691        {
 0692            mergedSettings.ReasoningEfforts = cliSettings.ReasoningEfforts;
 0693            if (mergedSettings.Verbose)
 0694                _logger.LogInformation("CLI override: ReasoningEfforts = {Value}", cliSettings.ReasoningEfforts);
 695        }
 696
 1697        if (!string.IsNullOrWhiteSpace(cliSettings.CommunityContexts))
 698        {
 1699            mergedSettings.CommunityContexts = cliSettings.CommunityContexts;
 1700            if (mergedSettings.Verbose)
 1701                _logger.LogInformation("CLI override: CommunityContexts = {Value}", cliSettings.CommunityContexts);
 702        }
 703
 1704        if (cliSettings.All) // Only override if explicitly set to true
 705        {
 1706            mergedSettings.All = cliSettings.All;
 1707            if (mergedSettings.Verbose)
 1708                _logger.LogInformation("CLI override: All = {Value}", cliSettings.All);
 709        }
 710
 1711        if (cliSettings.Verbose) // Only override if explicitly set to true
 712        {
 1713            mergedSettings.Verbose = cliSettings.Verbose;
 714        }
 715
 1716        if (cliSettings.DetailedBreakdown) // Only override if explicitly set to true
 717        {
 1718            mergedSettings.DetailedBreakdown = cliSettings.DetailedBreakdown;
 1719            if (mergedSettings.Verbose)
 1720                _logger.LogInformation("CLI override: DetailedBreakdown = {Value}", cliSettings.DetailedBreakdown);
 721        }
 722
 1723        if (!string.IsNullOrWhiteSpace(cliSettings.OutputJson))
 724        {
 0725            mergedSettings.OutputJson = cliSettings.OutputJson;
 0726            if (mergedSettings.Verbose)
 0727                _logger.LogInformation("CLI override: OutputJson = {Value}", cliSettings.OutputJson);
 728        }
 729
 730        // Always preserve the ConfigFile setting
 1731        mergedSettings.ConfigFile = cliSettings.ConfigFile;
 732
 1733        return mergedSettings;
 734    }
 735
 1736    private sealed record CostReport(
 1737        CostReportFilters Filters,
 1738        IReadOnlyList<CostReportRow> Rows,
 1739        IReadOnlyList<CostReportCategoryTotal> CategoryTotals,
 1740        CostReportTotal Total);
 741
 1742    private sealed record CostReportFilters(
 1743        IReadOnlyList<int>? Matchdays,
 1744        IReadOnlyList<string>? Models,
 1745        IReadOnlyList<string?>? ReasoningEfforts,
 1746        IReadOnlyList<string>? CommunityContexts,
 1747        bool IncludeBonus,
 1748        bool All);
 749
 1750    private sealed record CostReportRow(
 1751        string CommunityContext,
 1752        string Model,
 1753        string? ReasoningEffort,
 1754        string ModelConfigKey,
 1755        string ModelConfigDisplayName,
 1756        string Category,
 1757        int RepredictionIndex,
 1758        int Count,
 1759        double Cost);
 760
 1761    private sealed record CostReportCategoryTotal(
 1762        string Category,
 1763        int RepredictionIndex,
 1764        int Count,
 1765        double Cost);
 766
 1767    private sealed record CostReportTotal(
 1768        int MatchPredictionCount,
 1769        double MatchPredictionCost,
 1770        int BonusPredictionCount,
 1771        double BonusPredictionCost,
 1772        int Count,
 1773        double Cost);
 774}

Methods/Properties

.cctor()
.ctor(Spectre.Console.IAnsiConsole, Orchestrator.Infrastructure.Factories.IFirebaseServiceFactory, Microsoft.Extensions.Logging.ILogger<Orchestrator.Commands.Observability.Cost.CostCommand>)
ExecuteAsync()
<ExecuteAsync()
ParseMatchdays(Orchestrator.Commands.Observability.Cost.CostSettings)
FormatMatchdayFilter(System.Collections.Generic.List<int>)
GetMatchCostsByRepredictionIndexAsync()
CreateReport(Orchestrator.Commands.Observability.Cost.CostSettings, System.Collections.Generic.List<int>, System.Collections.Generic.List<string>, System.Collections.Generic.List<string>, System.Collections.Generic.List<string>, System.Collections.Generic.IReadOnlyList<Orchestrator.Commands.Observability.Cost.CostCommand.CostReportRow>, int, double, int, double, double)
WriteJsonReportAsync()
ParseModels(Orchestrator.Commands.Observability.Cost.CostSettings)
ParseReasoningEfforts(Orchestrator.Commands.Observability.Cost.CostSettings)
ResolveModelConfigsAsync()
ParseCommunityContexts(Orchestrator.Commands.Observability.Cost.CostSettings)
LoadConfigurationFromFile()
MergeConfigurations(Orchestrator.Commands.Observability.Cost.CostConfiguration, Orchestrator.Commands.Observability.Cost.CostSettings)
.ctor(Orchestrator.Commands.Observability.Cost.CostCommand.CostReportFilters, System.Collections.Generic.IReadOnlyList<Orchestrator.Commands.Observability.Cost.CostCommand.CostReportRow>, System.Collections.Generic.IReadOnlyList<Orchestrator.Commands.Observability.Cost.CostCommand.CostReportCategoryTotal>, Orchestrator.Commands.Observability.Cost.CostCommand.CostReportTotal)
.ctor(System.Collections.Generic.IReadOnlyList<int>, System.Collections.Generic.IReadOnlyList<string>, System.Collections.Generic.IReadOnlyList<string>, System.Collections.Generic.IReadOnlyList<string>, bool, bool)
.ctor(string, string, string, string, string, string, int, int, double)
.ctor(string, int, int, double)
.ctor(int, double, int, double, int, double)