feat: saving token data to sessions
This commit is contained in:
@@ -1,28 +1,29 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using AnchorCli.OpenRouter;
|
||||
using AnchorCli.Tools;
|
||||
|
||||
namespace AnchorCli;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON serializer context for Native AOT compatibility.
|
||||
/// Covers all parameter / return types used by AIFunction tool methods
|
||||
/// and the OpenRouter models API.
|
||||
/// </summary>
|
||||
[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<Microsoft.Extensions.AI.ChatMessage>))]
|
||||
[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;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON serializer context for Native AOT compatibility.
|
||||
/// Covers all parameter / return types used by AIFunction tool methods
|
||||
/// and the OpenRouter models API.
|
||||
/// </summary>
|
||||
[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<Microsoft.Extensions.AI.ChatMessage>))]
|
||||
[JsonSerializable(typeof(AnchorConfig))]
|
||||
[JsonSerializable(typeof(BatchOperation))]
|
||||
[JsonSerializable(typeof(BatchOperation[]))]
|
||||
[JsonSerializable(typeof(TokenMetadata))]
|
||||
[JsonSourceGenerationOptions(
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
internal partial class AppJsonContext : JsonSerializerContext;
|
||||
|
||||
@@ -8,6 +8,11 @@ internal sealed class ChatSession
|
||||
private readonly IChatClient _agent;
|
||||
public ContextCompactor Compactor { 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)
|
||||
{
|
||||
@@ -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<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>
|
||||
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;
|
||||
|
||||
/// <summary>Maximum context window for the model (tokens). 0 = unknown.</summary>
|
||||
public int ContextLength { get; set; }
|
||||
@@ -29,16 +36,16 @@ internal sealed class TokenTracker
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
391
Program.cs
391
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 */ }
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user