feat: Introduce a pluggable LLM provider system with token extraction, pricing, and updated setup configuration.
This commit is contained in:
@@ -7,7 +7,8 @@ internal sealed class AnchorConfig
|
|||||||
{
|
{
|
||||||
public string ApiKey { get; set; } = "";
|
public string ApiKey { get; set; } = "";
|
||||||
public string Model { get; set; } = "qwen/qwen3.5-397b-a17b";
|
public string Model { get; set; } = "qwen/qwen3.5-397b-a17b";
|
||||||
|
public string Provider { get; set; } = "openrouter";
|
||||||
|
public string Endpoint { get; set; } = "https://openrouter.ai/api/v1";
|
||||||
// ── Persistence ──────────────────────────────────────────────────────
|
// ── Persistence ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static string ConfigPath =>
|
private static string ConfigPath =>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace AnchorCli.OpenRouter;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class TokenTracker
|
internal sealed class TokenTracker
|
||||||
{
|
{
|
||||||
|
public string Provider { get; set; } = "Unknown";
|
||||||
public long SessionInputTokens { get; private set; }
|
public long SessionInputTokens { get; private set; }
|
||||||
public long SessionOutputTokens { get; private set; }
|
public long SessionOutputTokens { get; private set; }
|
||||||
public int RequestCount { get; private set; }
|
public int RequestCount { get; private set; }
|
||||||
@@ -23,7 +24,6 @@ internal sealed class TokenTracker
|
|||||||
|
|
||||||
/// <summary>Fixed USD per API request.</summary>
|
/// <summary>Fixed USD per API request.</summary>
|
||||||
public decimal RequestPrice { get; set; }
|
public decimal RequestPrice { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Record usage from one response (may span multiple LLM rounds).
|
/// Record usage from one response (may span multiple LLM rounds).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
20
Program.cs
20
Program.cs
@@ -1,4 +1,5 @@
|
|||||||
using System.ClientModel;
|
using System.ClientModel;
|
||||||
|
using AnchorCli.Providers;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
using OpenAI;
|
using OpenAI;
|
||||||
using AnchorCli;
|
using AnchorCli;
|
||||||
@@ -15,10 +16,11 @@ if (args.Length > 0 && args[0].Equals("setup", StringComparison.OrdinalIgnoreCas
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Config ──────────────────────────────────────────────────────────────
|
// ── Config ──────────────────────────────────────────────────────────────
|
||||||
const string endpoint = "https://openrouter.ai/api/v1";
|
|
||||||
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 endpoint = cfg.Endpoint ?? "https://openrouter.ai/api/v1";
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(apiKey))
|
if (string.IsNullOrWhiteSpace(apiKey))
|
||||||
{
|
{
|
||||||
@@ -26,11 +28,14 @@ if (string.IsNullOrWhiteSpace(apiKey))
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Fetch model pricing from OpenRouter ─────────────────────────────────
|
// ── Create token extractor for this provider ───────────────────────────
|
||||||
var pricingProvider = new PricingProvider();
|
var tokenExtractor = ProviderFactory.CreateTokenExtractorForEndpoint(endpoint);
|
||||||
var tokenTracker = new TokenTracker();
|
var tokenTracker = new TokenTracker { Provider = tokenExtractor.ProviderName };
|
||||||
|
|
||||||
|
// ── Fetch model pricing (only for supported providers) ─────────────────
|
||||||
ModelInfo? modelInfo = null;
|
ModelInfo? modelInfo = null;
|
||||||
|
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"))
|
||||||
@@ -38,6 +43,7 @@ await AnsiConsole.Status()
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var pricingProvider = new OpenRouterProvider();
|
||||||
modelInfo = await pricingProvider.GetModelInfoAsync(model);
|
modelInfo = await pricingProvider.GetModelInfoAsync(model);
|
||||||
if (modelInfo?.Pricing != null)
|
if (modelInfo?.Pricing != null)
|
||||||
{
|
{
|
||||||
@@ -48,6 +54,7 @@ await AnsiConsole.Status()
|
|||||||
}
|
}
|
||||||
catch { /* pricing is best-effort */ }
|
catch { /* pricing is best-effort */ }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Pretty header ───────────────────────────────────────────────────────
|
// ── Pretty header ───────────────────────────────────────────────────────
|
||||||
AnsiConsole.Write(
|
AnsiConsole.Write(
|
||||||
@@ -68,9 +75,12 @@ var infoTable = new Table()
|
|||||||
.AddColumn(new TableColumn("[dim]Value[/]"));
|
.AddColumn(new TableColumn("[dim]Value[/]"));
|
||||||
|
|
||||||
infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]");
|
infoTable.AddRow("[grey]Model[/]", $"[cyan]{Markup.Escape(modelInfo?.Name ?? model)}[/]");
|
||||||
infoTable.AddRow("[grey]Endpoint[/]", $"[blue]OpenRouter[/]");
|
infoTable.AddRow("[grey]Provider[/]", $"[blue]{tokenExtractor.ProviderName}[/]");
|
||||||
|
infoTable.AddRow("[grey]Endpoint[/]", $"[dim]{endpoint}[/]");
|
||||||
infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]");
|
infoTable.AddRow("[grey]CWD[/]", $"[green]{Markup.Escape(Environment.CurrentDirectory)}[/]");
|
||||||
|
|
||||||
|
if (modelInfo?.Pricing != null)
|
||||||
|
|
||||||
if (modelInfo?.Pricing != null)
|
if (modelInfo?.Pricing != null)
|
||||||
{
|
{
|
||||||
var inM = tokenTracker.InputPrice * 1_000_000m;
|
var inM = tokenTracker.InputPrice * 1_000_000m;
|
||||||
|
|||||||
89
Providers/GenericTokenExtractor.cs
Normal file
89
Providers/GenericTokenExtractor.cs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generic token extractor for any OpenAI-compatible endpoint.
|
||||||
|
/// Tries common header names and JSON body parsing.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GenericTokenExtractor : ITokenExtractor
|
||||||
|
{
|
||||||
|
public string ProviderName => "Generic";
|
||||||
|
|
||||||
|
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
|
||||||
|
{
|
||||||
|
// Try various common header names
|
||||||
|
var headerNames = new[] {
|
||||||
|
"x-total-tokens",
|
||||||
|
"x-ai-response-tokens",
|
||||||
|
"x-tokens",
|
||||||
|
"x-prompt-tokens",
|
||||||
|
"x-completion-tokens"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var headerName in headerNames)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValues(headerName, out var values))
|
||||||
|
{
|
||||||
|
if (int.TryParse(values.FirstOrDefault(), out var tokens))
|
||||||
|
{
|
||||||
|
// Assume all tokens are output if we can't determine split
|
||||||
|
return (0, tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try parsing from response body JSON
|
||||||
|
if (!string.IsNullOrEmpty(responseBody))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(responseBody);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
// Try standard OpenAI format: usage.prompt_tokens, usage.completion_tokens
|
||||||
|
if (root.TryGetProperty("usage", out var usage))
|
||||||
|
{
|
||||||
|
var prompt = usage.TryGetProperty("prompt_tokens", out var p) ? p.GetInt32() : 0;
|
||||||
|
var completion = usage.TryGetProperty("completion_tokens", out var c) ? c.GetInt32() : 0;
|
||||||
|
|
||||||
|
if (prompt > 0 || completion > 0)
|
||||||
|
{
|
||||||
|
return (prompt, completion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Ignore parsing errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? ExtractLatency(HttpResponseHeaders headers)
|
||||||
|
{
|
||||||
|
// Try various common latency headers
|
||||||
|
var headerNames = new[] {
|
||||||
|
"x-response-time",
|
||||||
|
"x-response-timing",
|
||||||
|
"x-latency-ms",
|
||||||
|
"x-duration-ms"
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (var headerName in headerNames)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValues(headerName, out var values))
|
||||||
|
{
|
||||||
|
if (int.TryParse(values.FirstOrDefault(), out var latency))
|
||||||
|
{
|
||||||
|
return latency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
Providers/GroqProvider.cs
Normal file
61
Providers/GroqProvider.cs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token extractor for Groq responses.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GroqTokenExtractor : ITokenExtractor
|
||||||
|
{
|
||||||
|
public string ProviderName => "Groq";
|
||||||
|
|
||||||
|
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
|
||||||
|
{
|
||||||
|
// Groq provides x-groq-tokens header (format: "n;<prompt_tokens>,n;<completion_tokens>")
|
||||||
|
if (headers.TryGetValues("x-groq-tokens", out var values))
|
||||||
|
{
|
||||||
|
var tokenStr = values.FirstOrDefault();
|
||||||
|
if (!string.IsNullOrEmpty(tokenStr))
|
||||||
|
{
|
||||||
|
// Parse format: "n;123,n;45" where first is prompt, second is completion
|
||||||
|
var parts = tokenStr.Split(',');
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
var inputPart = parts[0].Trim();
|
||||||
|
var outputPart = parts[1].Trim();
|
||||||
|
|
||||||
|
// Extract numbers after "n;"
|
||||||
|
if (inputPart.StartsWith("n;") && outputPart.StartsWith("n;"))
|
||||||
|
{
|
||||||
|
if (int.TryParse(inputPart[2..], out var input) &&
|
||||||
|
int.TryParse(outputPart[2..], out var output))
|
||||||
|
{
|
||||||
|
return (input, output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try parsing from response body
|
||||||
|
if (!string.IsNullOrEmpty(responseBody))
|
||||||
|
{
|
||||||
|
// TODO: Parse usage from JSON body if headers aren't available
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? ExtractLatency(HttpResponseHeaders headers)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValues("x-groq-response-time", out var values))
|
||||||
|
{
|
||||||
|
if (int.TryParse(values.FirstOrDefault(), out var latency))
|
||||||
|
{
|
||||||
|
return latency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Providers/IPricingProvider.cs
Normal file
18
Providers/IPricingProvider.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for fetching model pricing information.
|
||||||
|
/// </summary>
|
||||||
|
internal interface IPricingProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches pricing info for a specific model.
|
||||||
|
/// </summary>
|
||||||
|
Task<ModelInfo?> GetModelInfoAsync(string modelId, CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches all available models with pricing.
|
||||||
|
/// </summary>
|
||||||
|
Task<Dictionary<string, ModelInfo>> GetAllModelsAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
25
Providers/ITokenExtractor.cs
Normal file
25
Providers/ITokenExtractor.cs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Interface for extracting token usage from provider responses.
|
||||||
|
/// </summary>
|
||||||
|
internal interface ITokenExtractor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts token usage from response headers and/or body.
|
||||||
|
/// Returns (inputTokens, outputTokens) or null if unavailable.
|
||||||
|
/// </summary>
|
||||||
|
(int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the latency from response headers (in ms).
|
||||||
|
/// </summary>
|
||||||
|
int? ExtractLatency(HttpResponseHeaders headers);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the provider name for display purposes.
|
||||||
|
/// </summary>
|
||||||
|
string ProviderName { get; }
|
||||||
|
}
|
||||||
39
Providers/OllamaTokenExtractor.cs
Normal file
39
Providers/OllamaTokenExtractor.cs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token extractor for Ollama responses.
|
||||||
|
/// Ollama doesn't provide official token counts, so we estimate.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OllamaTokenExtractor : ITokenExtractor
|
||||||
|
{
|
||||||
|
public string ProviderName => "Ollama";
|
||||||
|
|
||||||
|
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
|
||||||
|
{
|
||||||
|
// Ollama doesn't provide token headers
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? ExtractLatency(HttpResponseHeaders headers)
|
||||||
|
{
|
||||||
|
// Ollama doesn't provide latency headers
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Estimates token count from text length (rough approximation).
|
||||||
|
/// Assumes ~4 characters per token on average.
|
||||||
|
/// </summary>
|
||||||
|
public static int EstimateTokens(string text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(text))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rough estimate: 4 characters per token
|
||||||
|
return text.Length / 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Providers/OpenRouterProvider.cs
Normal file
40
Providers/OpenRouterProvider.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using AnchorCli.OpenRouter;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pricing provider for OpenRouter API.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OpenRouterProvider : IPricingProvider
|
||||||
|
{
|
||||||
|
private const string ModelsUrl = "https://openrouter.ai/api/v1/models";
|
||||||
|
private static readonly HttpClient Http = new();
|
||||||
|
private Dictionary<string, ModelInfo>? _models;
|
||||||
|
|
||||||
|
static OpenRouterProvider()
|
||||||
|
{
|
||||||
|
OpenRouterHeaders.ApplyTo(Http);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Dictionary<string, ModelInfo>> GetAllModelsAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (_models != null) return _models;
|
||||||
|
|
||||||
|
var response = await Http.GetAsync(ModelsUrl, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
var result = JsonSerializer.Deserialize(json, AppJsonContext.Default.ModelsResponse);
|
||||||
|
|
||||||
|
_models = result?.Data?.ToDictionary(m => m.Id) ?? [];
|
||||||
|
return _models;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ModelInfo?> GetModelInfoAsync(string modelId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var models = await GetAllModelsAsync(ct);
|
||||||
|
return models.GetValueOrDefault(modelId);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Providers/OpenRouterTokenExtractor.cs
Normal file
42
Providers/OpenRouterTokenExtractor.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Net.Http.Headers;
|
||||||
|
|
||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Token extractor for OpenRouter responses.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class OpenRouterTokenExtractor : ITokenExtractor
|
||||||
|
{
|
||||||
|
public string ProviderName => "OpenRouter";
|
||||||
|
|
||||||
|
public (int inputTokens, int outputTokens)? ExtractTokens(HttpResponseHeaders headers, string? responseBody)
|
||||||
|
{
|
||||||
|
// OpenRouter provides x-total-tokens header
|
||||||
|
if (headers.TryGetValues("x-total-tokens", out var values))
|
||||||
|
{
|
||||||
|
// Note: OpenRouter only provides total tokens, not split
|
||||||
|
// We'll estimate split based on typical ratios if needed
|
||||||
|
if (long.TryParse(values.FirstOrDefault(), out var total))
|
||||||
|
{
|
||||||
|
// For now, return total as output (placeholder until we have better splitting)
|
||||||
|
// In practice, you'd need to track input separately from the request
|
||||||
|
return (0, (int)total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? ExtractLatency(HttpResponseHeaders headers)
|
||||||
|
{
|
||||||
|
if (headers.TryGetValues("x-response-timing", out var values))
|
||||||
|
{
|
||||||
|
if (int.TryParse(values.FirstOrDefault(), out var latency))
|
||||||
|
{
|
||||||
|
return latency;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
Providers/ProviderFactory.cs
Normal file
70
Providers/ProviderFactory.cs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
namespace AnchorCli.Providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Factory for creating provider instances based on endpoint or provider name.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ProviderFactory
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a token extractor based on the provider name.
|
||||||
|
/// </summary>
|
||||||
|
public static ITokenExtractor CreateTokenExtractor(string providerName)
|
||||||
|
{
|
||||||
|
return providerName.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"openrouter" => new OpenRouterTokenExtractor(),
|
||||||
|
"groq" => new GroqTokenExtractor(),
|
||||||
|
"ollama" => new OllamaTokenExtractor(),
|
||||||
|
_ => new GenericTokenExtractor()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a token extractor by auto-detecting from the endpoint URL.
|
||||||
|
/// </summary>
|
||||||
|
public static ITokenExtractor CreateTokenExtractorForEndpoint(string endpoint)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(endpoint))
|
||||||
|
{
|
||||||
|
return new GenericTokenExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = endpoint.ToLowerInvariant();
|
||||||
|
|
||||||
|
if (url.Contains("openrouter"))
|
||||||
|
{
|
||||||
|
return new OpenRouterTokenExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.Contains("groq"))
|
||||||
|
{
|
||||||
|
return new GroqTokenExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.Contains("ollama") || url.Contains("localhost") || url.Contains("127.0.0.1"))
|
||||||
|
{
|
||||||
|
return new OllamaTokenExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GenericTokenExtractor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a pricing provider based on the provider name.
|
||||||
|
/// Only OpenRouter has a pricing API currently.
|
||||||
|
/// </summary>
|
||||||
|
public static IPricingProvider? CreatePricingProvider(string providerName)
|
||||||
|
{
|
||||||
|
return providerName.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"openrouter" => new OpenRouterProvider(),
|
||||||
|
_ => null // Other providers don't have pricing APIs yet
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines if an endpoint is OpenRouter.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsOpenRouter(string endpoint) =>
|
||||||
|
!string.IsNullOrEmpty(endpoint) && endpoint.Contains("openrouter", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
78
SetupTui.cs
78
SetupTui.cs
@@ -27,10 +27,85 @@ internal static class SetupTui
|
|||||||
|
|
||||||
AnsiConsole.WriteLine();
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
|
// ── Provider ────────────────────────────────────────────────────
|
||||||
|
var providers = new List<(string Value, string Description)>
|
||||||
|
{
|
||||||
|
("openrouter", "default, pricing support"),
|
||||||
|
("groq", "high-speed inference"),
|
||||||
|
("ollama", "local, no auth required"),
|
||||||
|
("openai", "official OpenAI API"),
|
||||||
|
("custom", "generic OpenAI-compatible endpoint")
|
||||||
|
};
|
||||||
|
|
||||||
|
string currentProvider = config.Provider ?? "openrouter";
|
||||||
|
AnsiConsole.MarkupLine($" Current provider: [cyan]{Markup.Escape(currentProvider)}[/]");
|
||||||
|
|
||||||
|
var selectedProviderChoice = AnsiConsole.Prompt(
|
||||||
|
new SelectionPrompt<(string Value, string Description)>()
|
||||||
|
.Title(" Select a provider:")
|
||||||
|
.UseConverter(p => p.Value + (string.IsNullOrEmpty(p.Description) ? "" : $" [dim]({p.Description})[/]"))
|
||||||
|
.AddChoices(providers));
|
||||||
|
|
||||||
|
config.Provider = selectedProviderChoice.Value;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if (config.Provider == "custom")
|
||||||
|
{
|
||||||
|
string customEndpoint = AnsiConsole.Prompt(
|
||||||
|
new TextPrompt<string>(" Enter endpoint URL:")
|
||||||
|
.DefaultValue(config.Endpoint)
|
||||||
|
.AllowEmpty());
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(customEndpoint))
|
||||||
|
{
|
||||||
|
config.Endpoint = customEndpoint.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
config.Endpoint = config.Provider.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"openrouter" => "https://openrouter.ai/api/v1",
|
||||||
|
"groq" => "https://api.groq.com/openai/v1",
|
||||||
|
"ollama" => "http://localhost:11434/v1",
|
||||||
|
"openai" => "https://api.openai.com/v1",
|
||||||
|
_ => config.Endpoint
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
AnsiConsole.WriteLine();
|
||||||
|
|
||||||
// ── Model ─────────────────────────────────────────────────────
|
// ── Model ─────────────────────────────────────────────────────
|
||||||
AnsiConsole.MarkupLine($" Current model: [cyan]{Markup.Escape(config.Model)}[/]");
|
AnsiConsole.MarkupLine($" Current model: [cyan]{Markup.Escape(config.Model)}[/]");
|
||||||
|
|
||||||
var models = new List<(string Value, string Description)>
|
var models = config.Provider.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"groq" => new List<(string Value, string Description)>
|
||||||
|
{
|
||||||
|
("llama-3.3-70b-versatile", "fast, powerful"),
|
||||||
|
("llama-3.1-8b-instant", "very fast"),
|
||||||
|
("mixtral-8x7b-32768", "sparse MoE"),
|
||||||
|
("gemma2-9b-it", "Google's Gemma"),
|
||||||
|
("Custom...", "")
|
||||||
|
},
|
||||||
|
"ollama" => new List<(string Value, string Description)>
|
||||||
|
{
|
||||||
|
("llama3.2", "Meta's Llama 3.2"),
|
||||||
|
("qwen2.5", "Alibaba Qwen"),
|
||||||
|
("mistral", "Mistral AI"),
|
||||||
|
("codellama", "code-focused"),
|
||||||
|
("Custom...", "")
|
||||||
|
},
|
||||||
|
"openai" => new List<(string Value, string Description)>
|
||||||
|
{
|
||||||
|
("gpt-4o", "most capable"),
|
||||||
|
("gpt-4o-mini", "fast, affordable"),
|
||||||
|
("o1-preview", "reasoning model"),
|
||||||
|
("Custom...", "")
|
||||||
|
},
|
||||||
|
_ => new List<(string Value, string Description)>
|
||||||
{
|
{
|
||||||
("qwen/qwen3.5-397b-a17b", "smart, expensive"),
|
("qwen/qwen3.5-397b-a17b", "smart, expensive"),
|
||||||
("qwen/qwen3.5-122b-a10b", "faster"),
|
("qwen/qwen3.5-122b-a10b", "faster"),
|
||||||
@@ -38,6 +113,7 @@ internal static class SetupTui
|
|||||||
("qwen/qwen3.5-flash-02-23", "cloud, fast"),
|
("qwen/qwen3.5-flash-02-23", "cloud, fast"),
|
||||||
("qwen/qwen3.5-plus-02-15", "cloud, smart"),
|
("qwen/qwen3.5-plus-02-15", "cloud, smart"),
|
||||||
("Custom...", "")
|
("Custom...", "")
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
string selectedModel = AnsiConsole.Prompt(
|
string selectedModel = AnsiConsole.Prompt(
|
||||||
|
|||||||
Reference in New Issue
Block a user