feat: implement Stripe product configuration and add token-based input validation using Microsoft.ML.Tokenizers

This commit is contained in:
2026-05-02 10:31:28 +02:00
parent 0ed89ef5a4
commit e5611758f1
7 changed files with 90 additions and 15 deletions
@@ -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);
}
}
+5 -1
View File
@@ -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
}
},