diff --git a/DysonNetwork.Insight/Controllers/BillingController.cs b/DysonNetwork.Insight/Controllers/BillingController.cs new file mode 100644 index 0000000..8b0f7ed --- /dev/null +++ b/DysonNetwork.Insight/Controllers/BillingController.cs @@ -0,0 +1,21 @@ +using DysonNetwork.Insight.Thought; +using DysonNetwork.Shared.Auth; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; + +namespace DysonNetwork.Insight.Controllers; + +[ApiController] +[Route("/api/billing")] +public class BillingController(ThoughtService thoughtService, ILogger logger) : ControllerBase +{ + [HttpPost("settle")] + [Authorize] + [RequiredPermission("maintenance", "insight.billing.settle")] + public async Task ProcessTokenBilling() + { + await thoughtService.SettleThoughtBills(logger); + return Ok(); + } +} diff --git a/DysonNetwork.Insight/DysonNetwork.Insight.csproj b/DysonNetwork.Insight/DysonNetwork.Insight.csproj index 86dc20e..6469981 100644 --- a/DysonNetwork.Insight/DysonNetwork.Insight.csproj +++ b/DysonNetwork.Insight/DysonNetwork.Insight.csproj @@ -18,6 +18,9 @@ + + + diff --git a/DysonNetwork.Insight/Migrations/20251026134218_AddBilling.Designer.cs b/DysonNetwork.Insight/Migrations/20251026134218_AddBilling.Designer.cs new file mode 100644 index 0000000..c1b0040 --- /dev/null +++ b/DysonNetwork.Insight/Migrations/20251026134218_AddBilling.Designer.cs @@ -0,0 +1,146 @@ +// +using System; +using System.Collections.Generic; +using DysonNetwork.Insight; +using DysonNetwork.Shared.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DysonNetwork.Insight.Migrations +{ + [DbContext(typeof(AppDatabase))] + [Migration("20251026134218_AddBilling")] + partial class AddBilling + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingSequence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("PaidToken") + .HasColumnType("bigint") + .HasColumnName("paid_token"); + + b.Property("Topic") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("topic"); + + b.Property("TotalToken") + .HasColumnType("bigint") + .HasColumnName("total_token"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_thinking_sequences"); + + b.ToTable("thinking_sequences", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property>("Chunks") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("chunks"); + + b.Property("Content") + .HasColumnType("text") + .HasColumnName("content"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property>("Files") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("files"); + + b.Property("ModelName") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("model_name"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("SequenceId") + .HasColumnType("uuid") + .HasColumnName("sequence_id"); + + b.Property("TokenCount") + .HasColumnType("bigint") + .HasColumnName("token_count"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.HasKey("Id") + .HasName("pk_thinking_thoughts"); + + b.HasIndex("SequenceId") + .HasDatabaseName("ix_thinking_thoughts_sequence_id"); + + b.ToTable("thinking_thoughts", (string)null); + }); + + modelBuilder.Entity("DysonNetwork.Shared.Models.SnThinkingThought", b => + { + b.HasOne("DysonNetwork.Shared.Models.SnThinkingSequence", "Sequence") + .WithMany() + .HasForeignKey("SequenceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_thinking_thoughts_thinking_sequences_sequence_id"); + + b.Navigation("Sequence"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/DysonNetwork.Insight/Migrations/20251026134218_AddBilling.cs b/DysonNetwork.Insight/Migrations/20251026134218_AddBilling.cs new file mode 100644 index 0000000..73d2988 --- /dev/null +++ b/DysonNetwork.Insight/Migrations/20251026134218_AddBilling.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DysonNetwork.Insight.Migrations +{ + /// + public partial class AddBilling : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "model_name", + table: "thinking_thoughts", + type: "character varying(4096)", + maxLength: 4096, + nullable: true); + + migrationBuilder.AddColumn( + name: "token_count", + table: "thinking_thoughts", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "paid_token", + table: "thinking_sequences", + type: "bigint", + nullable: false, + defaultValue: 0L); + + migrationBuilder.AddColumn( + name: "total_token", + table: "thinking_sequences", + type: "bigint", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "model_name", + table: "thinking_thoughts"); + + migrationBuilder.DropColumn( + name: "token_count", + table: "thinking_thoughts"); + + migrationBuilder.DropColumn( + name: "paid_token", + table: "thinking_sequences"); + + migrationBuilder.DropColumn( + name: "total_token", + table: "thinking_sequences"); + } + } +} diff --git a/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs b/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs index 44a0533..ed22d68 100644 --- a/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs +++ b/DysonNetwork.Insight/Migrations/AppDatabaseModelSnapshot.cs @@ -44,11 +44,19 @@ namespace DysonNetwork.Insight.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("deleted_at"); + b.Property("PaidToken") + .HasColumnType("bigint") + .HasColumnName("paid_token"); + b.Property("Topic") .HasMaxLength(4096) .HasColumnType("character varying(4096)") .HasColumnName("topic"); + b.Property("TotalToken") + .HasColumnType("bigint") + .HasColumnName("total_token"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); @@ -88,6 +96,11 @@ namespace DysonNetwork.Insight.Migrations .HasColumnType("jsonb") .HasColumnName("files"); + b.Property("ModelName") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)") + .HasColumnName("model_name"); + b.Property("Role") .HasColumnType("integer") .HasColumnName("role"); @@ -96,6 +109,10 @@ namespace DysonNetwork.Insight.Migrations .HasColumnType("uuid") .HasColumnName("sequence_id"); + b.Property("TokenCount") + .HasColumnType("bigint") + .HasColumnName("token_count"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone") .HasColumnName("updated_at"); diff --git a/DysonNetwork.Insight/Program.cs b/DysonNetwork.Insight/Program.cs index 9eb461f..9682800 100644 --- a/DysonNetwork.Insight/Program.cs +++ b/DysonNetwork.Insight/Program.cs @@ -16,6 +16,7 @@ builder.Services.AddAppServices(); builder.Services.AddAppAuthentication(); builder.Services.AddAppFlushHandlers(); builder.Services.AddAppBusinessServices(); +builder.Services.AddAppScheduledJobs(); builder.Services.AddDysonAuth(); builder.Services.AddAccountService(); diff --git a/DysonNetwork.Insight/Startup/ScheduledJobsConfiguration.cs b/DysonNetwork.Insight/Startup/ScheduledJobsConfiguration.cs new file mode 100644 index 0000000..d07c5f3 --- /dev/null +++ b/DysonNetwork.Insight/Startup/ScheduledJobsConfiguration.cs @@ -0,0 +1,25 @@ +using Quartz; + +namespace DysonNetwork.Insight.Startup; + +public static class ScheduledJobsConfiguration +{ + public static IServiceCollection AddAppScheduledJobs(this IServiceCollection services) + { + services.AddQuartz(q => + { + var tokenBillingJob = new JobKey("TokenBilling"); + q.AddJob(opts => opts.WithIdentity(tokenBillingJob)); + q.AddTrigger(opts => opts + .ForJob(tokenBillingJob) + .WithIdentity("TokenBillingTrigger") + .WithSimpleSchedule(o => o + .WithIntervalInMinutes(5) + .RepeatForever()) + ); + }); + services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); + + return services; + } +} diff --git a/DysonNetwork.Insight/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Insight/Startup/ServiceCollectionExtensions.cs index e10cbb4..110f8ae 100644 --- a/DysonNetwork.Insight/Startup/ServiceCollectionExtensions.cs +++ b/DysonNetwork.Insight/Startup/ServiceCollectionExtensions.cs @@ -66,6 +66,14 @@ public static class ServiceCollectionExtensions services.AddSingleton(); services.AddScoped(); + // Add gRPC clients for ThoughtService + services.AddGrpcClient(o => o.Address = new Uri("https://_grpc.pass")) + .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() + { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }); + services.AddGrpcClient(o => o.Address = new Uri("https://_grpc.pass")) + .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() + { ServerCertificateCustomValidationCallback = (_, _, _, _) => true }); + return services; } } diff --git a/DysonNetwork.Insight/Startup/TokenBillingJob.cs b/DysonNetwork.Insight/Startup/TokenBillingJob.cs new file mode 100644 index 0000000..e83233c --- /dev/null +++ b/DysonNetwork.Insight/Startup/TokenBillingJob.cs @@ -0,0 +1,12 @@ +using DysonNetwork.Insight.Thought; +using Quartz; + +namespace DysonNetwork.Insight.Startup; + +public class TokenBillingJob(ThoughtService thoughtService, ILogger logger) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + await thoughtService.SettleThoughtBills(logger); + } +} diff --git a/DysonNetwork.Insight/Thought/ThoughtController.cs b/DysonNetwork.Insight/Thought/ThoughtController.cs index 2ad969a..cc9b34f 100644 --- a/DysonNetwork.Insight/Thought/ThoughtController.cs +++ b/DysonNetwork.Insight/Thought/ThoughtController.cs @@ -178,7 +178,8 @@ public class ThoughtController(ThoughtProvider provider, ThoughtService service) sequence, accumulatedContent.ToString(), ThinkingThoughtRole.Assistant, - thinkingChunks + thinkingChunks, + provider.ModelDefault ); // Write the topic if it was newly set, then the thought object as JSON to the stream diff --git a/DysonNetwork.Insight/Thought/ThoughtProvider.cs b/DysonNetwork.Insight/Thought/ThoughtProvider.cs index 7b5a938..b449929 100644 --- a/DysonNetwork.Insight/Thought/ThoughtProvider.cs +++ b/DysonNetwork.Insight/Thought/ThoughtProvider.cs @@ -26,7 +26,7 @@ public class ThoughtProvider public Kernel Kernel { get; } private string? ModelProviderType { get; set; } - private string? ModelDefault { get; set; } + public string? ModelDefault { get; set; } [Experimental("SKEXP0050")] public ThoughtProvider( diff --git a/DysonNetwork.Insight/Thought/ThoughtService.cs b/DysonNetwork.Insight/Thought/ThoughtService.cs index bcea8ff..520a82f 100644 --- a/DysonNetwork.Insight/Thought/ThoughtService.cs +++ b/DysonNetwork.Insight/Thought/ThoughtService.cs @@ -1,10 +1,14 @@ using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Models; +using DysonNetwork.Shared.Proto; using Microsoft.EntityFrameworkCore; +using PaymentService = DysonNetwork.Shared.Proto.PaymentService; +using TransactionType = DysonNetwork.Shared.Proto.TransactionType; +using WalletService = DysonNetwork.Shared.Proto.WalletService; namespace DysonNetwork.Insight.Thought; -public class ThoughtService(AppDatabase db, ICacheService cache) +public class ThoughtService(AppDatabase db, ICacheService cache, PaymentService.PaymentServiceClient paymentService, WalletService.WalletServiceClient walletService) { public async Task GetOrCreateSequenceAsync(Guid accountId, Guid? sequenceId, string? topic = null) @@ -28,17 +32,28 @@ public class ThoughtService(AppDatabase db, ICacheService cache) SnThinkingSequence sequence, string content, ThinkingThoughtRole role, - List? chunks = null + List? chunks = null, + string? model = null ) { + // Approximate token count (1 token ≈ 4 characters for GPT-like models) + var tokenCount = content?.Length / 4 ?? 0; + var thought = new SnThinkingThought { SequenceId = sequence.Id, Content = content, Role = role, - Chunks = chunks ?? [] + TokenCount = tokenCount, + ModelName = model, + Chunks = chunks ?? new List() }; db.ThinkingThoughts.Add(thought); + + // Update sequence total tokens only for assistant responses + if (role == ThinkingThoughtRole.Assistant) + sequence.TotalToken += tokenCount; + await db.SaveChangesAsync(); // Invalidate cache for this sequence's thoughts @@ -80,4 +95,58 @@ public class ThoughtService(AppDatabase db, ICacheService cache) return (totalCount, sequences); } -} \ No newline at end of file + + public async Task SettleThoughtBills(ILogger logger) + { + var sequences = await db.ThinkingSequences + .Where(s => s.PaidToken < s.TotalToken) + .ToListAsync(); + + if (sequences.Count == 0) + { + logger.LogInformation("No unpaid sequences found."); + return; + } + + // Group by account + var groupedByAccount = sequences.GroupBy(s => s.AccountId); + + foreach (var accountGroup in groupedByAccount) + { + var accountId = accountGroup.Key; + var totalUnpaidTokens = accountGroup.Sum(s => s.TotalToken - s.PaidToken); + var cost = (long)Math.Ceiling(totalUnpaidTokens / 1000.0); + + if (cost == 0) continue; + + try + { + var walletResponse = await walletService.GetWalletAsync(new GetWalletRequest { AccountId = accountId.ToString() }); + var walletId = Guid.Parse(walletResponse.Id); + + var date = DateTime.Now.ToString("yyyy-MM-dd"); + await paymentService.CreateTransactionAsync(new CreateTransactionRequest + { + PayerWalletId = walletId.ToString(), + PayeeWalletId = null, + Currency = WalletCurrency.SourcePoint, + Amount = cost.ToString(), + Remarks = $"Wage for SN-chan on {date}", + Type = TransactionType.System + }); + + // Mark all sequences for this account as paid + foreach (var sequence in accountGroup) + sequence.PaidToken = sequence.TotalToken; + + logger.LogInformation("Billed {cost} points for account {accountId}", cost, accountId); + } + catch (Exception ex) + { + logger.LogError(ex, "Error billing for account {accountId}", accountId); + } + } + + await db.SaveChangesAsync(); + } +} diff --git a/DysonNetwork.Insight/appsettings.json b/DysonNetwork.Insight/appsettings.json index a24fb23..5e3c4fd 100644 --- a/DysonNetwork.Insight/appsettings.json +++ b/DysonNetwork.Insight/appsettings.json @@ -22,6 +22,6 @@ "Thinking": { "Provider": "deepseek", "Model": "deepseek-chat", - "ApiKey": "sk-cd709f9f1b96432e99d2d992392b4220" + "ApiKey": "sk-bd20f6a2e9fa40b98c46899baa0e9f09" } } \ No newline at end of file diff --git a/DysonNetwork.Shared/Models/Payment.cs b/DysonNetwork.Shared/Models/Payment.cs index ebb09fb..1a1088f 100644 --- a/DysonNetwork.Shared/Models/Payment.cs +++ b/DysonNetwork.Shared/Models/Payment.cs @@ -6,7 +6,7 @@ using NodaTime.Serialization.Protobuf; namespace DysonNetwork.Shared.Models; -public class WalletCurrency +public abstract class WalletCurrency { public const string SourcePoint = "points"; public const string GoldenPoint = "golds"; diff --git a/DysonNetwork.Shared/Models/ThinkingSequence.cs b/DysonNetwork.Shared/Models/ThinkingSequence.cs index 71a8a94..c9f7d05 100644 --- a/DysonNetwork.Shared/Models/ThinkingSequence.cs +++ b/DysonNetwork.Shared/Models/ThinkingSequence.cs @@ -8,6 +8,9 @@ public class SnThinkingSequence : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); [MaxLength(4096)] public string? Topic { get; set; } + + public long TotalToken { get; set; } + public long PaidToken { get; set; } public Guid AccountId { get; set; } } @@ -38,11 +41,13 @@ public class SnThinkingThought : ModelBase public string? Content { get; set; } [Column(TypeName = "jsonb")] public List Files { get; set; } = []; - [Column(TypeName = "jsonb")] public List Chunks { get; set; } = []; public ThinkingThoughtRole Role { get; set; } + public long TokenCount { get; set; } + [MaxLength(4096)] public string? ModelName { get; set; } + public Guid SequenceId { get; set; } [JsonIgnore] public SnThinkingSequence Sequence { get; set; } = null!; } diff --git a/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs b/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs index 0ac7628..219a020 100644 --- a/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs +++ b/DysonNetwork.Shared/Registry/ServiceInjectionHelper.cs @@ -60,6 +60,11 @@ public static class ServiceInjectionHelper { ServerCertificateCustomValidationCallback = (_, _, _, _) => true } ); + services.AddGrpcClient(o => o.Address = new Uri("https://_grpc.pass")) + .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() + { ServerCertificateCustomValidationCallback = (_, _, _, _) => true } + ); + services .AddGrpcClient(o => o.Address = new Uri("https://_grpc.pass")) .ConfigurePrimaryHttpMessageHandler(_ => new HttpClientHandler() @@ -113,4 +118,4 @@ public static class ServiceInjectionHelper return services; } -} \ No newline at end of file +}