feat: implement Stripe product configuration and add token-based input validation using Microsoft.ML.Tokenizers
This commit is contained in:
@@ -6,7 +6,12 @@ public class AiSettings
|
||||
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public string Model { get; set; } = "gemini-1.5-flash";
|
||||
public int MaxInputLength { get; set; } = 15000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of tokens allowed for input.
|
||||
/// </summary>
|
||||
public int MaxInputTokens { get; set; } = 15000;
|
||||
|
||||
public int MaxOutputTokens { get; set; } = 1000;
|
||||
public int RetryAttempts { get; set; } = 3;
|
||||
public double Temperature { get; set; } = 0.1;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace NexusReader.Infrastructure.Configuration;
|
||||
|
||||
public record StripeSettings
|
||||
{
|
||||
public const string SectionName = "Stripe";
|
||||
public string ProProductId { get; init; } = string.Empty;
|
||||
public string BasicProductId { get; init; } = string.Empty;
|
||||
public string FreeProductId { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ public static class DependencyInjection
|
||||
}
|
||||
|
||||
services.Configure<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
||||
|
||||
Console.WriteLine($"[Infrastructure] AI Configured: Model={aiSettings.Model}, KeyPresent={!string.IsNullOrWhiteSpace(aiSettings.ApiKey) && aiSettings.ApiKey != "PLACEHOLDER"}");
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Resilience" Version="10.5.0" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" Version="2.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Polly" Version="8.6.6" />
|
||||
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Domain.Entities;
|
||||
using NexusReader.Infrastructure.Configuration;
|
||||
using NexusReader.Infrastructure.Persistence;
|
||||
|
||||
namespace NexusReader.Infrastructure.Services;
|
||||
@@ -10,44 +13,83 @@ public class BillingService : IBillingService
|
||||
{
|
||||
private readonly AppDbContext _dbContext;
|
||||
private readonly UserManager<NexusUser> _userManager;
|
||||
private readonly StripeSettings _stripeSettings;
|
||||
private readonly ILogger<BillingService> _logger;
|
||||
|
||||
public BillingService(AppDbContext dbContext, UserManager<NexusUser> userManager)
|
||||
public BillingService(
|
||||
AppDbContext dbContext,
|
||||
UserManager<NexusUser> userManager,
|
||||
IOptions<StripeSettings> stripeSettings,
|
||||
ILogger<BillingService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_userManager = userManager;
|
||||
_stripeSettings = stripeSettings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||
if (user == null) return false;
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to update subscription for non-existent user: {Email}", customerEmail);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Map Stripe Product IDs to Nexus Plans
|
||||
// These IDs would typically come from configuration
|
||||
if (stripeProductId.Contains("pro"))
|
||||
if (stripeProductId == _stripeSettings.ProProductId)
|
||||
{
|
||||
user.CurrentPlan = "Pro";
|
||||
user.AITokenLimit = 50000;
|
||||
}
|
||||
else if (stripeProductId.Contains("basic"))
|
||||
else if (stripeProductId == _stripeSettings.BasicProductId)
|
||||
{
|
||||
user.CurrentPlan = "Basic";
|
||||
user.AITokenLimit = 10000;
|
||||
}
|
||||
else if (stripeProductId == _stripeSettings.FreeProductId || string.IsNullOrEmpty(stripeProductId))
|
||||
{
|
||||
user.CurrentPlan = "Free";
|
||||
user.AITokenLimit = 1000;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Unrecognized Stripe Product ID: {ProductId} for user {Email}. Falling back to Free tier.", stripeProductId, customerEmail);
|
||||
user.CurrentPlan = "Free";
|
||||
user.AITokenLimit = 1000;
|
||||
}
|
||||
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogError("Failed to update user {Email} after subscription change: {Errors}",
|
||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
return false;
|
||||
}
|
||||
|
||||
await _userManager.UpdateAsync(user);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> HandleSubscriptionDeletedAsync(string customerEmail)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
||||
if (user == null) return false;
|
||||
if (user == null)
|
||||
{
|
||||
_logger.LogWarning("Attempted to delete subscription for non-existent user: {Email}", customerEmail);
|
||||
return false;
|
||||
}
|
||||
|
||||
user.CurrentPlan = "Free";
|
||||
user.AITokenLimit = 1000; // Reset to free limit
|
||||
|
||||
await _userManager.UpdateAsync(user);
|
||||
var result = await _userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
_logger.LogError("Failed to reset user {Email} to Free tier after subscription deletion: {Errors}",
|
||||
customerEmail, string.Join(", ", result.Errors.Select(e => e.Description)));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using FluentResults;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.ML.Tokenizers;
|
||||
using NexusReader.Application.Abstractions.Services;
|
||||
using NexusReader.Application.DTOs.AI;
|
||||
using NexusReader.Domain.Entities;
|
||||
@@ -20,6 +21,7 @@ public class KnowledgeService : IKnowledgeService
|
||||
private readonly AppDbContext _dbContext;
|
||||
private readonly ResiliencePipeline _retryPipeline;
|
||||
private readonly AiSettings _settings;
|
||||
private readonly Tokenizer _tokenizer;
|
||||
private const string PromptVersion = "1.0";
|
||||
|
||||
public KnowledgeService(
|
||||
@@ -32,6 +34,9 @@ public class KnowledgeService : IKnowledgeService
|
||||
_dbContext = dbContext;
|
||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||
_settings = settings.Value;
|
||||
// Use Tiktoken (cl100k_base) which is a standard for modern LLMs and provides
|
||||
// a very reliable estimation for token usage in Gemini-based workloads.
|
||||
_tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
|
||||
}
|
||||
|
||||
public async Task<Result<KnowledgePacket>> GetKnowledgeAsync(string text, CancellationToken cancellationToken = default)
|
||||
@@ -59,10 +64,11 @@ public class KnowledgeService : IKnowledgeService
|
||||
Console.WriteLine($"[KnowledgeService] Starting extraction ({cacheSuffix}) for text sample: {text.Substring(0, Math.Min(text.Length, 50))}...");
|
||||
|
||||
var normalizedText = ContentHasher.Normalize(text);
|
||||
if (normalizedText.Length > _settings.MaxInputLength)
|
||||
|
||||
var tokenCount = EstimateTokenCount(normalizedText);
|
||||
if (tokenCount > _settings.MaxInputTokens)
|
||||
{
|
||||
normalizedText = normalizedText.Substring(0, _settings.MaxInputLength);
|
||||
Console.WriteLine($"[KnowledgeService] WARNING: Input text truncated to {_settings.MaxInputLength} chars.");
|
||||
return Result.Fail($"Input exceeds maximum token limit. Estimated tokens: {tokenCount}, limit: {_settings.MaxInputTokens}.");
|
||||
}
|
||||
|
||||
var hash = ContentHasher.ComputeHash(normalizedText) + "_" + cacheSuffix;
|
||||
@@ -151,4 +157,10 @@ public class KnowledgeService : IKnowledgeService
|
||||
return Result.Fail($"Failed to clear cache: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private int EstimateTokenCount(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return 0;
|
||||
return _tokenizer.CountTokens(text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
{
|
||||
"Stripe": {
|
||||
"ApiKey": "sk_test_placeholder",
|
||||
"WebhookSecret": "whsec_placeholder"
|
||||
"WebhookSecret": "whsec_placeholder",
|
||||
"ProProductId": "prod_Pro123",
|
||||
"BasicProductId": "prod_Basic456",
|
||||
"FreeProductId": "prod_Free789"
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
@@ -24,6 +27,7 @@
|
||||
"Google": {
|
||||
"ApiKey": "PLACEHOLDER",
|
||||
"Model": "gemini-2.5-flash-lite",
|
||||
"MaxInputTokens": 15000,
|
||||
"MaxOutputTokens": 8192
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user