1
0

feat: saving token data to sessions

This commit is contained in:
2026-03-06 08:32:37 +01:00
parent 91a44bb2a4
commit a776d978ea
7 changed files with 283 additions and 299 deletions

View File

@@ -1,28 +1,29 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using AnchorCli.OpenRouter; using AnchorCli.OpenRouter;
using AnchorCli.Tools; using AnchorCli.Tools;
namespace AnchorCli; namespace AnchorCli;
/// <summary> /// <summary>
/// Source-generated JSON serializer context for Native AOT compatibility. /// Source-generated JSON serializer context for Native AOT compatibility.
/// Covers all parameter / return types used by AIFunction tool methods /// Covers all parameter / return types used by AIFunction tool methods
/// and the OpenRouter models API. /// and the OpenRouter models API.
/// </summary> /// </summary>
[JsonSerializable(typeof(string))] [JsonSerializable(typeof(string))]
[JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(int))] [JsonSerializable(typeof(int))]
[JsonSerializable(typeof(bool))] [JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(string[][]))] [JsonSerializable(typeof(string[][]))]
[JsonSerializable(typeof(ModelsResponse))] [JsonSerializable(typeof(ModelsResponse))]
[JsonSerializable(typeof(ModelInfo))] [JsonSerializable(typeof(ModelInfo))]
[JsonSerializable(typeof(ModelPricing))] [JsonSerializable(typeof(ModelPricing))]
[JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))] [JsonSerializable(typeof(Microsoft.Extensions.AI.ChatMessage))]
[JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))] [JsonSerializable(typeof(System.Collections.Generic.List<Microsoft.Extensions.AI.ChatMessage>))]
[JsonSerializable(typeof(AnchorConfig))] [JsonSerializable(typeof(AnchorConfig))]
[JsonSerializable(typeof(BatchOperation))] [JsonSerializable(typeof(BatchOperation))]
[JsonSerializable(typeof(BatchOperation[]))] [JsonSerializable(typeof(BatchOperation[]))]
[JsonSourceGenerationOptions( [JsonSerializable(typeof(TokenMetadata))]
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, [JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
internal partial class AppJsonContext : JsonSerializerContext; PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class AppJsonContext : JsonSerializerContext;

View File

@@ -8,6 +8,11 @@ internal sealed class ChatSession
private readonly IChatClient _agent; private readonly IChatClient _agent;
public ContextCompactor Compactor { get; } public ContextCompactor Compactor { get; }
public List<ChatMessage> History { get; } public List<ChatMessage> 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) public ChatSession(IChatClient innerClient)
{ {
@@ -84,6 +89,18 @@ internal sealed class ChatSession
}; };
var json = JsonSerializer.Serialize(messagesToSave, AppJsonContext.Default.ListChatMessage); 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); await File.WriteAllTextAsync(filePath, json, cancellationToken);
} }
@@ -104,5 +121,33 @@ internal sealed class ChatSession
History.Clear(); History.Clear();
History.Add(systemMessage); History.Add(systemMessage);
History.AddRange(messages); 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<TokenMetadata>(metadataJson, AppJsonContext.Default.TokenMetadata);
if (metadata != null)
{
SessionInputTokens = metadata.SessionInputTokens;
SessionOutputTokens = metadata.SessionOutputTokens;
RequestCount = metadata.RequestCount;
}
}
catch { /* Ignore metadata load errors */ }
}
} }
} }
/// <summary>
/// Token tracking metadata serialized with the session.
/// </summary>
internal sealed class TokenMetadata
{
public long SessionInputTokens { get; set; }
public long SessionOutputTokens { get; set; }
public int RequestCount { get; set; }
}

View File

@@ -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)}[/]");
}
}
}

View File

@@ -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)}[/]");
}
}
}

View File

