feat: saving token data to sessions
This commit is contained in:
@@ -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)]
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)}[/]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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)}[/]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
23
Program.cs
23
Program.cs
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user