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 ApiKey { get; set; } = string.Empty;
|
||||||
public string Model { get; set; } = "gemini-1.5-flash";
|
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 MaxOutputTokens { get; set; } = 1000;
|
||||||
public int RetryAttempts { get; set; } = 3;
|
public int RetryAttempts { get; set; } = 3;
|
||||||
public double Temperature { get; set; } = 0.1;
|
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<AiSettings>(configuration.GetSection(AiSettings.SectionName));
|
||||||
|
services.Configure<StripeSettings>(configuration.GetSection(StripeSettings.SectionName));
|
||||||
var aiSettings = configuration.GetSection(AiSettings.SectionName).Get<AiSettings>() ?? new AiSettings();
|
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"}");
|
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.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||||
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
<PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Resilience" 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="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||||
<PackageReference Include="Polly" Version="8.6.6" />
|
<PackageReference Include="Polly" Version="8.6.6" />
|
||||||
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
<PackageReference Include="Polly.Extensions.Http" Version="3.0.0" />
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
|
using NexusReader.Infrastructure.Configuration;
|
||||||
using NexusReader.Infrastructure.Persistence;
|
using NexusReader.Infrastructure.Persistence;
|
||||||
|
|
||||||
namespace NexusReader.Infrastructure.Services;
|
namespace NexusReader.Infrastructure.Services;
|
||||||
@@ -10,44 +13,83 @@ public class BillingService : IBillingService
|
|||||||
{
|
{
|
||||||
private readonly AppDbContext _dbContext;
|
private readonly AppDbContext _dbContext;
|
||||||
private readonly UserManager<NexusUser> _userManager;
|
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;
|
_dbContext = dbContext;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
|
_stripeSettings = stripeSettings.Value;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
public async Task<bool> HandleSubscriptionUpdatedAsync(string customerEmail, string stripeProductId)
|
||||||
{
|
{
|
||||||
var user = await _userManager.FindByEmailAsync(customerEmail);
|
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
|
if (stripeProductId == _stripeSettings.ProProductId)
|
||||||
// These IDs would typically come from configuration
|
|
||||||
if (stripeProductId.Contains("pro"))
|
|
||||||
{
|
{
|
||||||
user.CurrentPlan = "Pro";
|
user.CurrentPlan = "Pro";
|
||||||
user.AITokenLimit = 50000;
|
user.AITokenLimit = 50000;
|
||||||
}
|
}
|
||||||
else if (stripeProductId.Contains("basic"))
|
else if (stripeProductId == _stripeSettings.BasicProductId)
|
||||||
{
|
{
|
||||||
user.CurrentPlan = "Basic";
|
user.CurrentPlan = "Basic";
|
||||||
user.AITokenLimit = 10000;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> HandleSubscriptionDeletedAsync(string customerEmail)
|
public async Task<bool> HandleSubscriptionDeletedAsync(string customerEmail)
|
||||||
{
|
{
|
||||||
var user = await _userManager.FindByEmailAsync(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.CurrentPlan = "Free";
|
||||||
user.AITokenLimit = 1000; // Reset to free limit
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using FluentResults;
|
using FluentResults;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
using Microsoft.ML.Tokenizers;
|
||||||
using NexusReader.Application.Abstractions.Services;
|
using NexusReader.Application.Abstractions.Services;
|
||||||
using NexusReader.Application.DTOs.AI;
|
using NexusReader.Application.DTOs.AI;
|
||||||
using NexusReader.Domain.Entities;
|
using NexusReader.Domain.Entities;
|
||||||
@@ -20,6 +21,7 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
private readonly AppDbContext _dbContext;
|
private readonly AppDbContext _dbContext;
|
||||||
private readonly ResiliencePipeline _retryPipeline;
|
private readonly ResiliencePipeline _retryPipeline;
|
||||||
private readonly AiSettings _settings;
|
private readonly AiSettings _settings;
|
||||||
|
private readonly Tokenizer _tokenizer;
|
||||||
private const string PromptVersion = "1.0";
|
private const string PromptVersion = "1.0";
|
||||||
|
|
||||||
public KnowledgeService(
|
public KnowledgeService(
|
||||||
@@ -32,6 +34,9 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
_dbContext = dbContext;
|
_dbContext = dbContext;
|
||||||
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
_retryPipeline = pipelineProvider.GetPipeline("ai-retry");
|
||||||
_settings = settings.Value;
|
_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)
|
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))}...");
|
Console.WriteLine($"[KnowledgeService] Starting extraction ({cacheSuffix}) for text sample: {text.Substring(0, Math.Min(text.Length, 50))}...");
|
||||||
|
|
||||||
var normalizedText = ContentHasher.Normalize(text);
|
var normalizedText = ContentHasher.Normalize(text);
|
||||||
if (normalizedText.Length > _settings.MaxInputLength)
|
|
||||||
|
var tokenCount = EstimateTokenCount(normalizedText);
|
||||||
|
if (tokenCount > _settings.MaxInputTokens)
|
||||||
{
|
{
|
||||||
normalizedText = normalizedText.Substring(0, _settings.MaxInputLength);
|
return Result.Fail($"Input exceeds maximum token limit. Estimated tokens: {tokenCount}, limit: {_settings.MaxInputTokens}.");
|
||||||
Console.WriteLine($"[KnowledgeService] WARNING: Input text truncated to {_settings.MaxInputLength} chars.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var hash = ContentHasher.ComputeHash(normalizedText) + "_" + cacheSuffix;
|
var hash = ContentHasher.ComputeHash(normalizedText) + "_" + cacheSuffix;
|
||||||
@@ -151,4 +157,10 @@ public class KnowledgeService : IKnowledgeService
|
|||||||
return Result.Fail($"Failed to clear cache: {ex.Message}");
|
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": {
|
"Stripe": {
|
||||||
"ApiKey": "sk_test_placeholder",
|
"ApiKey": "sk_test_placeholder",
|
||||||
"WebhookSecret": "whsec_placeholder"
|
"WebhookSecret": "whsec_placeholder",
|
||||||
|
"ProProductId": "prod_Pro123",
|
||||||
|
"BasicProductId": "prod_Basic456",
|
||||||
|
"FreeProductId": "prod_Free789"
|
||||||
},
|
},
|
||||||
"Logging": {
|
"Logging": {
|
||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"Google": {
|
"Google": {
|
||||||
"ApiKey": "PLACEHOLDER",
|
"ApiKey": "PLACEHOLDER",
|
||||||
"Model": "gemini-2.5-flash-lite",
|
"Model": "gemini-2.5-flash-lite",
|
||||||
|
"MaxInputTokens": 15000,
|
||||||
"MaxOutputTokens": 8192
|
"MaxOutputTokens": 8192
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user