@@ -5,10 +5,17 @@ namespace AnchorCli.OpenRouter;
/// </summary> /// </summary>
internal sealed class TokenTracker internal sealed class TokenTracker
{ {
private readonly ChatSession _session;
public TokenTracker(ChatSession session)
{
_session = session;
}
public string Provider { get; set; } = "Unknown"; public string Provider { get; set; } = "Unknown";
public long SessionInputTokens { get; private set; } public long SessionInputTokens => _session.SessionInputTokens;
public long SessionOutputTokens { get; private set; } public long SessionOutputTokens => _session.SessionOutputTokens;
public int RequestCount { get; private set; } public int RequestCount => _session.RequestCount;
/// <summary>Maximum context window for the model (tokens). 0 = unknown.</summary> /// <summary>Maximum context window for the model (tokens). 0 = unknown.</summary>
public int ContextLength { get; set; } public int ContextLength { get; set; }
@@ -29,16 +36,16 @@ internal sealed class TokenTracker
/// </summary> /// </summary>
public void AddUsage(int inputTokens, int outputTokens) public void AddUsage(int inputTokens, int outputTokens)
{ {
SessionInputTokens += inputTokens; _session.SessionInputTokens += inputTokens;
SessionOutputTokens += outputTokens; _session.SessionOutputTokens += outputTokens;
LastInputTokens = inputTokens; LastInputTokens = inputTokens;
RequestCount++; _session.RequestCount++;
} }
public void Reset() public void Reset()
{ {
SessionInputTokens = 0; _session.SessionInputTokens = 0;
SessionOutputTokens = 0; _session.SessionOutputTokens = 0;
RequestCount = 0; _session.RequestCount = 0;
LastInputTokens = 0; LastInputTokens = 0;
} }

View File

@@ -1,198 +1,193 @@
using System.ClientModel; using System.ClientModel;
using System.Reflection; using System.Reflection;
using AnchorCli.Providers; using AnchorCli.Providers;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using OpenAI; using OpenAI;
using AnchorCli; using AnchorCli;
using AnchorCli.Tools; using AnchorCli.Tools;
using AnchorCli.Commands; using AnchorCli.Commands;
using AnchorCli.OpenRouter; using AnchorCli.OpenRouter;
using Spectre.Console; using Spectre.Console;
// ── Setup subcommand ───────────────────────────────────────────────────── // ── Setup subcommand ─────────────────────────────────────────────────────
if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase)) if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCase))
{ {
SetupTui.Run(); SetupTui.Run();
return; return;
} }
// ── Config ────────────────────────────────────────────────────────────── // ── Config ──────────────────────────────────────────────────────────────
var cfg = AnchorConfig.Load(); var cfg = AnchorConfig.Load();
string apiKey = cfg.ApiKey; string apiKey = cfg.ApiKey;
string model = cfg.Model; string model = cfg.Model;
string provider = cfg.Provider ?? "openrouter"; string provider = cfg.Provider ?? "openrouter";
string endpoint = cfg.Endpoint ?? "https://openrouter.ai/api/v1"; string endpoint = cfg.Endpoint ?? "https://openrouter.ai/api/v1";
if (string.IsNullOrWhiteSpace(apiKey)) if (string.IsNullOrWhiteSpace(apiKey))
{ {
AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]"); AnsiConsole.MarkupLine("[red]No API key configured. Run [bold]anchor setup[/] first.[/]");
return; return;
} }
// ── Create token extractor for this provider ─────────────────────────── // ── Create token extractor for this provider ───────────────────────────
var tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(endpoint); var tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(endpoint);
var tokenTracker = new TokenTracker { Provider = tokenExtractor.ProviderName };
// ── Fetch model pricing (only for supported providers) ─────────────────
// ── Fetch model pricing (only for supported providers) ───────────────── ModelInfo? modelInfo = null;
ModelInfo? modelInfo = null; TokenTracker? tokenTracker = null;
if (ProviderFactory.IsOpenRouter(endpoint)) if (ProviderFactory.IsOpenRouter(endpoint))
{ {
await AnsiConsole.Status() await AnsiConsole.Status()
.Spinner(Spinner.Known.BouncingBar) .Spinner(Spinner.Known.BouncingBar)
.SpinnerStyle(Style.Parse("cornflowerblue")) .SpinnerStyle(Style.Parse("cornflowerblue"))
.StartAsync("Fetching model pricing...", async ctx => .StartAsync("Fetching model pricing...", async ctx =>
{ {
try try
{ {
var pricingProvider = new OpenRouterProvider(); var pricingProvider = new OpenRouterProvider();
modelInfo = await pricingProvider.GetModelInfoAsync(model); modelInfo = await pricingProvider.GetModelInfoAsync(model);
if (modelInfo?.Pricing != null) }
{ catch { /* pricing is best-effort */ }
tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt); });
tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion); }
tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request);
}
} // ── Pretty header ───────────────────────────────────────────────────────
catch { /* pricing is best-effort */ } AnsiConsole.Write(
}); new FigletText("anchor")
} .Color(Color.CornflowerBlue));
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
// ── Pretty header ───────────────────────────────────────────────────────
AnsiConsole.Write( // ── Pretty header ───────────────────────────────────────────────────────
new FigletText("anchor")
.Color(Color.CornflowerBlue)); AnsiConsole.Write(
new Rule("[dim]AI-powered coding assistant[/]")
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; .RuleStyle(Style.Parse("cornflowerblue dim"))
.LeftJustified());
AnsiConsole.Write(
new Rule("[dim]AI-powered coding assistant[/]") AnsiConsole.WriteLine();
.RuleStyle(Style.Parse("cornflowerblue dim"))
.LeftJustified()); var infoTable = new Table()
// ── Pretty header ─────────────────────────────────────────────────────── .Border(TableBorder.Rounded)
.BorderColor(Color.Grey)
AnsiConsole.Write( .AddColumn(new TableColumn("[dim]Setting[/]").NoWrap())
new Rule("[dim]AI-powered coding assistant[/]") .AddColumn(new TableColumn("[dim]Value[/]"));
.RuleStyle(Style.Parse("cornflowerblue dim"))
.LeftJustified()); infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]");
infoTable.AddRow("[grey]Provider[/]", $"[blue]{tokenExtractor.ProviderName}[/]");
AnsiConsole.WriteLine(); infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]");
infoTable.AddRow("[grey]Version[/]", $"[magenta]{version}[/]");
var infoTable = new Table()
.Border(TableBorder.Rounded) if (modelInfo?.Pricing != null && tokenTracker != null)
.BorderColor(Color.Grey)
.AddColumn(new TableColumn("[dim]Setting[/]").NoWrap()) {
.AddColumn(new TableColumn("[dim]Value[/]")); var inM = tokenTracker.InputPrice * 1_000_000m;
var outM = tokenTracker.OutputPrice * 1_000_000m;
infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]"); infoTable.AddRow("[grey]Pricing[/]",
infoTable.AddRow("[grey]Provider[/]", $"[blue]{tokenExtractor.ProviderName}[/]"); $"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]"); }
infoTable.AddRow("[grey]Version[/]", $"[magenta]{version}[/]"); if (modelInfo != null)
{
if (modelInfo?.Pricing != null) infoTable.AddRow("[grey]Context[/]",
$"[dim]{modelInfo.ContextLength:N0} tokens[/]");
{ }
var inM = tokenTracker.InputPrice * 1_000_000m;
var outM = tokenTracker.OutputPrice * 1_000_000m; AnsiConsole.Write(infoTable);
infoTable.AddRow("[grey]Pricing[/]", AnsiConsole.WriteLine();
$"[yellow]${inM:F2}[/][dim]/M in[/] [yellow]${outM:F2}[/][dim]/M out[/]");
} // ── Build the chat client with tool-calling support ─────────────────────
if (modelInfo != null) var httpClient = new HttpClient();
{ OpenRouterHeaders.ApplyTo(httpClient);
infoTable.AddRow("[grey]Context[/]",
$"[dim]{modelInfo.ContextLength:N0} tokens[/]"); var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions
} {
Endpoint = new Uri(endpoint),
AnsiConsole.Write(infoTable); Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient)
AnsiConsole.WriteLine(); });
// ── Build the chat client with tool-calling support ───────────────────── IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient();
var httpClient = new HttpClient();
OpenRouterHeaders.ApplyTo(httpClient); // ── Tool call logging via Spectre ───────────────────────────────────────
object consoleLock = new();
var openAiClient = new OpenAIClient(new ApiKeyCredential(apiKey), new OpenAIClientOptions
{ void ToolLog(string message)
Endpoint = new Uri(endpoint), {
Transport = new System.ClientModel.Primitives.HttpClientPipelineTransport(httpClient) lock (consoleLock)
}); {
Console.Write("\r" + new string(' ', 40) + "\r");
IChatClient innerClient = openAiClient.GetChatClient(model).AsIChatClient(); AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]");
}
// ── Tool call logging via Spectre ─────────────────────────────────────── }
object consoleLock = new();
CommandTool.Log =
void ToolLog(string message) DirTools.Log =
{ FileTools.Log =
lock (consoleLock) EditTools.Log = ToolLog;
{
Console.Write("\r" + new string(' ', 40) + "\r"); // ── Instantiate Core Components ──────────────────────────────────────────
AnsiConsole.MarkupLine($"[dim grey] ● {Markup.Escape(message)}[/]");
} var session = new ChatSession(innerClient);
} tokenTracker = new TokenTracker(session) { Provider = tokenExtractor.ProviderName };
if (modelInfo != null)
CommandTool.Log = {
DirTools.Log = if (modelInfo.Pricing != null)
FileTools.Log = {
EditTools.Log = ToolLog; tokenTracker.InputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Prompt);
tokenTracker.OutputPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Completion);
// ── Instantiate Core Components ────────────────────────────────────────── tokenTracker.RequestPrice = PricingProvider.ParsePrice(modelInfo.Pricing.Request);
}
var session = new ChatSession(innerClient); tokenTracker.ContextLength = modelInfo.ContextLength;
if (modelInfo != null) }
{
tokenTracker.ContextLength = modelInfo.ContextLength; var commandRegistry = new CommandRegistry();
} commandRegistry.Register(new ExitCommand());
commandRegistry.Register(new HelpCommand(commandRegistry));
var commandRegistry = new CommandRegistry(); commandRegistry.Register(new ClearCommand());
commandRegistry.Register(new ExitCommand()); commandRegistry.Register(new StatusCommand(model, endpoint));
commandRegistry.Register(new HelpCommand(commandRegistry)); commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
commandRegistry.Register(new ClearCommand()); commandRegistry.Register(new SetupCommand());
commandRegistry.Register(new StatusCommand(model, endpoint)); commandRegistry.Register(new ResetCommand(session, tokenTracker));
commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
commandRegistry.Register(new SetupCommand());
commandRegistry.Register(new ResetCommand(session, tokenTracker)); var commandDispatcher = new CommandDispatcher(commandRegistry);
commandRegistry.Register(new SaveCommand(session));
commandRegistry.Register(new LoadCommand(session)); // ── Run Repl ────────────────────────────────────────────────────────────
// Auto-load session if it exists
var commandDispatcher = new CommandDispatcher(commandRegistry); const string sessionPath = ".anchor/session.json";
if (File.Exists(sessionPath))
// ── Run Repl ──────────────────────────────────────────────────────────── {
try
// Auto-load session if it exists {
const string sessionPath = ".anchor/session.json"; await session.LoadAsync(sessionPath, default);
if (File.Exists(sessionPath)) AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]");
{
try // Print the last message if there is one
{ if (session.History.Count > 1)
await session.LoadAsync(sessionPath, default); {
AnsiConsole.MarkupLine($"[dim grey]Auto-loaded previous session.[/]"); var lastMessage = session.History[^1];
var preview = lastMessage.Text.Length > 280
// Print the last message if there is one ? lastMessage.Text[..277] + "..."
if (session.History.Count > 1) : lastMessage.Text;
{ AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]");
var lastMessage = session.History[^1]; }
var preview = lastMessage.Text.Length > 280 }
? lastMessage.Text[..277] + "..." catch { /* Ignore load errors on startup */ }
: lastMessage.Text; }
AnsiConsole.MarkupLine($"[dim grey] Last message: {Markup.Escape(preview)}[/]");
} var repl = new ReplLoop(session, tokenTracker, commandDispatcher);
} await repl.RunAsync();
catch { /* Ignore load errors on startup */ }
} // Auto-save session on clean exit
try
var repl = new ReplLoop(session, tokenTracker, commandDispatcher); {
await repl.RunAsync(); var directory = Path.GetDirectoryName(sessionPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
// Auto-save session on clean exit {
try Directory.CreateDirectory(directory);
{ }
var directory = Path.GetDirectoryName(sessionPath); await session.SaveAsync(sessionPath, default);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) }
{ catch { /* Ignore save errors on exit */ }
Directory.CreateDirectory(directory);
}
await session.SaveAsync(sessionPath, default);
}
catch { /* Ignore save errors on exit */ }

View File

@@ -131,8 +131,6 @@ AnchorCli/
│ ├── CompactCommand.cs # /compact command │ ├── CompactCommand.cs # /compact command
│ ├── ResetCommand.cs # /reset command │ ├── ResetCommand.cs # /reset command
│ ├── SetupCommand.cs # /setup command │ ├── SetupCommand.cs # /setup command
│ ├── LoadCommand.cs # /load command
│ └── SaveCommand.cs # /save command
└── OpenRouter/ └── OpenRouter/
└── PricingProvider.cs # Fetch model pricing from OpenRouter └── PricingProvider.cs # Fetch model pricing from OpenRouter
``` ```