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

@@ -22,6 +22,7 @@ namespace AnchorCli;
[JsonSerializable(typeof(AnchorConfig))] [JsonSerializable(typeof(AnchorConfig))]
[JsonSerializable(typeof(BatchOperation))] [JsonSerializable(typeof(BatchOperation))]
[JsonSerializable(typeof(BatchOperation[]))] [JsonSerializable(typeof(BatchOperation[]))]
[JsonSerializable(typeof(TokenMetadata))]
[JsonSourceGenerationOptions( [JsonSourceGenerationOptions(
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]

View File

@@ -9,6 +9,11 @@ internal sealed class ChatSession
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)
{ {
Compactor = new ContextCompactor(innerClient); Compactor = new ContextCompactor(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

@@ -31,10 +31,10 @@ if (string.IsNullOrWhiteSpace(apiKey))
// ── 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()
@@ -46,12 +46,6 @@ if (ProviderFactory.IsOpenRouter(endpoint))
{ {
var pricingProvider = new OpenRouterProvider(); var pricingProvider = new OpenRouterProvider();
modelInfo = await pricingProvider.GetModelInfoAsync(model); 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 */ } catch { /* pricing is best-effort */ }
}); });
@@ -65,10 +59,6 @@ AnsiConsole.Write(
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; 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 ─────────────────────────────────────────────────────── // ── Pretty header ───────────────────────────────────────────────────────
AnsiConsole.Write( AnsiConsole.Write(
@@ -89,7 +79,7 @@ infoTable.AddRow("[grey]Provider[/]", $"[blue]{tokenExtractor.ProviderName}[/]")
infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]"); infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]");
infoTable.AddRow("[grey]Version[/]", $"[magenta]{version}[/]"); infoTable.AddRow("[grey]Version[/]", $"[magenta]{version}[/]");
if (modelInfo?.Pricing != null) if (modelInfo?.Pricing != null && tokenTracker != null)
{ {
var inM = tokenTracker.InputPrice * 1_000_000m; var inM = tokenTracker.InputPrice * 1_000_000m;
@@ -138,8 +128,15 @@ EditTools.Log = ToolLog;
// ── Instantiate Core Components ────────────────────────────────────────── // ── Instantiate Core Components ──────────────────────────────────────────
var session = new ChatSession(innerClient); var session = new ChatSession(innerClient);
tokenTracker = new TokenTracker(session) { Provider = tokenExtractor.ProviderName };
if (modelInfo != null) 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; tokenTracker.ContextLength = modelInfo.ContextLength;
} }
@@ -151,8 +148,6 @@ commandRegistry.Register(new StatusCommand(model, endpoint));
commandRegistry.Register(new CompactCommand(session.Compactor, session.History)); commandRegistry.Register(new CompactCommand(session.Compactor, session.History));
commandRegistry.Register(new SetupCommand()); commandRegistry.Register(new SetupCommand());
commandRegistry.Register(new ResetCommand(session, tokenTracker)); commandRegistry.Register(new ResetCommand(session, tokenTracker));
commandRegistry.Register(new SaveCommand(session));
commandRegistry.Register(new LoadCommand(session));
var commandDispatcher = new CommandDispatcher(commandRegistry); var commandDispatcher = new CommandDispatcher(commandRegistry);

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
``` ```