From a776d978ea1accb914266727083ac218d954a7f2 Mon Sep 17 00:00:00 2001 From: TomiEckert Date: Fri, 6 Mar 2026 08:32:37 +0100 Subject: [PATCH] feat: saving token data to sessions --- AppJsonContext.cs | 57 +++--- ChatSession.cs | 45 +++++ Commands/LoadCommand.cs | 31 --- Commands/SaveCommand.cs | 31 --- OpenRouter/TokenTracker.cs | 25 ++- Program.cs | 391 ++++++++++++++++++------------------- README.md | 2 - 7 files changed, 283 insertions(+), 299 deletions(-) delete mode 100644 Commands/LoadCommand.cs delete mode 100644 Commands/SaveCommand.cs diff --git a/AppJsonContext.cs b/AppJsonContext.cs index d3727b7..c6e83c3 100644 --- a/AppJsonContext.cs +++ b/AppJsonContext.cs @@ -1,28 +1,29 @@ -using System.Text.Json.Serialization; -using AnchorCli.OpenRouter; -using AnchorCli.Tools; - -namespace AnchorCli; - -/// -/// Source-generated JSON serializer context for Native AOT compatibility. -/// Covers all parameter / return types used by AIFunction tool methods -/// and the OpenRouter models API. -/// -[JsonSerializable(typeof(string))] -[JsonSerializable(typeof(string[]))] -[JsonSerializable(typeof(int))] -[JsonSerializable(typeof(bool))] -[JsonSerializable(typeof(string[][]))] -[JsonSerializable(typeof(ModelsResponse))] -[JsonSerializable(typeof(ModelInfo))] -[JsonSerializable(typeof(ModelPricing))] -[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))] -[JsonSerializable(typeof(System.Collections.Generic.List))] -[JsonSerializable(typeof(AnchorConfig))] -[JsonSerializable(typeof(BatchOperation))] -[JsonSerializable(typeof(BatchOperation[]))] -[JsonSourceGenerationOptions( - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -internal partial class AppJsonContext : JsonSerializerContext; +using System.Text.Json.Serialization; +using AnchorCli.OpenRouter; +using AnchorCli.Tools; + +namespace AnchorCli; + +/// +/// Source-generated JSON serializer context for Native AOT compatibility. +/// Covers all parameter / return types used by AIFunction tool methods +/// and the OpenRouter models API. +/// +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(string[]))] +[JsonSerializable(typeof(int))] +[JsonSerializable(typeof(bool))] +[JsonSerializable(typeof(string[][]))] +[JsonSerializable(typeof(ModelsResponse))] +[JsonSerializable(typeof(ModelInfo))] +[JsonSerializable(typeof(ModelPricing))] +[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))] +[JsonSerializable(typeof(System.Collections.Generic.List))] +[JsonSerializable(typeof(AnchorConfig))] +[JsonSerializable(typeof(BatchOperation))] +[JsonSerializable(typeof(BatchOperation[]))] +[JsonSerializable(typeof(TokenMetadata))] +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal partial class AppJsonContext : JsonSerializerContext; diff --git a/ChatSession.cs b/ChatSession.cs index 9d3e518..8626ef5 100644 --- a/ChatSession.cs +++ b/ChatSession.cs @@ -8,6 +8,11 @@ internal sealed class ChatSession private readonly IChatClient _agent; public ContextCompactor Compactor { get; } public List History { get; } + + // Token tracking state persisted across sessions + public long SessionInputTokens { get; set; } + public long SessionOutputTokens { get; set; } + public int RequestCount { get; set; } public ChatSession(IChatClient innerClient) { @@ -84,6 +89,18 @@ internal sealed class ChatSession }; var json = JsonSerializer.Serialize(messagesToSave, AppJsonContext.Default.ListChatMessage); + + // Save token stats to a separate metadata file + var metadataPath = Path.ChangeExtension(filePath, ".metadata.json"); + var metadata = new TokenMetadata + { + SessionInputTokens = SessionInputTokens, + SessionOutputTokens = SessionOutputTokens, + RequestCount = RequestCount + }; + var metadataJson = JsonSerializer.Serialize(metadata, AppJsonContext.Default.TokenMetadata); + await File.WriteAllTextAsync(metadataPath, metadataJson, cancellationToken); + await File.WriteAllTextAsync(filePath, json, cancellationToken); } @@ -104,5 +121,33 @@ internal sealed class ChatSession History.Clear(); History.Add(systemMessage); History.AddRange(messages); + + // Load token stats from metadata file if it exists + var metadataPath = Path.ChangeExtension(filePath, ".metadata.json"); + if (File.Exists(metadataPath)) + { + try + { + var metadataJson = await File.ReadAllTextAsync(metadataPath, cancellationToken); + var metadata = JsonSerializer.Deserialize(metadataJson, AppJsonContext.Default.TokenMetadata); + if (metadata != null) + { + SessionInputTokens = metadata.SessionInputTokens; + SessionOutputTokens = metadata.SessionOutputTokens; + RequestCount = metadata.RequestCount; + } + } + catch { /* Ignore metadata load errors */ } + } } } + +/// +/// Token tracking metadata serialized with the session. +/// +internal sealed class TokenMetadata +{ + public long SessionInputTokens { get; set; } + public long SessionOutputTokens { get; set; } + public int RequestCount { get; set; } +} diff --git a/Commands/LoadCommand.cs b/Commands/LoadCommand.cs deleted file mode 100644 index 2375a88..0000000 --- a/Commands/LoadCommand.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Spectre.Console; - -namespace AnchorCli.Commands; - -internal class LoadCommand(ChatSession session) : ICommand -{ - public string Name => "load"; - public string Description => "Load a chat session from a file"; - - public async Task ExecuteAsync(string[] args, CancellationToken ct) - { - string filePath = args.Length > 0 ? args[0] : ".anchor/session.json"; - - if (!File.Exists(filePath)) - { - AnsiConsole.MarkupLine($"[yellow]No session file found at {Markup.Escape(filePath)}[/]"); - return; - } - - try - { - await session.LoadAsync(filePath, ct); - AnsiConsole.MarkupLine($"[green]Session loaded from {Markup.Escape(filePath)}[/]"); - } - catch (Exception ex) - { - AnsiConsole.MarkupLine($"[red]Failed to load session: {Markup.Escape(ex.Message)}[/]"); - } - } -} - diff --git a/Commands/SaveCommand.cs b/Commands/SaveCommand.cs deleted file mode 100644 index e94e61e..0000000 --- a/Commands/SaveCommand.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Spectre.Console; - -namespace AnchorCli.Commands; - -internal class SaveCommand(ChatSession session) : ICommand -{ - public string Name => "save"; - public string Description => "Save the current chat session to a file"; - - public async Task ExecuteAsync(string[] args, CancellationToken ct) - { - string filePath = args.Length > 0 ? args[0] : ".anchor/session.json"; - - try - { - var directory = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - - await session.SaveAsync(filePath, ct); - AnsiConsole.MarkupLine($"[green]Session saved to {Markup.Escape(filePath)}[/]"); - } - catch (Exception ex) - { - AnsiConsole.MarkupLine($"[red]Failed to save session: {Markup.Escape(ex.Message)}[/]"); - } - } -} - diff --git a/OpenRouter/TokenTracker.cs b/OpenRouter/TokenTracker.cs index 60caa62..e29d102 100644 --- a/OpenRouter/TokenTracker.cs +++ b/OpenRouter/TokenTracker.cs @@ -5,10 +5,17 @@ namespace AnchorCli.OpenRouter; /// internal sealed class TokenTracker { + private readonly ChatSession _session; + + public TokenTracker(ChatSession session) + { + _session = session; + } + public string Provider { get; set; } = "Unknown"; - public long SessionInputTokens { get; private set; } - public long SessionOutputTokens { get; private set; } - public int RequestCount { get; private set; } + public long SessionInputTokens => _session.SessionInputTokens; + public long SessionOutputTokens => _session.SessionOutputTokens; + public int RequestCount => _session.RequestCount; /// Maximum context window for the model (tokens). 0 = unknown. public int ContextLength { get; set; } @@ -29,16 +36,16 @@ internal sealed class TokenTracker /// public void AddUsage(int inputTokens, int outputTokens) { - SessionInputTokens += inputTokens; - SessionOutputTokens += outputTokens; + _session.SessionInputTokens += inputTokens; + _session.SessionOutputTokens += outputTokens; LastInputTokens = inputTokens; - RequestCount++; + _session.RequestCount++; } public void Reset() { - SessionInputTokens = 0; - SessionOutputTokens = 0; - RequestCount = 0; + _session.SessionInputTokens = 0; + _session.SessionOutputTokens = 0; + _session.RequestCount = 0; LastInputTokens = 0; } diff --git a/Program.cs b/Program.cs index 9c22345..b579c72 100644 --- a/Program.cs +++ b/Program.cs @@ -1,198 +1,193 @@ -using System.ClientModel; -using System.Reflection; -using AnchorCli.Providers; -using Microsoft.Extensions.AI; -using OpenAI; -using AnchorCli; -using AnchorCli.Tools; -using AnchorCli.Commands; -using AnchorCli.OpenRouter; -using Spectre.Console; - -// ── Setup subcommand ───────────────────────────────────────────────────── -if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) -{ - SetupTui.Run(); - return; -} - -// ── Config ────────────────────────────────────────────────────────────── -var cfg = AnchorConfig.Load(); -string apiKey = cfg.ApiKey; -string model = cfg.Model; -string provider = cfg.Provider ?? "openrouter"; -string endpoint = cfg.Endpoint ?? "https://openrouter.ai/api/v1"; - -if (string.IsNullOrWhiteSpace(apiKey)) -{ - AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); - return; -} - -// ── Create token extractor for this provider ─────────────────────────── -var tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(endpoint); -var tokenTracker = new TokenTracker { Provider = tokenExtractor.ProviderName }; - -// ── Fetch model pricing (only for supported providers) ───────────────── -ModelInfo? modelInfo = null; -if (ProviderFactory.IsOpenRouter(endpoint)) -{ - await AnsiConsole.Status() - .Spinner(Spinner.Known.BouncingBar) - .SpinnerStyle(Style.Parse("cornflowerblue")) - .StartAsync("Fetching model pricing...", async ctx => - { - try - { - var pricingProvider = new OpenRouterProvider(); - modelInfo = await pricingProvider.GetModelInfoAsync(model); - if (modelInfo?.Pricing != null) - { - tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt); - tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion); - tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request); - } - } - catch { /* pricing is best-effort */ } - }); -} - - -// ── Pretty header ─────────────────────────────────────────────────────── -AnsiConsole.Write( - new FigletText("anchor") - .Color(Color.CornflowerBlue)); - -var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; - -AnsiConsole.Write( - new Rule("[dim]AI-powered coding assistant[/]") - .RuleStyle(Style.Parse("cornflowerblue dim")) - .LeftJustified()); -// ── Pretty header ─────────────────────────────────────────────────────── - -AnsiConsole.Write( - new Rule("[dim]AI-powered coding assistant[/]") - .RuleStyle(Style.Parse("cornflowerblue dim")) - .LeftJustified()); - -AnsiConsole.WriteLine(); - -var infoTable = new Table() - .Border(TableBorder.Rounded) - .BorderColor(Color.Grey) - .AddColumn(new TableColumn("[dim]Setting[/]").NoWrap()) - .AddColumn(new TableColumn("[dim]Value[/]")); - -infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]"); -infoTable.AddRow("[grey]Provider[/]", $"[blue]{tokenExtractor.ProviderName}[/]"); -infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]"); -infoTable.AddRow("[grey]Version[/]", $"[magenta]{version}[/]"); - -if (modelInfo?.Pricing != null) - - { - var inM = tokenTracker.InputPrice * 1_000_000m; - var outM = tokenTracker.OutputPrice * 1_000_000m; - infoTable.AddRow("[grey]Pricing[/]", - $"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]"); - } -if (modelInfo != null) -{ - infoTable.AddRow("[grey]Context[/]", - $"[dim]{modelInfo.ContextLength:N0} tokens[/]"); -} - -AnsiConsole.Write(infoTable); -AnsiConsole.WriteLine(); - -// ── Build the chat client with tool-calling support ───────────────────── -var httpClient = new HttpClient(); -OpenRouterHeaders.ApplyTo(httpClient); - -var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions -{ - Endpoint = new Uri(endpoint), - Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient) -}); - -IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient(); - -// ── Tool call logging via Spectre ─────────────────────────────────────── -object consoleLock = new(); - -void ToolLog(string message) -{ - lock (consoleLock) - { - Console.Write("\r" + new string(' ', 40) + "\r"); - AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]"); - } -} - -CommandTool.Log = -DirTools.Log = -FileTools.Log = -EditTools.Log = ToolLog; - -// ── Instantiate Core Components ────────────────────────────────────────── - -var session = new ChatSession(innerClient); -if (modelInfo != null) -{ - tokenTracker.ContextLength = modelInfo.ContextLength; -} - -var commandRegistry = new CommandRegistry(); -commandRegistry.Register(new ExitCommand()); -commandRegistry.Register(new HelpCommand(commandRegistry)); -commandRegistry.Register(new ClearCommand()); -commandRegistry.Register(new StatusCommand(model, endpoint)); -commandRegistry.Register(new CompactCommand(session.Compactor, session.History)); -commandRegistry.Register(new SetupCommand()); -commandRegistry.Register(new ResetCommand(session, tokenTracker)); -commandRegistry.Register(new SaveCommand(session)); -commandRegistry.Register(new LoadCommand(session)); - - -var commandDispatcher = new CommandDispatcher(commandRegistry); - -// ── Run Repl ──────────────────────────────────────────────────────────── - -// Auto-load session if it exists -const string sessionPath = ".anchor/session.json"; -if (File.Exists(sessionPath)) -{ - try - { - await session.LoadAsync(sessionPath, default); - AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]"); - - // Print the last message if there is one - if (session.History.Count > 1) - { - var lastMessage = session.History[^1]; - var preview = lastMessage.Text.Length > 280 - ? lastMessage.Text[..277] + "..." - : lastMessage.Text; - AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]"); - } - } - catch { /* Ignore load errors on startup */ } -} - -var repl = new ReplLoop(session, tokenTracker, commandDispatcher); -await repl.RunAsync(); - -// Auto-save session on clean exit -try -{ - var directory = Path.GetDirectoryName(sessionPath); - if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - await session.SaveAsync(sessionPath, default); -} -catch { /* Ignore save errors on exit */ } - +using System.ClientModel; +using System.Reflection; +using AnchorCli.Providers; +using Microsoft.Extensions.AI; +using OpenAI; +using AnchorCli; +using AnchorCli.Tools; +using AnchorCli.Commands; +using AnchorCli.OpenRouter; +using Spectre.Console; + +// ── Setup subcommand ───────────────────────────────────────────────────── +if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) +{ + SetupTui.Run(); + return; +} + +// ── Config ────────────────────────────────────────────────────────────── +var cfg = AnchorConfig.Load(); +string apiKey = cfg.ApiKey; +string model = cfg.Model; +string provider = cfg.Provider ?? "openrouter"; +string endpoint = cfg.Endpoint ?? "https://openrouter.ai/api/v1"; + +if (string.IsNullOrWhiteSpace(apiKey)) +{ + AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); + return; +} + +// ── Create token extractor for this provider ─────────────────────────── +var tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(endpoint); + +// ── Fetch model pricing (only for supported providers) ───────────────── +ModelInfo? modelInfo = null; +TokenTracker? tokenTracker = null; +if (ProviderFactory.IsOpenRouter(endpoint)) +{ + await AnsiConsole.Status() + .Spinner(Spinner.Known.BouncingBar) + .SpinnerStyle(Style.Parse("cornflowerblue")) + .StartAsync("Fetching model pricing...", async ctx => + { + try + { + var pricingProvider = new OpenRouterProvider(); + modelInfo = await pricingProvider.GetModelInfoAsync(model); + } + catch { /* pricing is best-effort */ } + }); +} + + +// ── Pretty header ─────────────────────────────────────────────────────── +AnsiConsole.Write( + new FigletText("anchor") + .Color(Color.CornflowerBlue)); + +var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; + +// ── Pretty header ─────────────────────────────────────────────────────── + +AnsiConsole.Write( + new Rule("[dim]AI-powered coding assistant[/]") + .RuleStyle(Style.Parse("cornflowerblue dim")) + .LeftJustified()); + +AnsiConsole.WriteLine(); + +var infoTable = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Grey) + .AddColumn(new TableColumn("[dim]Setting[/]").NoWrap()) + .AddColumn(new TableColumn("[dim]Value[/]")); + +infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]"); +infoTable.AddRow("[grey]Provider[/]", $"[blue]{tokenExtractor.ProviderName}[/]"); +infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]"); +infoTable.AddRow("[grey]Version[/]", $"[magenta]{version}[/]"); + +if (modelInfo?.Pricing != null && tokenTracker != null) + + { + var inM = tokenTracker.InputPrice * 1_000_000m; + var outM = tokenTracker.OutputPrice * 1_000_000m; + infoTable.AddRow("[grey]Pricing[/]", + $"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]"); + } +if (modelInfo != null) +{ + infoTable.AddRow("[grey]Context[/]", + $"[dim]{modelInfo.ContextLength:N0} tokens[/]"); +} + +AnsiConsole.Write(infoTable); +AnsiConsole.WriteLine(); + +// ── Build the chat client with tool-calling support ───────────────────── +var httpClient = new HttpClient(); +OpenRouterHeaders.ApplyTo(httpClient); + +var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions +{ + Endpoint = new Uri(endpoint), + Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient) +}); + +IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient(); + +// ── Tool call logging via Spectre ─────────────────────────────────────── +object consoleLock = new(); + +void ToolLog(string message) +{ + lock (consoleLock) + { + Console.Write("\r" + new string(' ', 40) + "\r"); + AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]"); + } +} + +CommandTool.Log = +DirTools.Log = +FileTools.Log = +EditTools.Log = ToolLog; + +// ── Instantiate Core Components ────────────────────────────────────────── + +var session = new ChatSession(innerClient); +tokenTracker = new TokenTracker(session) { Provider = tokenExtractor.ProviderName }; +if (modelInfo != null) +{ + if (modelInfo.Pricing != null) + { + tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt); + tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion); + tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request); + } + tokenTracker.ContextLength = modelInfo.ContextLength; +} + +var commandRegistry = new CommandRegistry(); +commandRegistry.Register(new ExitCommand()); +commandRegistry.Register(new HelpCommand(commandRegistry)); +commandRegistry.Register(new ClearCommand()); +commandRegistry.Register(new StatusCommand(model, endpoint)); +commandRegistry.Register(new CompactCommand(session.Compactor, session.History)); +commandRegistry.Register(new SetupCommand()); +commandRegistry.Register(new ResetCommand(session, tokenTracker)); + + +var commandDispatcher = new CommandDispatcher(commandRegistry); + +// ── Run Repl ──────────────────────────────────────────────────────────── + +// Auto-load session if it exists +const string sessionPath = ".anchor/session.json"; +if (File.Exists(sessionPath)) +{ + try + { + await session.LoadAsync(sessionPath, default); + AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]"); + + // Print the last message if there is one + if (session.History.Count > 1) + { + var lastMessage = session.History[^1]; + var preview = lastMessage.Text.Length > 280 + ? lastMessage.Text[..277] + "..." + : lastMessage.Text; + AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]"); + } + } + catch { /* Ignore load errors on startup */ } +} + +var repl = new ReplLoop(session, tokenTracker, commandDispatcher); +await repl.RunAsync(); + +// Auto-save session on clean exit +try +{ + var directory = Path.GetDirectoryName(sessionPath); + if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + await session.SaveAsync(sessionPath, default); +} +catch { /* Ignore save errors on exit */ } + diff --git a/README.md b/README.md index e7c552e..2dd455f 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,6 @@ AnchorCli/ │ ├── CompactCommand.cs # /compact command │ ├── ResetCommand.cs # /reset command │ ├── SetupCommand.cs # /setup command -│ ├── LoadCommand.cs # /load command -│ └── SaveCommand.cs # /save command └── OpenRouter/ └── PricingProvider.cs # Fetch model pricing from OpenRouter ```