From 3a6e4639e1f47ab71a4eaa7e25141f43c9a8421a Mon Sep 17 00:00:00 2001 From: Harsh Date: Thu, 19 Mar 2026 02:31:30 +0530 Subject: [PATCH 1/2] feat: add telegram bot v1 --- .env | 10 - .env.example | 11 + ...nd-arena.yml => deploy-dualmind-arena.yml} | 116 +++--- .github/workflows/dotnet.yml | 36 -- .github/workflows/verify.yml | 4 +- .gitignore | 1 + ...15_add_telegram_session_refresh_tokens.sql | 11 + .../Bot/Commands/BattleCommandHandler.cs | 329 ++++++++++++++++++ .../Bot/Commands/CancelCommandHandler.cs | 30 ++ .../Bot/Commands/HelpCommandHandler.cs | 26 ++ .../Bot/Commands/StartCommandHandler.cs | 26 ++ .../Bot/Commands/StatsCommandHandler.cs | 51 +++ src/DualMind.API/Bot/DualMindBotApiClient.cs | 122 +++++++ src/DualMind.API/Bot/IDualMindBotApiClient.cs | 16 + .../Bot/ISupabaseTelegramAuthClient.cs | 12 + src/DualMind.API/Bot/ITelegramAuthService.cs | 14 + src/DualMind.API/Bot/ITelegramSessionStore.cs | 13 + .../Bot/Models/ApiResponseModels.cs | 113 ++++++ src/DualMind.API/Bot/Models/BattleSession.cs | 45 +++ .../Bot/Models/TelegramBotCommand.cs | 8 + src/DualMind.API/Bot/Models/UserState.cs | 24 ++ .../Bot/SupabaseTelegramAuthClient.cs | 92 +++++ src/DualMind.API/Bot/TelegramAuthService.cs | 137 ++++++++ src/DualMind.API/Bot/TelegramBotExceptions.cs | 28 ++ src/DualMind.API/Bot/TelegramBotOptions.cs | 16 + src/DualMind.API/Bot/TelegramBotService.cs | 89 +++++ .../TelegramBotServiceCollectionExtensions.cs | 75 ++++ .../Bot/TelegramMessageFormatter.cs | 145 ++++++++ src/DualMind.API/Bot/TelegramSessionStore.cs | 141 ++++++++ src/DualMind.API/Bot/TelegramStateCache.cs | 179 ++++++++++ src/DualMind.API/Bot/TelegramUpdateHandler.cs | 304 ++++++++++++++++ .../Bot/Transport/ITelegramBotTransport.cs | 19 + .../Bot/Transport/TelegramBotTransport.cs | 108 ++++++ .../Core/Services/LeaderboardModelSelector.cs | 17 +- .../Core/Services/ModelSelector.cs | 33 +- src/DualMind.API/DualMind.API.csproj | 1 + .../Infrastructure/Data/SupabaseService.cs | 23 ++ src/DualMind.API/Program.cs | 6 +- src/DualMind.API/appsettings.Development.json | 6 + src/DualMind.API/appsettings.json | 8 +- .../BattleCommandHandlerTests.cs | 312 +++++++++++++++++ tests/DualMind.API.Tests/BotTestDoubles.cs | 267 ++++++++++++++ .../DualMind.API.Tests/ModelSelectorTests.cs | 93 +++++ .../StatsCommandHandlerTests.cs | 48 +++ .../SupabaseServiceTests.cs | 73 ++++ .../TelegramAuthServiceTests.cs | 117 +++++++ ...gramBotServiceCollectionExtensionsTests.cs | 48 +++ .../TelegramSessionStoreTests.cs | 41 +++ .../TelegramStateCacheTests.cs | 68 ++++ .../TelegramUpdateHandlerTests.cs | 131 +++++++ tests/DualMind.API.Tests/UnitTest1.cs | 10 - 51 files changed, 3528 insertions(+), 125 deletions(-) delete mode 100644 .env create mode 100644 .env.example rename .github/workflows/{master_dualmind-arena.yml => deploy-dualmind-arena.yml} (95%) delete mode 100644 .github/workflows/dotnet.yml create mode 100644 database/migrations/20260315_add_telegram_session_refresh_tokens.sql create mode 100644 src/DualMind.API/Bot/Commands/BattleCommandHandler.cs create mode 100644 src/DualMind.API/Bot/Commands/CancelCommandHandler.cs create mode 100644 src/DualMind.API/Bot/Commands/HelpCommandHandler.cs create mode 100644 src/DualMind.API/Bot/Commands/StartCommandHandler.cs create mode 100644 src/DualMind.API/Bot/Commands/StatsCommandHandler.cs create mode 100644 src/DualMind.API/Bot/DualMindBotApiClient.cs create mode 100644 src/DualMind.API/Bot/IDualMindBotApiClient.cs create mode 100644 src/DualMind.API/Bot/ISupabaseTelegramAuthClient.cs create mode 100644 src/DualMind.API/Bot/ITelegramAuthService.cs create mode 100644 src/DualMind.API/Bot/ITelegramSessionStore.cs create mode 100644 src/DualMind.API/Bot/Models/ApiResponseModels.cs create mode 100644 src/DualMind.API/Bot/Models/BattleSession.cs create mode 100644 src/DualMind.API/Bot/Models/TelegramBotCommand.cs create mode 100644 src/DualMind.API/Bot/Models/UserState.cs create mode 100644 src/DualMind.API/Bot/SupabaseTelegramAuthClient.cs create mode 100644 src/DualMind.API/Bot/TelegramAuthService.cs create mode 100644 src/DualMind.API/Bot/TelegramBotExceptions.cs create mode 100644 src/DualMind.API/Bot/TelegramBotOptions.cs create mode 100644 src/DualMind.API/Bot/TelegramBotService.cs create mode 100644 src/DualMind.API/Bot/TelegramBotServiceCollectionExtensions.cs create mode 100644 src/DualMind.API/Bot/TelegramMessageFormatter.cs create mode 100644 src/DualMind.API/Bot/TelegramSessionStore.cs create mode 100644 src/DualMind.API/Bot/TelegramStateCache.cs create mode 100644 src/DualMind.API/Bot/TelegramUpdateHandler.cs create mode 100644 src/DualMind.API/Bot/Transport/ITelegramBotTransport.cs create mode 100644 src/DualMind.API/Bot/Transport/TelegramBotTransport.cs create mode 100644 tests/DualMind.API.Tests/BattleCommandHandlerTests.cs create mode 100644 tests/DualMind.API.Tests/BotTestDoubles.cs create mode 100644 tests/DualMind.API.Tests/ModelSelectorTests.cs create mode 100644 tests/DualMind.API.Tests/StatsCommandHandlerTests.cs create mode 100644 tests/DualMind.API.Tests/SupabaseServiceTests.cs create mode 100644 tests/DualMind.API.Tests/TelegramAuthServiceTests.cs create mode 100644 tests/DualMind.API.Tests/TelegramBotServiceCollectionExtensionsTests.cs create mode 100644 tests/DualMind.API.Tests/TelegramSessionStoreTests.cs create mode 100644 tests/DualMind.API.Tests/TelegramStateCacheTests.cs create mode 100644 tests/DualMind.API.Tests/TelegramUpdateHandlerTests.cs delete mode 100644 tests/DualMind.API.Tests/UnitTest1.cs diff --git a/.env b/.env deleted file mode 100644 index 1cceff3..0000000 --- a/.env +++ /dev/null @@ -1,10 +0,0 @@ -# Groq -GROQ_API_KEY=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 - -# Google -GOOGLE_API_KEY=AIzaSyADGHJSbsooLKufmiVCfl0hFsTiBpNnCo4 - -# Supabase -SUPABASE_URL=https://calqfzajyidkdzbaswjp.supabase.co -SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNhbHFmemFqeWlka2R6YmFzd2pwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQyNzMwODMsImV4cCI6MjA3OTg0OTA4M30.ptXyUNCcAhGi9u2kVDHOxSBvQv0W72S5HHqkIFXQS08 -SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNhbHFmemFqeWlka2R6YmFzd2pwIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2NDI3MzA4MywiZXhwIjoyMDc5ODQ5MDgzfQ.bt3MjR2dItU1FT3yRTlNkNhNPRFO5_NBO1lMCqQy1d8 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f2f5e30 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +GROQ_API_KEY= +GOOGLE_API_KEY= +SUPABASE_URL= +SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= +Telegram__BotToken= +Telegram__ApiBaseUrl=http://localhost:5079 +Telegram__SignupUrl=https://dualmind.arena/signup +Telegram__BattleCooldownSeconds=15 +Telegram__SoftTimeoutSeconds=30 +Telegram__ApiTimeoutSeconds=75 diff --git a/.github/workflows/master_dualmind-arena.yml b/.github/workflows/deploy-dualmind-arena.yml similarity index 95% rename from .github/workflows/master_dualmind-arena.yml rename to .github/workflows/deploy-dualmind-arena.yml index 400c3ba..a2832d6 100644 --- a/.github/workflows/master_dualmind-arena.yml +++ b/.github/workflows/deploy-dualmind-arena.yml @@ -1,58 +1,58 @@ -# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy -# More GitHub Actions for Azure: https://github.com/Azure/actions - -name: Build and deploy ASP.Net Core app to Azure Web App - DualMind-Arena - -on: - push: - branches: - - master - workflow_dispatch: - -jobs: - build: - runs-on: windows-latest - permissions: - contents: read #This is required for actions/checkout - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up .NET Core - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.x' - - - name: Build with dotnet - run: dotnet build src/DualMind.API/DualMind.API.csproj --configuration Release - - - name: dotnet publish - run: dotnet publish src/DualMind.API/DualMind.API.csproj -c Release -o "${{env.DOTNET_ROOT}}/myapp" - - - name: Upload artifact for deployment job - uses: actions/upload-artifact@v4 - with: - name: .net-app - path: "${{env.DOTNET_ROOT}}/myapp" - - deploy: - runs-on: windows-latest - needs: build - permissions: - contents: none - - steps: - - name: Download artifact from build job - uses: actions/download-artifact@v4 - with: - name: .net-app - - - name: Deploy to Azure Web App - id: deploy-to-webapp - uses: azure/webapps-deploy@v3 - with: - app-name: 'dualmind-arena' - slot-name: 'Production' - publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }} - package: . +# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy +# More GitHub Actions for Azure: https://github.com/Azure/actions + +name: Build and deploy ASP.Net Core app to Azure Web App - DualMind-Arena + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + permissions: + contents: read #This is required for actions/checkout + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up .NET Core + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.x' + + - name: Build with dotnet + run: dotnet build src/DualMind.API/DualMind.API.csproj --configuration Release + + - name: dotnet publish + run: dotnet publish src/DualMind.API/DualMind.API.csproj -c Release -o "${{env.DOTNET_ROOT}}/myapp" + + - name: Upload artifact for deployment job + uses: actions/upload-artifact@v4 + with: + name: .net-app + path: "${{env.DOTNET_ROOT}}/myapp" + + deploy: + runs-on: windows-latest + needs: build + permissions: + contents: none + + steps: + - name: Download artifact from build job + uses: actions/download-artifact@v4 + with: + name: .net-app + + - name: Deploy to Azure Web App + id: deploy-to-webapp + uses: azure/webapps-deploy@v3 + with: + app-name: 'dualmind-arena' + slot-name: 'Production' + publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }} + package: . diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml deleted file mode 100644 index d8e8a94..0000000 --- a/.github/workflows/dotnet.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Build and deploy ASP.NET 4.8 to Azure Web App - -on: - push: - branches: - - master - workflow_dispatch: - -jobs: - build-and-deploy: - runs-on: windows-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - - name: Restore - run: dotnet restore src/DualMind.API/DualMind.API.csproj - - - name: Build - run: dotnet build src/DualMind.API/DualMind.API.csproj -c Release --no-restore - - - name: Publish - run: dotnet publish src/DualMind.API/DualMind.API.csproj -c Release -o "${{ github.workspace }}\published" --no-build - - - name: Deploy to Azure Web App - uses: azure/webapps-deploy@v3 - with: - app-name: - publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }} - package: published diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 902f0b6..033733a 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -2,9 +2,9 @@ name: Verify on: push: - branches: [ "master" ] + branches: [ "main" ] pull_request: - branches: [ "master" ] + branches: [ "main" ] jobs: build: diff --git a/.gitignore b/.gitignore index 1aa84b8..5cce3b9 100644 --- a/.gitignore +++ b/.gitignore @@ -123,6 +123,7 @@ yarn-error.log # Environment Variables .env +.codex/ # Verification Demo verification_demo/node_modules/ diff --git a/database/migrations/20260315_add_telegram_session_refresh_tokens.sql b/database/migrations/20260315_add_telegram_session_refresh_tokens.sql new file mode 100644 index 0000000..cf82206 --- /dev/null +++ b/database/migrations/20260315_add_telegram_session_refresh_tokens.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS telegram_sessions ( + telegram_chat_id BIGINT PRIMARY KEY, + jwt_token TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE telegram_sessions + ADD COLUMN IF NOT EXISTS refresh_token TEXT; + +ALTER TABLE telegram_sessions + ADD COLUMN IF NOT EXISTS jwt_expires_at TIMESTAMPTZ; diff --git a/src/DualMind.API/Bot/Commands/BattleCommandHandler.cs b/src/DualMind.API/Bot/Commands/BattleCommandHandler.cs new file mode 100644 index 0000000..ac28a1b --- /dev/null +++ b/src/DualMind.API/Bot/Commands/BattleCommandHandler.cs @@ -0,0 +1,329 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; +using DualMind.API.Bot.Transport; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DualMind.API.Bot.Commands +{ + public class BattleCommandHandler + { + private readonly ITelegramAuthService _authService; + private readonly IDualMindBotApiClient _apiClient; + private readonly ITelegramBotTransport _transport; + private readonly TelegramStateCache _stateCache; + private readonly TelegramBotOptions _options; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public BattleCommandHandler( + ITelegramAuthService authService, + IDualMindBotApiClient apiClient, + ITelegramBotTransport transport, + TelegramStateCache stateCache, + IOptions options, + TimeProvider timeProvider, + ILogger logger) + { + _authService = authService; + _apiClient = apiClient; + _transport = transport; + _stateCache = stateCache; + _options = options.Value; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task HandleCommandAsync(long chatId, CancellationToken cancellationToken) + { + var session = await _authService.GetValidSessionAsync(chatId, cancellationToken); + if (session == null) + { + await _transport.SendTextMessageAsync( + chatId, + "Sign in first and then send /battle again", + TelegramMessageFormatter.BuildMainMenuKeyboard(_options.SignupUrl), + cancellationToken); + return; + } + + if (_stateCache.GetActiveBattle(chatId) != null) + { + await _transport.SendTextMessageAsync( + chatId, + "Finish voting on the current battle before starting a new one", + null, + cancellationToken); + return; + } + + _stateCache.SetAwaitingBattlePrompt(chatId); + await _transport.SendTextMessageAsync( + chatId, + "Send the prompt you want both agents to answer", + null, + cancellationToken); + } + + public async Task HandlePromptAsync(long chatId, string prompt, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(prompt)) + { + _stateCache.SetAwaitingBattlePrompt(chatId); + await _transport.SendTextMessageAsync( + chatId, + "Send a non empty prompt to start the battle", + null, + cancellationToken); + return; + } + + _stateCache.ClearConversationState(chatId); + + if (_stateCache.GetActiveBattle(chatId) != null) + { + await _transport.SendTextMessageAsync( + chatId, + "Finish voting on the current battle before starting a new one", + null, + cancellationToken); + return; + } + + var session = await _authService.GetValidSessionAsync(chatId, cancellationToken); + if (session == null) + { + await _transport.SendTextMessageAsync( + chatId, + "Sign in first and then start a battle", + TelegramMessageFormatter.BuildMainMenuKeyboard(_options.SignupUrl), + cancellationToken); + return; + } + + if (!_stateCache.TryBeginBattleCooldown(chatId, TimeSpan.FromSeconds(_options.BattleCooldownSeconds), out var remainingCooldown)) + { + var seconds = Math.Max(1, (int)Math.Ceiling(remainingCooldown.TotalSeconds)); + await _transport.SendTextMessageAsync( + chatId, + $"Please wait {seconds}s before starting another battle", + null, + cancellationToken); + return; + } + + var statusMessage = await _transport.SendTextMessageAsync( + chatId, + "Starting battle", + null, + cancellationToken); + + try + { + var battleTask = StartBattleWithRetryAsync(chatId, prompt, session, cancellationToken); + var softTimeoutTask = Task.Delay(TimeSpan.FromSeconds(_options.SoftTimeoutSeconds), cancellationToken); + + var completedTask = await Task.WhenAny(battleTask, softTimeoutTask); + if (completedTask == softTimeoutTask && !battleTask.IsCompleted) + { + await _transport.EditMessageTextAsync( + chatId, + statusMessage.MessageId, + "Taking longer than usual. Still waiting for both agents", + null, + cancellationToken); + } + + var battle = await battleTask; + if (battle == null) + { + await _transport.EditMessageTextAsync( + chatId, + statusMessage.MessageId, + "Session expired. Sign in again and retry the battle", + TelegramMessageFormatter.BuildMainMenuKeyboard(_options.SignupUrl), + cancellationToken); + return; + } + + var agentAName = battle.Agent1?.Model?.DisplayName ?? battle.Agent1?.Model?.Name ?? "Agent A"; + var agentBName = battle.Agent2?.Model?.DisplayName ?? battle.Agent2?.Model?.Name ?? "Agent B"; + var agentAResponse = battle.Agent1?.Message ?? string.Empty; + var agentBResponse = battle.Agent2?.Message ?? string.Empty; + + var agentAMessage = await _transport.SendTextMessageAsync( + chatId, + TelegramMessageFormatter.FormatMaskedBattleMessage("Agent A", agentAResponse), + null, + cancellationToken); + + var agentBMessage = await _transport.SendTextMessageAsync( + chatId, + TelegramMessageFormatter.FormatMaskedBattleMessage("Agent B", agentBResponse), + TelegramMessageFormatter.BuildVoteKeyboard(battle.ComparisonId), + cancellationToken); + + _stateCache.SetActiveBattle(chatId, new BattleSession + { + ComparisonId = battle.ComparisonId, + Prompt = prompt, + AgentAResponse = agentAResponse, + AgentBResponse = agentBResponse, + AgentAModelDisplayName = agentAName, + AgentBModelDisplayName = agentBName, + AgentAMessageId = agentAMessage.MessageId, + AgentBMessageId = agentBMessage.MessageId, + StartedAt = _timeProvider.GetUtcNow() + }); + + try + { + await _transport.DeleteMessageAsync(chatId, statusMessage.MessageId, cancellationToken); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to delete battle status message {MessageId}", statusMessage.MessageId); + } + } + catch (DualMindBotApiException ex) + { + await _transport.EditMessageTextAsync( + chatId, + statusMessage.MessageId, + $"Battle failed: {TelegramMessageFormatter.EscapeMarkdown(ex.Message)}", + null, + cancellationToken); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + await _transport.EditMessageTextAsync( + chatId, + statusMessage.MessageId, + "The battle timed out before both agents replied. Please try again", + null, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected battle failure for chat {ChatId}", chatId); + await _transport.EditMessageTextAsync( + chatId, + statusMessage.MessageId, + "Something went wrong while starting the battle. Please try again", + null, + cancellationToken); + } + } + + public async Task HandleVoteAsync(long chatId, string callbackQueryId, Guid comparisonId, string voteChoice, CancellationToken cancellationToken) + { + if (!_stateCache.TryBeginVote(chatId, comparisonId, voteChoice, out var battleSession) || battleSession == null) + { + await _transport.AnswerCallbackQueryAsync( + callbackQueryId, + "That vote is no longer available.", + false, + cancellationToken); + return; + } + + await _transport.AnswerCallbackQueryAsync( + callbackQueryId, + "Submitting vote", + false, + cancellationToken); + + try + { + var voteDurationMs = (int)Math.Max(0, (_timeProvider.GetUtcNow() - battleSession.StartedAt).TotalMilliseconds); + var voteResponse = await SubmitVoteWithRetryAsync(chatId, comparisonId, voteChoice, voteDurationMs, cancellationToken); + if (voteResponse == null) + { + _stateCache.ResetVote(chatId); + await _transport.SendTextMessageAsync( + chatId, + "Session expired. Sign in again and try voting once more", + TelegramMessageFormatter.BuildMainMenuKeyboard(_options.SignupUrl), + cancellationToken); + return; + } + + await _transport.EditMessageTextAsync( + chatId, + battleSession.AgentAMessageId, + TelegramMessageFormatter.FormatRevealedBattleMessage("Agent A", battleSession.AgentAModelDisplayName, battleSession.AgentAResponse), + null, + cancellationToken); + + await _transport.EditMessageTextAsync( + chatId, + battleSession.AgentBMessageId, + TelegramMessageFormatter.FormatRevealedBattleMessage("Agent B", battleSession.AgentBModelDisplayName, battleSession.AgentBResponse), + null, + cancellationToken); + + _stateCache.CompleteBattle(chatId); + + await _transport.SendTextMessageAsync( + chatId, + TelegramMessageFormatter.EscapeMarkdown(voteResponse.Message ?? "Vote recorded"), + null, + cancellationToken); + } + catch (Exception ex) + { + _stateCache.ResetVote(chatId); + _logger.LogError(ex, "Failed to submit vote for chat {ChatId}", chatId); + await _transport.SendTextMessageAsync( + chatId, + "I could not record that vote. Please try again", + null, + cancellationToken); + } + } + + private async Task StartBattleWithRetryAsync(long chatId, string prompt, TelegramAuthSession session, CancellationToken cancellationToken) + { + try + { + return await _apiClient.StartBattleAsync(session.AccessToken, prompt, cancellationToken); + } + catch (DualMindBotApiException ex) when (ex.IsUnauthorized) + { + session = await _authService.ForceRefreshSessionAsync(chatId, cancellationToken); + if (session == null) + { + return null; + } + + return await _apiClient.StartBattleAsync(session.AccessToken, prompt, cancellationToken); + } + } + + private async Task SubmitVoteWithRetryAsync(long chatId, Guid comparisonId, string voteChoice, int voteDurationMs, CancellationToken cancellationToken) + { + var session = await _authService.GetValidSessionAsync(chatId, cancellationToken); + if (session == null) + { + return null; + } + + try + { + return await _apiClient.SubmitVoteAsync(session.AccessToken, comparisonId, voteChoice, voteDurationMs, cancellationToken); + } + catch (DualMindBotApiException ex) when (ex.IsUnauthorized) + { + session = await _authService.ForceRefreshSessionAsync(chatId, cancellationToken); + if (session == null) + { + return null; + } + + return await _apiClient.SubmitVoteAsync(session.AccessToken, comparisonId, voteChoice, voteDurationMs, cancellationToken); + } + } + } +} diff --git a/src/DualMind.API/Bot/Commands/CancelCommandHandler.cs b/src/DualMind.API/Bot/Commands/CancelCommandHandler.cs new file mode 100644 index 0000000..c355a78 --- /dev/null +++ b/src/DualMind.API/Bot/Commands/CancelCommandHandler.cs @@ -0,0 +1,30 @@ +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Transport; + +namespace DualMind.API.Bot.Commands +{ + public class CancelCommandHandler + { + private readonly ITelegramBotTransport _transport; + private readonly TelegramStateCache _stateCache; + private readonly string _signupUrl; + + public CancelCommandHandler(ITelegramBotTransport transport, TelegramStateCache stateCache, string signupUrl) + { + _transport = transport; + _stateCache = stateCache; + _signupUrl = signupUrl; + } + + public Task HandleAsync(long chatId, CancellationToken cancellationToken) + { + _stateCache.ClearConversationState(chatId); + return _transport.SendTextMessageAsync( + chatId, + "Action cancelled\n\nUse the menu to start again", + TelegramMessageFormatter.BuildMainMenuKeyboard(_signupUrl), + cancellationToken); + } + } +} diff --git a/src/DualMind.API/Bot/Commands/HelpCommandHandler.cs b/src/DualMind.API/Bot/Commands/HelpCommandHandler.cs new file mode 100644 index 0000000..49b011a --- /dev/null +++ b/src/DualMind.API/Bot/Commands/HelpCommandHandler.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Transport; +using Microsoft.Extensions.Options; + +namespace DualMind.API.Bot.Commands +{ + public class HelpCommandHandler + { + private readonly ITelegramBotTransport _transport; + private readonly TelegramBotOptions _options; + + public HelpCommandHandler(ITelegramBotTransport transport, IOptions options) + { + _transport = transport; + _options = options.Value; + } + + public Task HandleAsync(long chatId, CancellationToken cancellationToken) => + _transport.SendTextMessageAsync( + chatId, + TelegramMessageFormatter.FormatHelpMessage(), + TelegramMessageFormatter.BuildMainMenuKeyboard(_options.SignupUrl), + cancellationToken); + } +} diff --git a/src/DualMind.API/Bot/Commands/StartCommandHandler.cs b/src/DualMind.API/Bot/Commands/StartCommandHandler.cs new file mode 100644 index 0000000..d579fb5 --- /dev/null +++ b/src/DualMind.API/Bot/Commands/StartCommandHandler.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Transport; +using Microsoft.Extensions.Options; + +namespace DualMind.API.Bot.Commands +{ + public class StartCommandHandler + { + private readonly ITelegramBotTransport _transport; + private readonly TelegramBotOptions _options; + + public StartCommandHandler(ITelegramBotTransport transport, IOptions options) + { + _transport = transport; + _options = options.Value; + } + + public Task HandleAsync(long chatId, CancellationToken cancellationToken) => + _transport.SendTextMessageAsync( + chatId, + TelegramMessageFormatter.FormatWelcomeMessage(), + TelegramMessageFormatter.BuildMainMenuKeyboard(_options.SignupUrl), + cancellationToken); + } +} diff --git a/src/DualMind.API/Bot/Commands/StatsCommandHandler.cs b/src/DualMind.API/Bot/Commands/StatsCommandHandler.cs new file mode 100644 index 0000000..3f73fd7 --- /dev/null +++ b/src/DualMind.API/Bot/Commands/StatsCommandHandler.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Transport; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DualMind.API.Bot.Commands +{ + public class StatsCommandHandler + { + private readonly IDualMindBotApiClient _apiClient; + private readonly ITelegramBotTransport _transport; + private readonly TelegramBotOptions _options; + private readonly ILogger _logger; + + public StatsCommandHandler( + IDualMindBotApiClient apiClient, + ITelegramBotTransport transport, + IOptions options, + ILogger logger) + { + _apiClient = apiClient; + _transport = transport; + _options = options.Value; + _logger = logger; + } + + public async Task HandleAsync(long chatId, CancellationToken cancellationToken) + { + try + { + var stats = await _apiClient.GetModelStatsAsync(cancellationToken); + await _transport.SendTextMessageAsync( + chatId, + TelegramMessageFormatter.FormatStats(stats), + TelegramMessageFormatter.BuildMainMenuKeyboard(_options.SignupUrl), + cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch stats for chat {ChatId}", chatId); + await _transport.SendTextMessageAsync( + chatId, + "⚠️ *Leaderboard Unavailable*\n\nI couldn't load the stats right now\\. Please try again in a moment\\.", + TelegramMessageFormatter.BuildMainMenuKeyboard(_options.SignupUrl), + cancellationToken); + } + } + } +} diff --git a/src/DualMind.API/Bot/DualMindBotApiClient.cs b/src/DualMind.API/Bot/DualMindBotApiClient.cs new file mode 100644 index 0000000..8e8f58f --- /dev/null +++ b/src/DualMind.API/Bot/DualMindBotApiClient.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; +using DualMind.API.Core.Models; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace DualMind.API.Bot +{ + public class DualMindBotApiClient : IDualMindBotApiClient + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public DualMindBotApiClient(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public Task StartBattleAsync(string accessToken, string prompt, CancellationToken cancellationToken) => + SendAuthorizedAsync( + HttpMethod.Post, + "api/arena/dualchat", + accessToken, + new { prompt }, + cancellationToken); + + public Task SubmitVoteAsync(string accessToken, Guid comparisonId, string voteChoice, int voteDurationMs, CancellationToken cancellationToken) => + SendAuthorizedAsync( + HttpMethod.Post, + "api/arena/model-vote", + accessToken, + new + { + comparisonId, + voteChoice, + voteDurationMs + }, + cancellationToken); + + public async Task> GetModelStatsAsync(CancellationToken cancellationToken) + { + var response = await SendAsync( + HttpMethod.Get, + "api/arena/model-stats", + accessToken: null, + payload: null, + cancellationToken: cancellationToken); + + return response.Items ?? new List(); + } + + private Task SendAuthorizedAsync(HttpMethod method, string path, string accessToken, object? payload, CancellationToken cancellationToken) => + SendAsync(method, path, accessToken, payload, cancellationToken); + + private async Task SendAsync(HttpMethod method, string path, string? accessToken, object? payload, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, path); + if (!string.IsNullOrWhiteSpace(accessToken)) + { + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + } + + if (payload != null) + { + request.Content = new StringContent( + JsonConvert.SerializeObject(payload), + Encoding.UTF8, + "application/json"); + } + + var client = _httpClientFactory.CreateClient("DualMindTelegramApi"); + using var response = await client.SendAsync(request, cancellationToken); + var content = await response.Content.ReadAsStringAsync(); + + if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden) + { + throw new DualMindBotApiException("The Telegram bot session is no longer authorized.", response.StatusCode); + } + + if (!response.IsSuccessStatusCode) + { + var message = ExtractErrorMessage(content) ?? $"DualMind API request failed with status {(int)response.StatusCode}."; + _logger.LogWarning("DualMind API request to {Path} failed with status {StatusCode}: {Message}", path, response.StatusCode, message); + throw new DualMindBotApiException(message, response.StatusCode); + } + + var result = JsonConvert.DeserializeObject(content); + if (result == null) + { + throw new DualMindBotApiException("The DualMind API response was empty.", response.StatusCode); + } + + return result; + } + + private static string? ExtractErrorMessage(string content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return null; + } + + try + { + var body = JObject.Parse(content); + return body["message"]?.ToString() ?? body["error"]?.ToString() ?? body["detail"]?.ToString(); + } + catch + { + return content; + } + } + } +} diff --git a/src/DualMind.API/Bot/IDualMindBotApiClient.cs b/src/DualMind.API/Bot/IDualMindBotApiClient.cs new file mode 100644 index 0000000..a675d49 --- /dev/null +++ b/src/DualMind.API/Bot/IDualMindBotApiClient.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; +using DualMind.API.Core.Models; + +namespace DualMind.API.Bot +{ + public interface IDualMindBotApiClient + { + Task StartBattleAsync(string accessToken, string prompt, CancellationToken cancellationToken); + Task SubmitVoteAsync(string accessToken, Guid comparisonId, string voteChoice, int voteDurationMs, CancellationToken cancellationToken); + Task> GetModelStatsAsync(CancellationToken cancellationToken); + } +} diff --git a/src/DualMind.API/Bot/ISupabaseTelegramAuthClient.cs b/src/DualMind.API/Bot/ISupabaseTelegramAuthClient.cs new file mode 100644 index 0000000..ad87c7d --- /dev/null +++ b/src/DualMind.API/Bot/ISupabaseTelegramAuthClient.cs @@ -0,0 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; + +namespace DualMind.API.Bot +{ + public interface ISupabaseTelegramAuthClient + { + Task SignInWithPasswordAsync(long chatId, string email, string password, CancellationToken cancellationToken); + Task RefreshSessionAsync(long chatId, string refreshToken, CancellationToken cancellationToken); + } +} diff --git a/src/DualMind.API/Bot/ITelegramAuthService.cs b/src/DualMind.API/Bot/ITelegramAuthService.cs new file mode 100644 index 0000000..e0627a8 --- /dev/null +++ b/src/DualMind.API/Bot/ITelegramAuthService.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; + +namespace DualMind.API.Bot +{ + public interface ITelegramAuthService + { + Task GetValidSessionAsync(long chatId, CancellationToken cancellationToken); + Task ForceRefreshSessionAsync(long chatId, CancellationToken cancellationToken); + Task SignInAsync(long chatId, string email, string password, CancellationToken cancellationToken); + Task ClearSessionAsync(long chatId, CancellationToken cancellationToken); + } +} diff --git a/src/DualMind.API/Bot/ITelegramSessionStore.cs b/src/DualMind.API/Bot/ITelegramSessionStore.cs new file mode 100644 index 0000000..5c830fa --- /dev/null +++ b/src/DualMind.API/Bot/ITelegramSessionStore.cs @@ -0,0 +1,13 @@ +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; + +namespace DualMind.API.Bot +{ + public interface ITelegramSessionStore + { + Task GetSessionAsync(long chatId, CancellationToken cancellationToken); + Task SaveSessionAsync(TelegramAuthSession session, CancellationToken cancellationToken); + Task DeleteSessionAsync(long chatId, CancellationToken cancellationToken); + } +} diff --git a/src/DualMind.API/Bot/Models/ApiResponseModels.cs b/src/DualMind.API/Bot/Models/ApiResponseModels.cs new file mode 100644 index 0000000..153ca16 --- /dev/null +++ b/src/DualMind.API/Bot/Models/ApiResponseModels.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using DualMind.API.AI.Contracts; +using DualMind.API.Core.Models; +using Newtonsoft.Json; + +namespace DualMind.API.Bot.Models +{ + public sealed class TelegramAuthSession + { + public long ChatId { get; set; } + public string AccessToken { get; set; } = string.Empty; + public string? RefreshToken { get; set; } + public DateTimeOffset? ExpiresAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public bool IsExpiringSoon(TimeSpan threshold, DateTimeOffset now) + { + if (!ExpiresAt.HasValue) + { + return false; + } + + return ExpiresAt.Value <= now.Add(threshold); + } + } + + public sealed class SupabaseAuthResponse + { + [JsonProperty("access_token")] + public string? AccessToken { get; set; } + + [JsonProperty("refresh_token")] + public string? RefreshToken { get; set; } + + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + + [JsonProperty("user")] + public SupabaseAuthUser? User { get; set; } + } + + public sealed class SupabaseAuthUser + { + [JsonProperty("id")] + public string? Id { get; set; } + + [JsonProperty("email")] + public string? Email { get; set; } + } + + public sealed class SupabaseErrorResponse + { + [JsonProperty("error")] + public string? Error { get; set; } + + [JsonProperty("error_description")] + public string? ErrorDescription { get; set; } + + [JsonProperty("msg")] + public string? Message { get; set; } + } + + public sealed class DualChatApiResponse + { + [JsonProperty("success")] + public bool Success { get; set; } + + [JsonProperty("agent1")] + public ChatResponse? Agent1 { get; set; } + + [JsonProperty("agent2")] + public ChatResponse? Agent2 { get; set; } + + [JsonProperty("comparisonId")] + public Guid ComparisonId { get; set; } + } + + public sealed class VoteApiResponse + { + [JsonProperty("success")] + public bool Success { get; set; } + + [JsonProperty("message")] + public string? Message { get; set; } + } + + public sealed class ModelStatsEnvelope + { + [JsonProperty("items")] + public List? Items { get; set; } + } + + public sealed class TelegramIncomingUpdate + { + public long UpdateId { get; set; } + public long ChatId { get; set; } + public string ChatType { get; set; } = "private"; + public int MessageId { get; set; } + public string? Text { get; set; } + public string? CallbackQueryId { get; set; } + public string? CallbackData { get; set; } + + public bool IsCallback => !string.IsNullOrWhiteSpace(CallbackQueryId); + } + + public sealed class TelegramSentMessage + { + public long ChatId { get; set; } + public int MessageId { get; set; } + public string? Text { get; set; } + } +} diff --git a/src/DualMind.API/Bot/Models/BattleSession.cs b/src/DualMind.API/Bot/Models/BattleSession.cs new file mode 100644 index 0000000..d9cab23 --- /dev/null +++ b/src/DualMind.API/Bot/Models/BattleSession.cs @@ -0,0 +1,45 @@ +using System; +using System.Threading; + +namespace DualMind.API.Bot.Models +{ + public sealed class BattleSession + { + private int _voteState; + + public Guid ComparisonId { get; set; } + public string Prompt { get; set; } = string.Empty; + public string AgentAResponse { get; set; } = string.Empty; + public string AgentBResponse { get; set; } = string.Empty; + public string AgentAModelDisplayName { get; set; } = string.Empty; + public string AgentBModelDisplayName { get; set; } = string.Empty; + public int AgentAMessageId { get; set; } + public int AgentBMessageId { get; set; } + public DateTimeOffset StartedAt { get; set; } + public string? VoteChoice { get; private set; } + + public bool VoteSubmitted => Volatile.Read(ref _voteState) == 2; + + public bool TryBeginVote(string voteChoice) + { + if (Interlocked.CompareExchange(ref _voteState, 1, 0) != 0) + { + return false; + } + + VoteChoice = voteChoice; + return true; + } + + public void MarkVoteSubmitted() + { + Interlocked.Exchange(ref _voteState, 2); + } + + public void ResetVote() + { + VoteChoice = null; + Interlocked.Exchange(ref _voteState, 0); + } + } +} diff --git a/src/DualMind.API/Bot/Models/TelegramBotCommand.cs b/src/DualMind.API/Bot/Models/TelegramBotCommand.cs new file mode 100644 index 0000000..46d36da --- /dev/null +++ b/src/DualMind.API/Bot/Models/TelegramBotCommand.cs @@ -0,0 +1,8 @@ +namespace DualMind.API.Bot.Models +{ + public class TelegramBotCommand + { + public string Command { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + } +} diff --git a/src/DualMind.API/Bot/Models/UserState.cs b/src/DualMind.API/Bot/Models/UserState.cs new file mode 100644 index 0000000..5efd2f9 --- /dev/null +++ b/src/DualMind.API/Bot/Models/UserState.cs @@ -0,0 +1,24 @@ +using System; + +namespace DualMind.API.Bot.Models +{ + public enum TelegramUserMode + { + Idle = 0, + WaitingForEmail = 1, + WaitingForPassword = 2, + WaitingForBattlePrompt = 3 + } + + public sealed class TelegramUserState + { + internal object SyncRoot { get; } = new(); + + public TelegramUserMode Mode { get; set; } = TelegramUserMode.Idle; + public string? PendingEmail { get; set; } + public DateTimeOffset? CooldownUntil { get; set; } + public BattleSession? ActiveBattle { get; set; } + public TelegramAuthSession? Session { get; set; } + public bool SessionLoaded { get; set; } + } +} diff --git a/src/DualMind.API/Bot/SupabaseTelegramAuthClient.cs b/src/DualMind.API/Bot/SupabaseTelegramAuthClient.cs new file mode 100644 index 0000000..7a8d199 --- /dev/null +++ b/src/DualMind.API/Bot/SupabaseTelegramAuthClient.cs @@ -0,0 +1,92 @@ +using System; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; +using DualMind.API.Infrastructure.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; + +namespace DualMind.API.Bot +{ + public class SupabaseTelegramAuthClient : ISupabaseTelegramAuthClient + { + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly string _supabaseUrl; + + public SupabaseTelegramAuthClient( + IHttpClientFactory httpClientFactory, + IOptions settings, + TimeProvider timeProvider, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + _timeProvider = timeProvider; + _supabaseUrl = settings.Value.Url?.TrimEnd('/') ?? throw new InvalidOperationException("Supabase URL is missing."); + } + + public Task SignInWithPasswordAsync(long chatId, string email, string password, CancellationToken cancellationToken) => + SendTokenRequestAsync( + chatId, + $"{_supabaseUrl}/auth/v1/token?grant_type=password", + new { email, password }, + cancellationToken); + + public Task RefreshSessionAsync(long chatId, string refreshToken, CancellationToken cancellationToken) => + SendTokenRequestAsync( + chatId, + $"{_supabaseUrl}/auth/v1/token?grant_type=refresh_token", + new { refresh_token = refreshToken }, + cancellationToken); + + private async Task SendTokenRequestAsync(long chatId, string url, object payload, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(JsonConvert.SerializeObject(payload), Encoding.UTF8, "application/json") + }; + + var client = _httpClientFactory.CreateClient("TelegramSupabaseAuth"); + using var response = await client.SendAsync(request, cancellationToken); + var content = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + var error = DeserializeOrDefault(content); + var message = error?.ErrorDescription ?? error?.Message ?? error?.Error ?? "Supabase authentication failed."; + _logger.LogWarning("Supabase auth request failed with status {StatusCode}: {Message}", response.StatusCode, message); + throw new TelegramAuthException(message); + } + + var authResponse = DeserializeOrDefault(content); + if (authResponse?.AccessToken == null) + { + throw new TelegramAuthException("Supabase authentication response was missing an access token."); + } + + return new TelegramAuthSession + { + ChatId = chatId, + AccessToken = authResponse.AccessToken, + RefreshToken = authResponse.RefreshToken, + ExpiresAt = _timeProvider.GetUtcNow().Add(TimeSpan.FromSeconds(Math.Max(authResponse.ExpiresIn, 0))), + UpdatedAt = _timeProvider.GetUtcNow() + }; + } + + private static T? DeserializeOrDefault(string content) + { + if (string.IsNullOrWhiteSpace(content)) + { + return default; + } + + return JsonConvert.DeserializeObject(content); + } + } +} diff --git a/src/DualMind.API/Bot/TelegramAuthService.cs b/src/DualMind.API/Bot/TelegramAuthService.cs new file mode 100644 index 0000000..16378f4 --- /dev/null +++ b/src/DualMind.API/Bot/TelegramAuthService.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; +using Microsoft.Extensions.Logging; + +namespace DualMind.API.Bot +{ + public class TelegramAuthService : ITelegramAuthService + { + private static readonly TimeSpan RefreshThreshold = TimeSpan.FromMinutes(5); + + private readonly TelegramStateCache _stateCache; + private readonly ISupabaseTelegramAuthClient _authClient; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _locks = new(); + + public TelegramAuthService( + TelegramStateCache stateCache, + ISupabaseTelegramAuthClient authClient, + TimeProvider timeProvider, + ILogger logger) + { + _stateCache = stateCache; + _authClient = authClient; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task GetValidSessionAsync(long chatId, CancellationToken cancellationToken) + { + var gate = GetLock(chatId); + await gate.WaitAsync(cancellationToken); + try + { + var session = await _stateCache.GetSessionAsync(chatId, cancellationToken); + if (session == null) + { + return null; + } + + if (!session.IsExpiringSoon(RefreshThreshold, _timeProvider.GetUtcNow())) + { + return session; + } + + return await RefreshSessionCoreAsync(chatId, session, cancellationToken); + } + finally + { + gate.Release(); + } + } + + public async Task ForceRefreshSessionAsync(long chatId, CancellationToken cancellationToken) + { + var gate = GetLock(chatId); + await gate.WaitAsync(cancellationToken); + try + { + var session = await _stateCache.GetSessionAsync(chatId, cancellationToken); + if (session == null) + { + return null; + } + + return await RefreshSessionCoreAsync(chatId, session, cancellationToken); + } + finally + { + gate.Release(); + } + } + + public async Task SignInAsync(long chatId, string email, string password, CancellationToken cancellationToken) + { + var gate = GetLock(chatId); + await gate.WaitAsync(cancellationToken); + try + { + var session = await _authClient.SignInWithPasswordAsync(chatId, email, password, cancellationToken); + await _stateCache.SaveSessionAsync(chatId, session, cancellationToken); + return session; + } + finally + { + gate.Release(); + } + } + + public async Task ClearSessionAsync(long chatId, CancellationToken cancellationToken) + { + var gate = GetLock(chatId); + await gate.WaitAsync(cancellationToken); + try + { + await _stateCache.RemoveSessionAsync(chatId, cancellationToken); + } + finally + { + gate.Release(); + } + } + + private async Task RefreshSessionCoreAsync(long chatId, TelegramAuthSession session, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(session.RefreshToken)) + { + await _stateCache.RemoveSessionAsync(chatId, cancellationToken); + return null; + } + + try + { + var refreshed = await _authClient.RefreshSessionAsync(chatId, session.RefreshToken, cancellationToken); + if (string.IsNullOrWhiteSpace(refreshed.RefreshToken)) + { + refreshed.RefreshToken = session.RefreshToken; + } + + await _stateCache.SaveSessionAsync(chatId, refreshed, cancellationToken); + return refreshed; + } + catch (TelegramAuthException ex) + { + _logger.LogWarning(ex, "Failed to refresh telegram session for chat {ChatId}", chatId); + await _stateCache.RemoveSessionAsync(chatId, cancellationToken); + return null; + } + } + + private SemaphoreSlim GetLock(long chatId) => + _locks.GetOrAdd(chatId, _ => new SemaphoreSlim(1, 1)); + } +} diff --git a/src/DualMind.API/Bot/TelegramBotExceptions.cs b/src/DualMind.API/Bot/TelegramBotExceptions.cs new file mode 100644 index 0000000..29d7292 --- /dev/null +++ b/src/DualMind.API/Bot/TelegramBotExceptions.cs @@ -0,0 +1,28 @@ +using System; +using System.Net; + +namespace DualMind.API.Bot +{ + public class TelegramAuthException : Exception + { + public TelegramAuthException(string message) + : base(message) + { + } + } + + public class DualMindBotApiException : Exception + { + public DualMindBotApiException(string message, HttpStatusCode? statusCode = null) + : base(message) + { + StatusCode = statusCode; + } + + public HttpStatusCode? StatusCode { get; } + + public bool IsUnauthorized => + StatusCode == HttpStatusCode.Unauthorized || + StatusCode == HttpStatusCode.Forbidden; + } +} diff --git a/src/DualMind.API/Bot/TelegramBotOptions.cs b/src/DualMind.API/Bot/TelegramBotOptions.cs new file mode 100644 index 0000000..e80448c --- /dev/null +++ b/src/DualMind.API/Bot/TelegramBotOptions.cs @@ -0,0 +1,16 @@ +namespace DualMind.API.Bot +{ + public class TelegramBotOptions + { + public string? BotToken { get; set; } + public string? ApiBaseUrl { get; set; } + public string SignupUrl { get; set; } = "https://dualmind.arena/signup"; + public int BattleCooldownSeconds { get; set; } = 15; + public int SoftTimeoutSeconds { get; set; } = 30; + public int ApiTimeoutSeconds { get; set; } = 75; + + public bool IsEnabled => + !string.IsNullOrWhiteSpace(BotToken) && + !string.IsNullOrWhiteSpace(ApiBaseUrl); + } +} diff --git a/src/DualMind.API/Bot/TelegramBotService.cs b/src/DualMind.API/Bot/TelegramBotService.cs new file mode 100644 index 0000000..83d8d9d --- /dev/null +++ b/src/DualMind.API/Bot/TelegramBotService.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; +using DualMind.API.Bot.Transport; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DualMind.API.Bot +{ + public class TelegramBotService : BackgroundService + { + private readonly ITelegramBotTransport _transport; + private readonly TelegramUpdateHandler _updateHandler; + private readonly TelegramBotOptions _options; + private readonly ILogger _logger; + + public TelegramBotService( + ITelegramBotTransport transport, + TelegramUpdateHandler updateHandler, + IOptions options, + ILogger logger) + { + _transport = transport; + _updateHandler = updateHandler; + _options = options.Value; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!_options.IsEnabled) + { + _logger.LogInformation("Telegram bot is disabled because the required configuration is missing."); + return; + } + + long? offset = null; + + try + { + await _transport.DeleteWebhookAsync(false, stoppingToken); + await SetBotCommandsAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to initialize Telegram bot commands or webhook."); + } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + var updates = await _transport.GetUpdatesAsync(offset, stoppingToken); + foreach (var update in updates) + { + offset = update.UpdateId + 1; + await _updateHandler.HandleAsync(update, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "Telegram long polling failed; retrying shortly."); + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } + } + private async Task SetBotCommandsAsync(CancellationToken cancellationToken) + { + var commands = new List + { + new() { Command = "start", Description = "Open main menu" }, + new() { Command = "help", Description = "Show help message" }, + new() { Command = "battle", Description = "Start a blind model battle" }, + new() { Command = "stats", Description = "Show top model leaderboard" }, + new() { Command = "cancel", Description = "Cancel current action" } + }; + + await _transport.SetMyCommandsAsync(commands, cancellationToken); + _logger.LogInformation("Telegram bot commands registered successfully."); + } + } +} diff --git a/src/DualMind.API/Bot/TelegramBotServiceCollectionExtensions.cs b/src/DualMind.API/Bot/TelegramBotServiceCollectionExtensions.cs new file mode 100644 index 0000000..6bd895a --- /dev/null +++ b/src/DualMind.API/Bot/TelegramBotServiceCollectionExtensions.cs @@ -0,0 +1,75 @@ +using System; +using DualMind.API.Bot.Commands; +using DualMind.API.Bot.Transport; +using DualMind.API.Core.Services; +using DualMind.API.Infrastructure.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace DualMind.API.Bot +{ + public static class TelegramBotServiceCollectionExtensions + { + public static bool AddTelegramBot(this IServiceCollection services, IConfiguration configuration) + { + services.AddOptions() + .Bind(configuration.GetSection("Telegram")); + + var options = configuration.GetSection("Telegram").Get() ?? new TelegramBotOptions(); + if (!options.IsEnabled) + { + return false; + } + + services.TryAddSingleton(TimeProvider.System); + services.TryAddSingleton(); + + services.AddHttpClient("TelegramSupabaseAuth", (serviceProvider, client) => + { + var settings = serviceProvider.GetRequiredService>().Value; + if (!string.IsNullOrWhiteSpace(settings.Url)) + { + client.BaseAddress = new Uri(settings.Url.TrimEnd('/') + "/"); + } + + if (!string.IsNullOrWhiteSpace(settings.Key)) + { + client.DefaultRequestHeaders.Remove("apikey"); + client.DefaultRequestHeaders.Remove("Authorization"); + client.DefaultRequestHeaders.Add("apikey", settings.Key); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {settings.Key}"); + } + + client.Timeout = TimeSpan.FromSeconds(30); + }); + + services.AddHttpClient("DualMindTelegramApi", (serviceProvider, client) => + { + var telegramOptions = serviceProvider.GetRequiredService>().Value; + client.BaseAddress = new Uri(telegramOptions.ApiBaseUrl!.TrimEnd('/') + "/"); + client.Timeout = TimeSpan.FromSeconds(Math.Max(telegramOptions.ApiTimeoutSeconds, 1)); + }); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(sp => new CancelCommandHandler( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>().Value.SignupUrl)); + services.TryAddSingleton(); + services.AddHostedService(); + + return true; + } + } +} diff --git a/src/DualMind.API/Bot/TelegramMessageFormatter.cs b/src/DualMind.API/Bot/TelegramMessageFormatter.cs new file mode 100644 index 0000000..e239ab7 --- /dev/null +++ b/src/DualMind.API/Bot/TelegramMessageFormatter.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using DualMind.API.Bot.Models; +using DualMind.API.Core.Models; +using Telegram.Bot.Types.ReplyMarkups; + +namespace DualMind.API.Bot +{ + public static class TelegramMessageFormatter + { + public const int MaxMessageBodyLength = 3800; + + public static string FormatWelcomeMessage() => + "DualMind Telegram Bot\n\n" + + "Use the menu or these commands\n" + + "/start\n" + + "/help\n" + + "/battle\n" + + "/stats\n" + + "/cancel"; + + public static string FormatHelpMessage() => + "Available commands\n\n" + + "/start opens the main menu\n" + + "/help shows this message\n" + + "/battle starts a blind model battle\n" + + "/stats shows the leaderboard\n" + + "/cancel stops the current flow\n\n" + + "Sign in is required before starting battles"; + + public static InlineKeyboardMarkup BuildMainMenuKeyboard(string signupUrl) => + new(new[] + { + new[] + { + InlineKeyboardButton.WithCallbackData("Sign In", "action:signin"), + InlineKeyboardButton.WithUrl("Sign Up", signupUrl) + }, + new[] + { + InlineKeyboardButton.WithCallbackData("Battle", "action:battle"), + InlineKeyboardButton.WithCallbackData("Stats", "action:stats") + }, + new[] + { + InlineKeyboardButton.WithCallbackData("Help", "action:help"), + InlineKeyboardButton.WithCallbackData("Cancel", "action:cancel") + } + }); + + public static InlineKeyboardMarkup BuildCancelKeyboard() => + new(new[] + { + new[] + { + InlineKeyboardButton.WithCallbackData("Cancel", "action:cancel") + } + }); + + public static InlineKeyboardMarkup BuildVoteKeyboard(Guid comparisonId) => + new(new[] + { + new[] + { + InlineKeyboardButton.WithCallbackData("Vote A", $"vote:{comparisonId}:left"), + InlineKeyboardButton.WithCallbackData("Vote B", $"vote:{comparisonId}:right") + }, + new[] + { + InlineKeyboardButton.WithCallbackData("Tie", $"vote:{comparisonId}:tie"), + InlineKeyboardButton.WithCallbackData("Both Bad", $"vote:{comparisonId}:both-bad") + } + }); + + public static string FormatMaskedBattleMessage(string agentLabel, string response) => + $"{EscapeMarkdown(agentLabel)}\n\n{EscapeMarkdown(Truncate(response))}"; + + public static string FormatRevealedBattleMessage(string agentLabel, string modelDisplayName, string response) => + $"{EscapeMarkdown(agentLabel)}: {EscapeMarkdown(modelDisplayName)}\n\n{EscapeMarkdown(Truncate(response))}"; + + public static string FormatStats(IReadOnlyList stats) + { + if (stats.Count == 0) + { + return "No leaderboard data available yet"; + } + + var top = stats + .OrderBy(s => s.EloRank == 0 ? int.MaxValue : s.EloRank) + .ThenByDescending(s => s.EloScore) + .Take(10) + .Select((stat, index) => + { + var rank = stat.EloRank > 0 ? stat.EloRank : index + 1; + var name = string.IsNullOrWhiteSpace(stat.DisplayName) ? stat.ModelName : stat.DisplayName; + return $"{rank}) {EscapeMarkdown(name)} | {EscapeMarkdown(stat.ProviderName)} | Elo {stat.EloScore:F0} | Win {stat.WinRate:F1}%"; + }); + + return "Top Models\n\n" + string.Join("\n", top); + } + + public static string Truncate(string? value, int maxLength = MaxMessageBodyLength) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "(empty response)"; + } + + if (value.Length <= maxLength) + { + return value; + } + + return value[..Math.Max(0, maxLength - 3)] + "..."; + } + + public static string EscapeMarkdown(string text) + { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + return text + .Replace("_", "\\_") + .Replace("*", "\\*") + .Replace("[", "\\[") + .Replace("]", "\\]") + .Replace("(", "\\(") + .Replace(")", "\\)") + .Replace("~", "\\~") + .Replace("`", "\\`") + .Replace(">", "\\>") + .Replace("#", "\\#") + .Replace("+", "\\+") + .Replace("-", "\\-") + .Replace("=", "\\=") + .Replace("|", "\\|") + .Replace("{", "\\{") + .Replace("}", "\\}") + .Replace("!", "\\!"); + } + } +} diff --git a/src/DualMind.API/Bot/TelegramSessionStore.cs b/src/DualMind.API/Bot/TelegramSessionStore.cs new file mode 100644 index 0000000..bb28689 --- /dev/null +++ b/src/DualMind.API/Bot/TelegramSessionStore.cs @@ -0,0 +1,141 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; +using DualMind.API.Core.Services; +using DualMind.API.Infrastructure.Data; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json.Linq; + +namespace DualMind.API.Bot +{ + public class TelegramSessionStore : ITelegramSessionStore + { + private readonly ISupabaseService _supabase; + private readonly EncryptionService _encryption; + private readonly ILogger _logger; + + public TelegramSessionStore( + ISupabaseService supabase, + EncryptionService encryption, + ILogger logger) + { + _supabase = supabase; + _encryption = encryption; + _logger = logger; + } + + public async Task GetSessionAsync(long chatId, CancellationToken cancellationToken) + { + try + { + var row = await _supabase.SelectSingleAsync( + "telegram_sessions", + "*", + $"telegram_chat_id=eq.{chatId}"); + + if (row == null) + { + return null; + } + + var encryptedJwt = row["jwt_token"]?.ToString(); + if (string.IsNullOrWhiteSpace(encryptedJwt)) + { + return null; + } + + return new TelegramAuthSession + { + ChatId = chatId, + AccessToken = TryDecrypt(encryptedJwt) ?? encryptedJwt, + RefreshToken = TryDecrypt(row["refresh_token"]?.ToString()), + ExpiresAt = ParseTimestamp(row["jwt_expires_at"]), + UpdatedAt = ParseTimestamp(row["updated_at"]) ?? DateTimeOffset.UtcNow + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load telegram session for chat {ChatId}", chatId); + throw; + } + } + + public async Task SaveSessionAsync(TelegramAuthSession session, CancellationToken cancellationToken) + { + var payload = new + { + telegram_chat_id = session.ChatId, + jwt_token = Encrypt(session.AccessToken), + refresh_token = Encrypt(session.RefreshToken), + jwt_expires_at = session.ExpiresAt?.UtcDateTime, + updated_at = DateTime.UtcNow + }; + + try + { + await _supabase.UpsertAsync("telegram_sessions", payload); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to persist telegram session for chat {ChatId}", session.ChatId); + throw; + } + } + + public async Task DeleteSessionAsync(long chatId, CancellationToken cancellationToken) + { + try + { + await _supabase.DeleteAsync("telegram_sessions", $"telegram_chat_id=eq.{chatId}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete telegram session for chat {ChatId}", chatId); + throw; + } + } + + private string? Encrypt(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + return _encryption.Encrypt(token); + } + + private string? TryDecrypt(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + try + { + return _encryption.Decrypt(token); + } + catch + { + return token; + } + } + + private static DateTimeOffset? ParseTimestamp(JToken? token) + { + if (token == null || token.Type == JTokenType.Null) + { + return null; + } + + if (DateTimeOffset.TryParse(token.ToString(), out var value)) + { + return value; + } + + return null; + } + } +} diff --git a/src/DualMind.API/Bot/TelegramStateCache.cs b/src/DualMind.API/Bot/TelegramStateCache.cs new file mode 100644 index 0000000..97cd3b6 --- /dev/null +++ b/src/DualMind.API/Bot/TelegramStateCache.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; + +namespace DualMind.API.Bot +{ + public class TelegramStateCache + { + private readonly ConcurrentDictionary _states = new(); + private readonly ITelegramSessionStore _sessionStore; + private readonly TimeProvider _timeProvider; + + public TelegramStateCache(ITelegramSessionStore sessionStore, TimeProvider timeProvider) + { + _sessionStore = sessionStore; + _timeProvider = timeProvider; + } + + public TelegramUserState GetState(long chatId) => _states.GetOrAdd(chatId, _ => new TelegramUserState()); + + public void SetAwaitingEmail(long chatId) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + state.Mode = TelegramUserMode.WaitingForEmail; + state.PendingEmail = null; + } + } + + public void SetAwaitingPassword(long chatId, string email) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + state.Mode = TelegramUserMode.WaitingForPassword; + state.PendingEmail = email; + } + } + + public void SetAwaitingBattlePrompt(long chatId) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + state.Mode = TelegramUserMode.WaitingForBattlePrompt; + } + } + + public void ClearConversationState(long chatId) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + state.Mode = TelegramUserMode.Idle; + state.PendingEmail = null; + } + } + + public async Task GetSessionAsync(long chatId, CancellationToken cancellationToken) + { + var state = GetState(chatId); + if (state.SessionLoaded) + { + return state.Session; + } + + var loadedSession = await _sessionStore.GetSessionAsync(chatId, cancellationToken); + lock (state.SyncRoot) + { + if (!state.SessionLoaded) + { + state.Session = loadedSession; + state.SessionLoaded = true; + } + + return state.Session; + } + } + + public async Task SaveSessionAsync(long chatId, TelegramAuthSession session, CancellationToken cancellationToken) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + state.Session = session; + state.SessionLoaded = true; + } + + await _sessionStore.SaveSessionAsync(session, cancellationToken); + } + + public async Task RemoveSessionAsync(long chatId, CancellationToken cancellationToken) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + state.Session = null; + state.SessionLoaded = true; + } + + await _sessionStore.DeleteSessionAsync(chatId, cancellationToken); + } + + public bool TryBeginBattleCooldown(long chatId, TimeSpan cooldown, out TimeSpan remaining) + { + var state = GetState(chatId); + var now = _timeProvider.GetUtcNow(); + + lock (state.SyncRoot) + { + if (state.CooldownUntil.HasValue && state.CooldownUntil.Value > now) + { + remaining = state.CooldownUntil.Value - now; + return false; + } + + state.CooldownUntil = now.Add(cooldown); + remaining = TimeSpan.Zero; + return true; + } + } + + public BattleSession? GetActiveBattle(long chatId) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + return state.ActiveBattle; + } + } + + public void SetActiveBattle(long chatId, BattleSession battleSession) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + state.ActiveBattle = battleSession; + state.Mode = TelegramUserMode.Idle; + } + } + + public bool TryBeginVote(long chatId, Guid comparisonId, string voteChoice, out BattleSession? session) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + session = state.ActiveBattle; + if (session == null || session.ComparisonId != comparisonId) + { + return false; + } + + return session.TryBeginVote(voteChoice); + } + } + + public void ResetVote(long chatId) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + state.ActiveBattle?.ResetVote(); + } + } + + public void CompleteBattle(long chatId) + { + var state = GetState(chatId); + lock (state.SyncRoot) + { + state.ActiveBattle?.MarkVoteSubmitted(); + state.ActiveBattle = null; + } + } + } +} diff --git a/src/DualMind.API/Bot/TelegramUpdateHandler.cs b/src/DualMind.API/Bot/TelegramUpdateHandler.cs new file mode 100644 index 0000000..dc5ea07 --- /dev/null +++ b/src/DualMind.API/Bot/TelegramUpdateHandler.cs @@ -0,0 +1,304 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Commands; +using DualMind.API.Bot.Models; +using DualMind.API.Bot.Transport; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DualMind.API.Bot +{ + public class TelegramUpdateHandler + { + private readonly StartCommandHandler _startCommandHandler; + private readonly HelpCommandHandler _helpCommandHandler; + private readonly BattleCommandHandler _battleCommandHandler; + private readonly StatsCommandHandler _statsCommandHandler; + private readonly CancelCommandHandler _cancelCommandHandler; + private readonly ITelegramAuthService _authService; + private readonly ITelegramBotTransport _transport; + private readonly TelegramStateCache _stateCache; + private readonly TelegramBotOptions _options; + private readonly ILogger _logger; + + public TelegramUpdateHandler( + StartCommandHandler startCommandHandler, + HelpCommandHandler helpCommandHandler, + BattleCommandHandler battleCommandHandler, + StatsCommandHandler statsCommandHandler, + CancelCommandHandler cancelCommandHandler, + ITelegramAuthService authService, + ITelegramBotTransport transport, + TelegramStateCache stateCache, + IOptions options, + ILogger logger) + { + _startCommandHandler = startCommandHandler; + _helpCommandHandler = helpCommandHandler; + _battleCommandHandler = battleCommandHandler; + _statsCommandHandler = statsCommandHandler; + _cancelCommandHandler = cancelCommandHandler; + _authService = authService; + _transport = transport; + _stateCache = stateCache; + _options = options.Value; + _logger = logger; + } + + public TelegramUpdateHandler( + StartCommandHandler startCommandHandler, + HelpCommandHandler helpCommandHandler, + BattleCommandHandler battleCommandHandler, + StatsCommandHandler statsCommandHandler, + ITelegramAuthService authService, + ITelegramBotTransport transport, + TelegramStateCache stateCache, + IOptions options, + ILogger logger) + : this( + startCommandHandler, + helpCommandHandler, + battleCommandHandler, + statsCommandHandler, + new CancelCommandHandler(transport, stateCache, options.Value.SignupUrl), + authService, + transport, + stateCache, + options, + logger) + { + } + + public async Task HandleAsync(TelegramIncomingUpdate update, CancellationToken cancellationToken) + { + if (!string.Equals(update.ChatType, "private", StringComparison.OrdinalIgnoreCase)) + { + if (update.IsCallback && update.CallbackQueryId != null) + { + await _transport.AnswerCallbackQueryAsync(update.CallbackQueryId, "Use this bot in a private chat", false, cancellationToken); + } + + return; + } + + if (update.IsCallback) + { + await HandleCallbackAsync(update, cancellationToken); + return; + } + + if (string.IsNullOrWhiteSpace(update.Text)) + { + await _transport.SendTextMessageAsync( + update.ChatId, + "Invalid input\n\nPlease send text messages only in this version of the bot", + null, + cancellationToken); + return; + } + + var text = update.Text.Trim(); + if (TryHandleCommand(update.ChatId, text, cancellationToken, out var commandTask)) + { + await commandTask!; + return; + } + + var state = _stateCache.GetState(update.ChatId); + switch (state.Mode) + { + case TelegramUserMode.WaitingForEmail: + await HandleEmailAsync(update.ChatId, text, cancellationToken); + break; + case TelegramUserMode.WaitingForPassword: + await HandlePasswordAsync(update.ChatId, update.MessageId, text, cancellationToken); + break; + case TelegramUserMode.WaitingForBattlePrompt: + await _battleCommandHandler.HandlePromptAsync(update.ChatId, text, cancellationToken); + break; + default: + await _helpCommandHandler.HandleAsync(update.ChatId, cancellationToken); + break; + } + } + + private async Task HandleCallbackAsync(TelegramIncomingUpdate update, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(update.CallbackQueryId) || string.IsNullOrWhiteSpace(update.CallbackData)) + { + return; + } + + var callbackData = update.CallbackData.Trim(); + switch (callbackData) + { + case "action:signin": + await _transport.AnswerCallbackQueryAsync(update.CallbackQueryId, null, false, cancellationToken); + await BeginSignInAsync(update.ChatId, cancellationToken); + return; + case "action:help": + await _transport.AnswerCallbackQueryAsync(update.CallbackQueryId, null, false, cancellationToken); + await _helpCommandHandler.HandleAsync(update.ChatId, cancellationToken); + return; + case "action:cancel": + await _transport.AnswerCallbackQueryAsync(update.CallbackQueryId, "Action cancelled", false, cancellationToken); + await _cancelCommandHandler.HandleAsync(update.ChatId, cancellationToken); + return; + case "action:battle": + await _transport.AnswerCallbackQueryAsync(update.CallbackQueryId, null, false, cancellationToken); + await _battleCommandHandler.HandleCommandAsync(update.ChatId, cancellationToken); + return; + case "action:stats": + await _transport.AnswerCallbackQueryAsync(update.CallbackQueryId, null, false, cancellationToken); + await _statsCommandHandler.HandleAsync(update.ChatId, cancellationToken); + return; + } + + if (callbackData.StartsWith("vote:", StringComparison.OrdinalIgnoreCase)) + { + var parts = callbackData.Split(':', 3); + if (parts.Length == 3 && Guid.TryParse(parts[1], out var comparisonId)) + { + await _battleCommandHandler.HandleVoteAsync(update.ChatId, update.CallbackQueryId, comparisonId, parts[2], cancellationToken); + return; + } + } + + await _transport.AnswerCallbackQueryAsync(update.CallbackQueryId, "Unknown action", false, cancellationToken); + await _transport.SendTextMessageAsync( + update.ChatId, + "Unknown action\n\nUse /start to reset the chat flow", + null, + cancellationToken); + } + + private bool TryHandleCommand(long chatId, string text, CancellationToken cancellationToken, out Task? commandTask) + { + commandTask = null; + + if (!text.StartsWith("/", StringComparison.Ordinal)) + { + return false; + } + + var firstSpace = text.IndexOf(' '); + var command = firstSpace >= 0 ? text[..firstSpace] : text; + var arguments = firstSpace >= 0 ? text[(firstSpace + 1)..].Trim() : string.Empty; + var botMentionIndex = command.IndexOf('@'); + if (botMentionIndex >= 0) + { + command = command[..botMentionIndex]; + } + + switch (command.ToLowerInvariant()) + { + case "/start": + _stateCache.ClearConversationState(chatId); + commandTask = _startCommandHandler.HandleAsync(chatId, cancellationToken); + return true; + case "/help": + commandTask = _helpCommandHandler.HandleAsync(chatId, cancellationToken); + return true; + case "/stats": + commandTask = _statsCommandHandler.HandleAsync(chatId, cancellationToken); + return true; + case "/cancel": + commandTask = _cancelCommandHandler.HandleAsync(chatId, cancellationToken); + return true; + case "/battle": + commandTask = string.IsNullOrWhiteSpace(arguments) + ? _battleCommandHandler.HandleCommandAsync(chatId, cancellationToken) + : _battleCommandHandler.HandlePromptAsync(chatId, arguments, cancellationToken); + return true; + default: + return false; + } + } + + private async Task BeginSignInAsync(long chatId, CancellationToken cancellationToken) + { + var session = await _authService.GetValidSessionAsync(chatId, cancellationToken); + if (session != null) + { + await _transport.SendTextMessageAsync( + chatId, + "You are already signed in\n\nUse /battle to start a comparison or /stats to view the leaderboard", + TelegramMessageFormatter.BuildMainMenuKeyboard(_options.SignupUrl), + cancellationToken); + return; + } + + _stateCache.SetAwaitingEmail(chatId); + await _transport.SendTextMessageAsync( + chatId, + "Welcome\n\nSend the email address you use for DualMind Arena", + TelegramMessageFormatter.BuildCancelKeyboard(), + cancellationToken); + } + + private async Task HandleEmailAsync(long chatId, string email, CancellationToken cancellationToken) + { + if (!email.Contains("@", StringComparison.Ordinal)) + { + await _transport.SendTextMessageAsync( + chatId, + "Invalid email\n\nSend a valid email address to continue", + TelegramMessageFormatter.BuildCancelKeyboard(), + cancellationToken); + return; + } + + _stateCache.SetAwaitingPassword(chatId, email); + await _transport.SendTextMessageAsync( + chatId, + "Password required\n\nSend your password and I will delete that message immediately", + TelegramMessageFormatter.BuildCancelKeyboard(), + cancellationToken); + } + + private async Task HandlePasswordAsync(long chatId, int messageId, string password, CancellationToken cancellationToken) + { + var state = _stateCache.GetState(chatId); + var email = state.PendingEmail; + if (string.IsNullOrWhiteSpace(email)) + { + _stateCache.SetAwaitingEmail(chatId); + await _transport.SendTextMessageAsync( + chatId, + "Session restarted\n\nSend your email address to continue", + TelegramMessageFormatter.BuildCancelKeyboard(), + cancellationToken); + return; + } + + try + { + await _transport.DeleteMessageAsync(chatId, messageId, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete password message {MessageId} for chat {ChatId}", messageId, chatId); + } + + try + { + await _authService.SignInAsync(chatId, email, password, cancellationToken); + _stateCache.ClearConversationState(chatId); + await _transport.SendTextMessageAsync( + chatId, + "You are signed in\n\nUse /battle to start a blind comparison or /stats to check the leaderboard", + TelegramMessageFormatter.BuildMainMenuKeyboard(_options.SignupUrl), + cancellationToken); + } + catch (TelegramAuthException ex) + { + await _transport.SendTextMessageAsync( + chatId, + $"Sign in failed\n\n{TelegramMessageFormatter.EscapeMarkdown(ex.Message)}\n\nSend your password again or /cancel to stop", + TelegramMessageFormatter.BuildCancelKeyboard(), + cancellationToken); + } + } + } +} diff --git a/src/DualMind.API/Bot/Transport/ITelegramBotTransport.cs b/src/DualMind.API/Bot/Transport/ITelegramBotTransport.cs new file mode 100644 index 0000000..3e2e54a --- /dev/null +++ b/src/DualMind.API/Bot/Transport/ITelegramBotTransport.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; +using Telegram.Bot.Types.ReplyMarkups; + +namespace DualMind.API.Bot.Transport +{ + public interface ITelegramBotTransport + { + Task DeleteWebhookAsync(bool dropPendingUpdates, CancellationToken cancellationToken); + Task> GetUpdatesAsync(long? offset, CancellationToken cancellationToken); + Task SendTextMessageAsync(long chatId, string text, InlineKeyboardMarkup? replyMarkup, CancellationToken cancellationToken); + Task EditMessageTextAsync(long chatId, int messageId, string text, InlineKeyboardMarkup? replyMarkup, CancellationToken cancellationToken); + Task DeleteMessageAsync(long chatId, int messageId, CancellationToken cancellationToken); + Task AnswerCallbackQueryAsync(string callbackQueryId, string? text, bool showAlert, CancellationToken cancellationToken); + Task SetMyCommandsAsync(IEnumerable commands, CancellationToken cancellationToken); + } +} diff --git a/src/DualMind.API/Bot/Transport/TelegramBotTransport.cs b/src/DualMind.API/Bot/Transport/TelegramBotTransport.cs new file mode 100644 index 0000000..a5bb501 --- /dev/null +++ b/src/DualMind.API/Bot/Transport/TelegramBotTransport.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Telegram.Bot; +using Telegram.Bot.Types; +using Telegram.Bot.Types.Enums; +using Telegram.Bot.Types.ReplyMarkups; + +namespace DualMind.API.Bot.Transport +{ + public class TelegramBotTransport : ITelegramBotTransport + { + private readonly ITelegramBotClient _client; + private readonly ILogger _logger; + + public TelegramBotTransport(IOptions options, ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + + var token = options.Value.BotToken ?? throw new InvalidOperationException("Telegram bot token is missing."); + _client = new TelegramBotClient(token); + _logger = logger; + } + + public Task DeleteWebhookAsync(bool dropPendingUpdates, CancellationToken cancellationToken) => + _client.DeleteWebhook(dropPendingUpdates, cancellationToken: cancellationToken); + + public async Task> GetUpdatesAsync(long? offset, CancellationToken cancellationToken) + { + var updates = await _client.GetUpdates( + offset: offset.HasValue ? checked((int)offset.Value) : null, + timeout: 30, + allowedUpdates: new[] { UpdateType.Message, UpdateType.CallbackQuery }, + cancellationToken: cancellationToken); + + return updates + .Select(MapUpdate) + .Where(update => update != null) + .Cast() + .ToList(); + } + + public async Task SendTextMessageAsync(long chatId, string text, InlineKeyboardMarkup? replyMarkup, CancellationToken cancellationToken) + { + var message = await _client.SendMessage( + chatId: chatId, + text: text, + parseMode: ParseMode.MarkdownV2, + replyMarkup: replyMarkup, + cancellationToken: cancellationToken); + + return new TelegramSentMessage + { + ChatId = chatId, + MessageId = message.MessageId, + Text = message.Text + }; + } + + public async Task EditMessageTextAsync(long chatId, int messageId, string text, InlineKeyboardMarkup? replyMarkup, CancellationToken cancellationToken) + { + await _client.EditMessageText( + chatId: chatId, + messageId: messageId, + text: text, + parseMode: ParseMode.MarkdownV2, + replyMarkup: replyMarkup, + cancellationToken: cancellationToken); + } + + public Task DeleteMessageAsync(long chatId, int messageId, CancellationToken cancellationToken) => + _client.DeleteMessage(chatId, messageId, cancellationToken); + + public Task AnswerCallbackQueryAsync(string callbackQueryId, string? text, bool showAlert, CancellationToken cancellationToken) => + _client.AnswerCallbackQuery(callbackQueryId, text, showAlert: showAlert, cancellationToken: cancellationToken); + + public Task SetMyCommandsAsync(IEnumerable commands, CancellationToken cancellationToken) + { + var botCommands = commands.Select(c => new BotCommand { Command = c.Command, Description = c.Description }); + return _client.SetMyCommands(botCommands, cancellationToken: cancellationToken); + } + + private TelegramIncomingUpdate? MapUpdate(Update update) + { + var chat = update.Message?.Chat ?? update.CallbackQuery?.Message?.Chat; + if (chat == null) + { + return null; + } + + return new TelegramIncomingUpdate + { + UpdateId = update.Id, + ChatId = chat.Id, + ChatType = chat.Type.ToString().ToLowerInvariant(), + MessageId = update.Message?.MessageId ?? update.CallbackQuery?.Message?.MessageId ?? 0, + Text = update.Message?.Text, + CallbackQueryId = update.CallbackQuery?.Id, + CallbackData = update.CallbackQuery?.Data + }; + } + } +} diff --git a/src/DualMind.API/Core/Services/LeaderboardModelSelector.cs b/src/DualMind.API/Core/Services/LeaderboardModelSelector.cs index 0219898..9620db9 100644 --- a/src/DualMind.API/Core/Services/LeaderboardModelSelector.cs +++ b/src/DualMind.API/Core/Services/LeaderboardModelSelector.cs @@ -21,6 +21,16 @@ public LeaderboardModelSelector(IModelStatsService modelStatsService, IModelSele { try { + var allModels = _modelSelector.GetAllModels(); + var availableNames = new HashSet( + allModels.Select(m => m.Name), + StringComparer.OrdinalIgnoreCase); + + if (availableNames.Count < 2) + { + return await _modelSelector.GetTwoRandomModelsAsync(); + } + var stats = await _modelStatsService.GetModelStatsAsync(); if (stats == null || stats.Count == 0) @@ -28,13 +38,16 @@ public LeaderboardModelSelector(IModelStatsService modelStatsService, IModelSele return await _modelSelector.GetTwoRandomModelsAsync(); } - var topModel = stats.OrderByDescending(s => s.WinRate).FirstOrDefault(); + var topModel = stats + .Where(s => !string.IsNullOrWhiteSpace(s.ModelName) && availableNames.Contains(s.ModelName)) + .OrderByDescending(s => s.WinRate) + .FirstOrDefault(); + if (topModel == null) { return await _modelSelector.GetTwoRandomModelsAsync(); } - var allModels = _modelSelector.GetAllModels(); var otherModels = allModels .Where(m => !m.Name.Equals(topModel.ModelName, StringComparison.OrdinalIgnoreCase)) .ToList(); diff --git a/src/DualMind.API/Core/Services/ModelSelector.cs b/src/DualMind.API/Core/Services/ModelSelector.cs index d9189fa..9cca394 100644 --- a/src/DualMind.API/Core/Services/ModelSelector.cs +++ b/src/DualMind.API/Core/Services/ModelSelector.cs @@ -5,20 +5,29 @@ using Newtonsoft.Json.Linq; using DualMind.API.Infrastructure.Data; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; namespace DualMind.API.Core.Services { public class ModelSelector : IModelSelector { + private static readonly HashSet SupportedProviders = new(StringComparer.OrdinalIgnoreCase) + { + "groq", + "google" + }; + private readonly ISupabaseService _supabase; private readonly IMemoryCache _cache; + private readonly ILogger _logger; private readonly Random _random = new Random(); private const string CacheKey = "ai_models_cache"; - public ModelSelector(ISupabaseService supabase, IMemoryCache cache) + public ModelSelector(ISupabaseService supabase, IMemoryCache cache, ILogger logger) { _supabase = supabase; _cache = cache; + _logger = logger; } private async Task> LoadModelsAsync(bool force = false) @@ -34,7 +43,7 @@ private async Task> LoadModelsAsync(bool force = false) "status=eq.active&order=created_at.desc" ); - var list = (rows ?? new List()) + var allModels = (rows ?? new List()) .Select(r => new ModelDefinition { Id = r["model_id"]?.ToString(), @@ -45,6 +54,22 @@ private async Task> LoadModelsAsync(bool force = false) .Where(m => !string.IsNullOrWhiteSpace(m.Name)) .ToList(); + var unsupportedModels = allModels + .Where(m => !string.IsNullOrWhiteSpace(m.Provider) && !SupportedProviders.Contains(m.Provider)) + .ToList(); + + if (unsupportedModels.Count > 0) + { + _logger.LogWarning( + "Ignoring {Count} active models with unsupported providers: {Providers}", + unsupportedModels.Count, + string.Join(", ", unsupportedModels.Select(m => m.Provider).Distinct(StringComparer.OrdinalIgnoreCase))); + } + + var list = allModels + .Where(m => string.IsNullOrWhiteSpace(m.Provider) || SupportedProviders.Contains(m.Provider)) + .ToList(); + var cacheEntryOptions = new MemoryCacheEntryOptions() .SetAbsoluteExpiration(TimeSpan.FromMinutes(5)); @@ -97,7 +122,7 @@ private async Task GetRandomModelInternalAsync() { var models = await LoadModelsAsync(); if (models.Count == 0) - throw new InvalidOperationException("No active models found in Supabase (ai_models)"); + throw new InvalidOperationException("No active models found for the providers supported by this backend."); var index = _random.Next(models.Count); return models[index].Name; @@ -107,7 +132,7 @@ private async Task GetRandomModelInternalAsync() { var models = await LoadModelsAsync(); if (models.Count < 2) - throw new InvalidOperationException("Need at least 2 active models in Supabase (ai_models)"); + throw new InvalidOperationException("Need at least 2 active models from supported providers to run a battle."); var shuffled = models.OrderBy(_ => _random.Next()).Take(2).ToList(); return (shuffled[0].Name, shuffled[1].Name); diff --git a/src/DualMind.API/DualMind.API.csproj b/src/DualMind.API/DualMind.API.csproj index 31dfee8..b5a0b7f 100644 --- a/src/DualMind.API/DualMind.API.csproj +++ b/src/DualMind.API/DualMind.API.csproj @@ -14,6 +14,7 @@ + diff --git a/src/DualMind.API/Infrastructure/Data/SupabaseService.cs b/src/DualMind.API/Infrastructure/Data/SupabaseService.cs index 2708aa6..a8c34d0 100644 --- a/src/DualMind.API/Infrastructure/Data/SupabaseService.cs +++ b/src/DualMind.API/Infrastructure/Data/SupabaseService.cs @@ -75,6 +75,8 @@ public async Task SelectSingleAsync(string table, string select = "*", str if (!response.IsSuccessStatusCode) { + if (response.StatusCode == HttpStatusCode.NotAcceptable && IsNoRowsResponse(content)) + return default; if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return default; throw new Exception($"Supabase error: {content}"); @@ -83,6 +85,27 @@ public async Task SelectSingleAsync(string table, string select = "*", str return JsonConvert.DeserializeObject(content); } + private static bool IsNoRowsResponse(string content) + { + if (string.IsNullOrWhiteSpace(content)) + return false; + + try + { + var body = JObject.Parse(content); + var details = body["details"]?.ToString(); + var code = body["code"]?.ToString(); + + return string.Equals(code, "PGRST116", StringComparison.OrdinalIgnoreCase) + && !string.IsNullOrWhiteSpace(details) + && details.Contains("0 rows", StringComparison.OrdinalIgnoreCase); + } + catch + { + return false; + } + } + public async Task InsertAsync(string table, object data) { var url = $"{RestUrl}/{table}"; diff --git a/src/DualMind.API/Program.cs b/src/DualMind.API/Program.cs index d36c7b5..916c814 100644 --- a/src/DualMind.API/Program.cs +++ b/src/DualMind.API/Program.cs @@ -1,15 +1,16 @@ using System; +using DualMind.API.Bot; using DualMind.API.Infrastructure.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.OpenApi.Models; -var builder = WebApplication.CreateBuilder(args); - // Load .env EnvConfig.Load(); +var builder = WebApplication.CreateBuilder(args); + // Add services to the container. builder.Services.AddControllers() .AddNewtonsoftJson(options => @@ -178,6 +179,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddTelegramBot(builder.Configuration); // Register Admin Services builder.Services.AddHttpClient(); diff --git a/src/DualMind.API/appsettings.Development.json b/src/DualMind.API/appsettings.Development.json index 0c208ae..bc54557 100644 --- a/src/DualMind.API/appsettings.Development.json +++ b/src/DualMind.API/appsettings.Development.json @@ -4,5 +4,11 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Telegram": { + "SignupUrl": "https://dualmind.arena/signup", + "BattleCooldownSeconds": 15, + "SoftTimeoutSeconds": 30, + "ApiTimeoutSeconds": 75 } } diff --git a/src/DualMind.API/appsettings.json b/src/DualMind.API/appsettings.json index c50e02d..239e5cd 100644 --- a/src/DualMind.API/appsettings.json +++ b/src/DualMind.API/appsettings.json @@ -10,5 +10,11 @@ "Url": "https://calqfzajyidkdzbaswjp.supabase.co", "Key": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNhbHFmemFqeWlka2R6YmFzd2pwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjQyNzMwODMsImV4cCI6MjA3OTg0OTA4M30.ptXyUNCcAhGi9u2kVDHOxSBvQv0W72S5HHqkIFXQS08", "ServiceKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImNhbHFmemFqeWlka2R6YmFzd2pwIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc2NDI3MzA4MywiZXhwIjoyMDc5ODQ5MDgzfQ.bt3MjR2dItU1FT3yRTlNkNhNPRFO5_NBO1lMCqQy1d8" + }, + "Telegram": { + "SignupUrl": "https://dualmind.arena/signup", + "BattleCooldownSeconds": 15, + "SoftTimeoutSeconds": 30, + "ApiTimeoutSeconds": 75 } -} \ No newline at end of file +} diff --git a/tests/DualMind.API.Tests/BattleCommandHandlerTests.cs b/tests/DualMind.API.Tests/BattleCommandHandlerTests.cs new file mode 100644 index 0000000..7f01a07 --- /dev/null +++ b/tests/DualMind.API.Tests/BattleCommandHandlerTests.cs @@ -0,0 +1,312 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.AI.Contracts; +using DualMind.API.Bot; +using DualMind.API.Bot.Commands; +using DualMind.API.Bot.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace DualMind.API.Tests; + +public class BattleCommandHandlerTests +{ + [Fact] + public async Task HandlePromptAsync_RequiresAuthenticationBeforeStartingBattle() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var transport = new FakeTelegramBotTransport(); + var cache = new TelegramStateCache(new InMemorySessionStore(), timeProvider); + var authService = new FakeTelegramAuthService(); + var handler = CreateHandler(authService, new FakeDualMindBotApiClient(), transport, cache, timeProvider); + + await handler.HandlePromptAsync(1, "test prompt", CancellationToken.None); + + var message = Assert.Single(transport.SentMessages); + Assert.Contains("Sign in first", message.Message.Text); + Assert.NotNull(message.ReplyMarkup); + Assert.Empty(transport.EditedMessages); + Assert.Null(cache.GetActiveBattle(1)); + } + + [Fact] + public async Task HandlePromptAsync_ShowsSoftTimeoutBeforeBattleCompletes() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var transport = new FakeTelegramBotTransport(); + var cache = new TelegramStateCache(new InMemorySessionStore(), timeProvider); + var authService = CreateSignedInAuthService(); + var apiClient = new FakeDualMindBotApiClient + { + StartBattleHandler = async (_, _, cancellationToken) => + { + await Task.Delay(TimeSpan.FromMilliseconds(1100), cancellationToken); + return CreateBattleResponse(); + } + }; + + var handler = CreateHandler( + authService, + apiClient, + transport, + cache, + timeProvider, + new TelegramBotOptions { SoftTimeoutSeconds = 1, BattleCooldownSeconds = 15 }); + + await handler.HandlePromptAsync(1, "slow prompt", CancellationToken.None); + + Assert.Contains(transport.EditedMessages, message => message.Text.Contains("Taking longer than usual", StringComparison.Ordinal)); + Assert.Equal(3, transport.SentMessages.Count); + Assert.NotNull(cache.GetActiveBattle(1)); + } + + [Fact] + public async Task HandlePromptAsync_RejectsNewBattleWhenOneIsAlreadyActive() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var transport = new FakeTelegramBotTransport(); + var cache = new TelegramStateCache(new InMemorySessionStore(), timeProvider); + var authService = CreateSignedInAuthService(); + var handler = CreateHandler(authService, new FakeDualMindBotApiClient(), transport, cache, timeProvider); + + cache.SetActiveBattle(1, TestBattleFactory.CreateBattleSession()); + await handler.HandlePromptAsync(1, "another prompt", CancellationToken.None); + + var message = Assert.Single(transport.SentMessages); + Assert.Contains("Finish voting on the current battle", message.Message.Text); + } + + [Fact] + public async Task HandlePromptAsync_FormatsAndTruncatesMaskedResponses() + { + var longResponse = new string('a', TelegramMessageFormatter.MaxMessageBodyLength + 250); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var transport = new FakeTelegramBotTransport(); + var cache = new TelegramStateCache(new InMemorySessionStore(), timeProvider); + var authService = CreateSignedInAuthService(); + var apiClient = new FakeDualMindBotApiClient + { + StartBattleHandler = (_, _, _) => Task.FromResult(new DualChatApiResponse + { + Success = true, + ComparisonId = Guid.NewGuid(), + Agent1 = new ChatResponse + { + Message = longResponse, + Model = new ModelInfo { Name = "model-a", DisplayName = "Model A" } + }, + Agent2 = new ChatResponse + { + Message = longResponse, + Model = new ModelInfo { Name = "model-b", DisplayName = "Model B" } + } + }) + }; + + var handler = CreateHandler(authService, apiClient, transport, cache, timeProvider); + + await handler.HandlePromptAsync(1, "truncate prompt", CancellationToken.None); + + var agentAMessage = transport.SentMessages[1].Message.Text!; + var agentBMessage = transport.SentMessages[2].Message.Text!; + Assert.StartsWith("Agent A", agentAMessage); + Assert.StartsWith("Agent B", agentBMessage); + Assert.EndsWith("...", agentAMessage); + Assert.EndsWith("...", agentBMessage); + Assert.True(agentAMessage.Length < 4096); + Assert.True(agentBMessage.Length < 4096); + } + + [Fact] + public async Task HandlePromptAsync_ReportsTimeoutWhenBattleTaskIsCanceled() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var transport = new FakeTelegramBotTransport(); + var cache = new TelegramStateCache(new InMemorySessionStore(), timeProvider); + var authService = CreateSignedInAuthService(); + var apiClient = new FakeDualMindBotApiClient + { + StartBattleHandler = (_, _, _) => throw new TaskCanceledException("timed out") + }; + + var handler = CreateHandler(authService, apiClient, transport, cache, timeProvider); + + await handler.HandlePromptAsync(1, "timeout prompt", CancellationToken.None); + + var edit = Assert.Single(transport.EditedMessages); + Assert.Contains("timed out", edit.Text, StringComparison.OrdinalIgnoreCase); + Assert.Null(cache.GetActiveBattle(1)); + } + + [Theory] + [InlineData("left")] + [InlineData("right")] + [InlineData("tie")] + [InlineData("both-bad")] + public async Task HandleVoteAsync_SubmitsSupportedVotesAndRevealsModels(string voteChoice) + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var transport = new FakeTelegramBotTransport(); + var cache = new TelegramStateCache(new InMemorySessionStore(), timeProvider); + var authService = CreateSignedInAuthService(); + var comparisonId = Guid.NewGuid(); + var capturedVote = string.Empty; + var apiClient = new FakeDualMindBotApiClient + { + SubmitVoteHandler = (_, submittedComparisonId, choice, _, _) => + { + capturedVote = choice; + Assert.Equal(comparisonId, submittedComparisonId); + return Task.FromResult(new VoteApiResponse + { + Success = true, + Message = "Vote recorded successfully" + }); + } + }; + + cache.SetActiveBattle(1, TestBattleFactory.CreateBattleSession(comparisonId)); + var handler = CreateHandler(authService, apiClient, transport, cache, timeProvider); + + await handler.HandleVoteAsync(1, "cb-1", comparisonId, voteChoice, CancellationToken.None); + + Assert.Equal(voteChoice, capturedVote); + Assert.Equal(2, transport.EditedMessages.Count); + Assert.All(transport.EditedMessages, edit => Assert.Contains("Model ", edit.Text)); + Assert.Null(cache.GetActiveBattle(1)); + } + + [Fact] + public async Task HandleVoteAsync_RejectsDuplicateOrStaleCallbacks() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var transport = new FakeTelegramBotTransport(); + var cache = new TelegramStateCache(new InMemorySessionStore(), timeProvider); + var authService = CreateSignedInAuthService(); + var comparisonId = Guid.NewGuid(); + var handler = CreateHandler(authService, new FakeDualMindBotApiClient(), transport, cache, timeProvider); + + cache.SetActiveBattle(1, TestBattleFactory.CreateBattleSession(comparisonId)); + await handler.HandleVoteAsync(1, "cb-1", comparisonId, "left", CancellationToken.None); + await handler.HandleVoteAsync(1, "cb-2", comparisonId, "right", CancellationToken.None); + + Assert.Contains(transport.CallbackAnswers, answer => answer.CallbackQueryId == "cb-2" && answer.Text == "That vote is no longer available."); + } + + [Fact] + public async Task HandlePromptAsync_RetriesAfterUnauthorizedResponse() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var transport = new FakeTelegramBotTransport(); + var cache = new TelegramStateCache(new InMemorySessionStore(), timeProvider); + var refreshedSession = new TelegramAuthSession + { + ChatId = 1, + AccessToken = "new-token", + RefreshToken = "refresh-token", + ExpiresAt = timeProvider.GetUtcNow().AddHours(1) + }; + var authService = new FakeTelegramAuthService + { + GetValidHandler = (_, _) => Task.FromResult(new TelegramAuthSession + { + ChatId = 1, + AccessToken = "old-token", + RefreshToken = "refresh-token", + ExpiresAt = timeProvider.GetUtcNow().AddHours(1) + }), + ForceRefreshHandler = (_, _) => Task.FromResult(refreshedSession) + }; + + var attempt = 0; + var apiClient = new FakeDualMindBotApiClient + { + StartBattleHandler = (token, _, _) => + { + attempt++; + if (attempt == 1) + { + Assert.Equal("old-token", token); + throw new DualMindBotApiException("unauthorized", System.Net.HttpStatusCode.Unauthorized); + } + + Assert.Equal("new-token", token); + return Task.FromResult(CreateBattleResponse()); + } + }; + + var handler = CreateHandler(authService, apiClient, transport, cache, timeProvider); + + await handler.HandlePromptAsync(1, "retry prompt", CancellationToken.None); + + Assert.Equal(2, attempt); + Assert.NotNull(cache.GetActiveBattle(1)); + } + + private static BattleCommandHandler CreateHandler( + ITelegramAuthService authService, + FakeDualMindBotApiClient apiClient, + FakeTelegramBotTransport transport, + TelegramStateCache cache, + TimeProvider timeProvider, + TelegramBotOptions? options = null) => + new( + authService, + apiClient, + transport, + cache, + Options.Create(options ?? new TelegramBotOptions()), + timeProvider, + NullLogger.Instance); + + private static FakeTelegramAuthService CreateSignedInAuthService() => + new() + { + GetValidHandler = (_, _) => Task.FromResult(new TelegramAuthSession + { + ChatId = 1, + AccessToken = "access-token", + RefreshToken = "refresh-token", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }), + ForceRefreshHandler = (_, _) => Task.FromResult(new TelegramAuthSession + { + ChatId = 1, + AccessToken = "access-token", + RefreshToken = "refresh-token", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }) + }; + + private static DualChatApiResponse CreateBattleResponse() => + new() + { + Success = true, + ComparisonId = Guid.NewGuid(), + Agent1 = new ChatResponse + { + Message = "Agent A reply", + Model = new ModelInfo { Name = "model-a", DisplayName = "Model A" } + }, + Agent2 = new ChatResponse + { + Message = "Agent B reply", + Model = new ModelInfo { Name = "model-b", DisplayName = "Model B" } + } + }; + + private sealed class InMemorySessionStore : ITelegramSessionStore + { + public Task GetSessionAsync(long chatId, CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task SaveSessionAsync(TelegramAuthSession session, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task DeleteSessionAsync(long chatId, CancellationToken cancellationToken) => + Task.CompletedTask; + } +} diff --git a/tests/DualMind.API.Tests/BotTestDoubles.cs b/tests/DualMind.API.Tests/BotTestDoubles.cs new file mode 100644 index 0000000..9545baf --- /dev/null +++ b/tests/DualMind.API.Tests/BotTestDoubles.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.AI.Contracts; +using DualMind.API.Bot; +using DualMind.API.Bot.Models; +using DualMind.API.Bot.Transport; +using DualMind.API.Core.Models; +using DualMind.API.Infrastructure.Data; +using Newtonsoft.Json.Linq; +using Telegram.Bot.Types.ReplyMarkups; + +namespace DualMind.API.Tests; + +public sealed class FakeTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow; + + public FakeTimeProvider(DateTimeOffset utcNow) + { + _utcNow = utcNow; + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan delta) + { + _utcNow = _utcNow.Add(delta); + } +} + +public sealed class FakeSupabaseService : ISupabaseService +{ + public Dictionary TelegramSessions { get; } = new(); + + public Task> SelectAsync(string table, string select = "*", string? filter = null) => + throw new NotSupportedException(); + + public Task SelectSingleAsync(string table, string select = "*", string? filter = null) + { + if (table != "telegram_sessions") + { + throw new NotSupportedException(); + } + + var chatId = ParseChatId(filter); + if (!TelegramSessions.TryGetValue(chatId, out var row)) + { + return Task.FromResult(default(T)!); + } + + return Task.FromResult(row.ToObject()!); + } + + public Task InsertAsync(string table, object data) => + throw new NotSupportedException(); + + public Task UpsertAsync(string table, object data) + { + if (table != "telegram_sessions") + { + throw new NotSupportedException(); + } + + var row = JObject.FromObject(data); + var chatId = row["telegram_chat_id"]!.Value(); + TelegramSessions[chatId] = row; + return Task.FromResult(row.ToObject()!); + } + + public Task> UpdateAsync(string table, object data, string filter) => + throw new NotSupportedException(); + + public Task DeleteAsync(string table, string filter) + { + if (table != "telegram_sessions") + { + throw new NotSupportedException(); + } + + TelegramSessions.Remove(ParseChatId(filter)); + return Task.CompletedTask; + } + + public Task RpcAsync(string functionName, object? parameters = null) => + throw new NotSupportedException(); + + private static long ParseChatId(string? filter) + { + var marker = "telegram_chat_id=eq."; + if (string.IsNullOrWhiteSpace(filter) || !filter.StartsWith(marker, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Unexpected filter: {filter}"); + } + + return long.Parse(filter[marker.Length..]); + } +} + +public sealed class FakeTelegramBotTransport : ITelegramBotTransport +{ + private int _nextMessageId = 1; + + public List SentMessages { get; } = new(); + public List EditedMessages { get; } = new(); + public List<(long ChatId, int MessageId)> DeletedMessages { get; } = new(); + public List CallbackAnswers { get; } = new(); + public List RegisteredCommands { get; } = new(); + + public Task DeleteWebhookAsync(bool dropPendingUpdates, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task> GetUpdatesAsync(long? offset, CancellationToken cancellationToken) => + Task.FromResult>(Array.Empty()); + + public Task SendTextMessageAsync(long chatId, string text, InlineKeyboardMarkup? replyMarkup, CancellationToken cancellationToken) + { + var message = new TelegramSentMessage + { + ChatId = chatId, + MessageId = _nextMessageId++, + Text = text + }; + + SentMessages.Add(new SentMessageRecord(message, replyMarkup)); + return Task.FromResult(message); + } + + public Task EditMessageTextAsync(long chatId, int messageId, string text, InlineKeyboardMarkup? replyMarkup, CancellationToken cancellationToken) + { + EditedMessages.Add(new EditedMessageRecord(chatId, messageId, text, replyMarkup)); + return Task.CompletedTask; + } + + public Task DeleteMessageAsync(long chatId, int messageId, CancellationToken cancellationToken) + { + DeletedMessages.Add((chatId, messageId)); + return Task.CompletedTask; + } + + public Task AnswerCallbackQueryAsync(string callbackQueryId, string? text, bool showAlert, CancellationToken cancellationToken) + { + CallbackAnswers.Add(new CallbackAnswerRecord(callbackQueryId, text, showAlert)); + return Task.CompletedTask; + } + + public Task SetMyCommandsAsync(IEnumerable commands, CancellationToken cancellationToken) + { + RegisteredCommands.AddRange(commands); + return Task.CompletedTask; + } +} + +public sealed record SentMessageRecord(TelegramSentMessage Message, InlineKeyboardMarkup? ReplyMarkup); +public sealed record EditedMessageRecord(long ChatId, int MessageId, string Text, InlineKeyboardMarkup? ReplyMarkup); +public sealed record CallbackAnswerRecord(string CallbackQueryId, string? Text, bool ShowAlert); + +public sealed class FakeSupabaseTelegramAuthClient : ISupabaseTelegramAuthClient +{ + public Func>? SignInHandler { get; set; } + public Func>? RefreshHandler { get; set; } + + public Task SignInWithPasswordAsync(long chatId, string email, string password, CancellationToken cancellationToken) => + SignInHandler?.Invoke(chatId, email, password, cancellationToken) + ?? Task.FromResult(new TelegramAuthSession + { + ChatId = chatId, + AccessToken = "signed-in-token", + RefreshToken = "refresh-token", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }); + + public Task RefreshSessionAsync(long chatId, string refreshToken, CancellationToken cancellationToken) => + RefreshHandler?.Invoke(chatId, refreshToken, cancellationToken) + ?? Task.FromResult(new TelegramAuthSession + { + ChatId = chatId, + AccessToken = "refreshed-token", + RefreshToken = refreshToken, + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }); +} + +public sealed class FakeTelegramAuthService : ITelegramAuthService +{ + public Func>? GetValidHandler { get; set; } + public Func>? ForceRefreshHandler { get; set; } + public Func>? SignInHandler { get; set; } + public Func? ClearHandler { get; set; } + + public Task GetValidSessionAsync(long chatId, CancellationToken cancellationToken) => + GetValidHandler?.Invoke(chatId, cancellationToken) + ?? Task.FromResult(null); + + public Task ForceRefreshSessionAsync(long chatId, CancellationToken cancellationToken) => + ForceRefreshHandler?.Invoke(chatId, cancellationToken) + ?? Task.FromResult(null); + + public Task SignInAsync(long chatId, string email, string password, CancellationToken cancellationToken) => + SignInHandler?.Invoke(chatId, email, password, cancellationToken) + ?? Task.FromResult(new TelegramAuthSession + { + ChatId = chatId, + AccessToken = "signed-in-token", + RefreshToken = "refresh-token", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1) + }); + + public Task ClearSessionAsync(long chatId, CancellationToken cancellationToken) => + ClearHandler?.Invoke(chatId, cancellationToken) ?? Task.CompletedTask; +} + +public sealed class FakeDualMindBotApiClient : IDualMindBotApiClient +{ + public Func>? StartBattleHandler { get; set; } + public Func>? SubmitVoteHandler { get; set; } + public Func>>? StatsHandler { get; set; } + + public Task StartBattleAsync(string accessToken, string prompt, CancellationToken cancellationToken) => + StartBattleHandler?.Invoke(accessToken, prompt, cancellationToken) + ?? Task.FromResult(new DualChatApiResponse + { + Success = true, + ComparisonId = Guid.NewGuid(), + Agent1 = new ChatResponse + { + Message = "Agent A reply", + Model = new ModelInfo { Name = "model-a", DisplayName = "Model A" } + }, + Agent2 = new ChatResponse + { + Message = "Agent B reply", + Model = new ModelInfo { Name = "model-b", DisplayName = "Model B" } + } + }); + + public Task SubmitVoteAsync(string accessToken, Guid comparisonId, string voteChoice, int voteDurationMs, CancellationToken cancellationToken) => + SubmitVoteHandler?.Invoke(accessToken, comparisonId, voteChoice, voteDurationMs, cancellationToken) + ?? Task.FromResult(new VoteApiResponse + { + Success = true, + Message = "Vote recorded successfully" + }); + + public Task> GetModelStatsAsync(CancellationToken cancellationToken) => + StatsHandler?.Invoke(cancellationToken) + ?? Task.FromResult>(Array.Empty()); +} + +public static class TestBattleFactory +{ + public static BattleSession CreateBattleSession(Guid? comparisonId = null) => + new() + { + ComparisonId = comparisonId ?? Guid.NewGuid(), + Prompt = "prompt", + AgentAResponse = "Response A", + AgentBResponse = "Response B", + AgentAModelDisplayName = "Model A", + AgentBModelDisplayName = "Model B", + AgentAMessageId = 100, + AgentBMessageId = 101, + StartedAt = DateTimeOffset.UtcNow.AddSeconds(-10) + }; +} diff --git a/tests/DualMind.API.Tests/ModelSelectorTests.cs b/tests/DualMind.API.Tests/ModelSelectorTests.cs new file mode 100644 index 0000000..8087c0e --- /dev/null +++ b/tests/DualMind.API.Tests/ModelSelectorTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DualMind.API.Core.Services; +using DualMind.API.Infrastructure.Data; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace DualMind.API.Tests; + +public class ModelSelectorTests +{ + [Fact] + public async Task GetAllModels_FiltersUnsupportedProviders() + { + var supabase = new FakeModelSupabaseService(new List + { + JObject.FromObject(new + { + model_id = Guid.NewGuid().ToString(), + model_name = "llama-3.3-70b-versatile", + display_name = "Llama 3.3 70B", + provider_name = "groq", + status = "active" + }), + JObject.FromObject(new + { + model_id = Guid.NewGuid().ToString(), + model_name = "moonshotai/kimi-k2-instruct-0905", + display_name = "Kimi K2 Instruct", + provider_name = "openrouter", + status = "active" + }), + JObject.FromObject(new + { + model_id = Guid.NewGuid().ToString(), + model_name = "gemini-2.0-flash", + display_name = "Gemini 2.0 Flash", + provider_name = "google", + status = "active" + }) + }); + + var cache = new MemoryCache(new MemoryCacheOptions()); + var selector = new ModelSelector(supabase, cache, NullLogger.Instance); + + await selector.GetTwoRandomModelsAsync(); + var allModels = selector.GetAllModels(); + + Assert.Equal(2, allModels.Count); + Assert.DoesNotContain(allModels, model => string.Equals(model.Provider, "openrouter", StringComparison.OrdinalIgnoreCase)); + } + + private sealed class FakeModelSupabaseService : ISupabaseService + { + private readonly List _models; + + public FakeModelSupabaseService(List models) + { + _models = models; + } + + public Task> SelectAsync(string table, string select = "*", string filter = null!) + { + if (table != "ai_models") + { + throw new NotSupportedException(); + } + + return Task.FromResult(_models.ConvertAll(model => model.ToObject()!)); + } + + public Task SelectSingleAsync(string table, string select = "*", string filter = null!) => + throw new NotSupportedException(); + + public Task InsertAsync(string table, object data) => + throw new NotSupportedException(); + + public Task UpsertAsync(string table, object data) => + throw new NotSupportedException(); + + public Task> UpdateAsync(string table, object data, string filter) => + throw new NotSupportedException(); + + public Task DeleteAsync(string table, string filter) => + throw new NotSupportedException(); + + public Task RpcAsync(string functionName, object parameters = null!) => + throw new NotSupportedException(); + } +} diff --git a/tests/DualMind.API.Tests/StatsCommandHandlerTests.cs b/tests/DualMind.API.Tests/StatsCommandHandlerTests.cs new file mode 100644 index 0000000..912ba0d --- /dev/null +++ b/tests/DualMind.API.Tests/StatsCommandHandlerTests.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot; +using DualMind.API.Bot.Commands; +using DualMind.API.Core.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace DualMind.API.Tests; + +public class StatsCommandHandlerTests +{ + [Fact] + public async Task HandleAsync_RendersLeaderboard() + { + var transport = new FakeTelegramBotTransport(); + var apiClient = new FakeDualMindBotApiClient + { + StatsHandler = _ => Task.FromResult>(new[] + { + new ModelStatsDto + { + EloRank = 1, + EloScore = 1532, + WinRate = 64.8, + DisplayName = "Model Alpha", + ModelName = "model-alpha", + ProviderName = "openai" + } + }) + }; + + var handler = new StatsCommandHandler( + apiClient, + transport, + Options.Create(new TelegramBotOptions { SignupUrl = "https://dualmind.arena/signup" }), + NullLogger.Instance); + + await handler.HandleAsync(1, CancellationToken.None); + + var message = Assert.Single(transport.SentMessages); + Assert.Contains("Top Models", message.Message.Text); + Assert.Contains("Model Alpha", message.Message.Text); + Assert.NotNull(message.ReplyMarkup); + } +} diff --git a/tests/DualMind.API.Tests/SupabaseServiceTests.cs b/tests/DualMind.API.Tests/SupabaseServiceTests.cs new file mode 100644 index 0000000..2b3163e --- /dev/null +++ b/tests/DualMind.API.Tests/SupabaseServiceTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Infrastructure.Configuration; +using DualMind.API.Infrastructure.Data; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace DualMind.API.Tests; + +public class SupabaseServiceTests +{ + [Fact] + public async Task SelectSingleAsync_ReturnsDefault_WhenPostgrestReportsZeroRows() + { + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotAcceptable) + { + Content = new StringContent("{\"code\":\"PGRST116\",\"details\":\"The result contains 0 rows\",\"message\":\"Cannot coerce the result to a single JSON object\"}") + }); + + using var client = new HttpClient(handler); + var service = CreateService(client); + + var result = await service.SelectSingleAsync("telegram_sessions", "*", "telegram_chat_id=eq.1"); + + Assert.Null(result); + } + + [Fact] + public async Task SelectSingleAsync_Throws_WhenPostgrestReportsMultipleRows() + { + var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotAcceptable) + { + Content = new StringContent("{\"code\":\"PGRST116\",\"details\":\"The result contains 2 rows\",\"message\":\"Cannot coerce the result to a single JSON object\"}") + }); + + using var client = new HttpClient(handler); + var service = CreateService(client); + + var ex = await Assert.ThrowsAsync(() => + service.SelectSingleAsync("telegram_sessions", "*", "telegram_chat_id=eq.1")); + + Assert.Contains("Supabase error", ex.Message); + } + + private static SupabaseService CreateService(HttpClient client) => + new( + client, + Options.Create(new SupabaseSettings + { + Url = "https://example.supabase.co", + ServiceKey = "service-key" + }), + NullLogger.Instance); + + private sealed class StubHttpMessageHandler : HttpMessageHandler + { + private readonly Func _handler; + + public StubHttpMessageHandler(Func handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(_handler(request)); + } +} diff --git a/tests/DualMind.API.Tests/TelegramAuthServiceTests.cs b/tests/DualMind.API.Tests/TelegramAuthServiceTests.cs new file mode 100644 index 0000000..930451e --- /dev/null +++ b/tests/DualMind.API.Tests/TelegramAuthServiceTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot; +using DualMind.API.Bot.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DualMind.API.Tests; + +public class TelegramAuthServiceTests +{ + [Fact] + public async Task SignIn_PersistsSession() + { + var supabase = new FakeSupabaseService(); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var store = new TelegramSessionStore(supabase, new DualMind.API.Core.Services.EncryptionService(), NullLogger.Instance); + var cache = new TelegramStateCache(store, timeProvider); + var authClient = new FakeSupabaseTelegramAuthClient(); + var authService = new TelegramAuthService(cache, authClient, timeProvider, NullLogger.Instance); + + await authService.SignInAsync(50, "user@example.com", "secret", CancellationToken.None); + + Assert.True(supabase.TelegramSessions.ContainsKey(50)); + var stored = await authService.GetValidSessionAsync(50, CancellationToken.None); + Assert.NotNull(stored); + Assert.Equal("signed-in-token", stored!.AccessToken); + } + + [Fact] + public async Task ExpiringSession_RefreshesSilently() + { + var supabase = new FakeSupabaseService(); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var store = new TelegramSessionStore(supabase, new DualMind.API.Core.Services.EncryptionService(), NullLogger.Instance); + var cache = new TelegramStateCache(store, timeProvider); + await cache.SaveSessionAsync(11, new TelegramAuthSession + { + ChatId = 11, + AccessToken = "old-token", + RefreshToken = "refresh-token", + ExpiresAt = timeProvider.GetUtcNow().AddMinutes(3) + }, CancellationToken.None); + + var authClient = new FakeSupabaseTelegramAuthClient + { + RefreshHandler = (chatId, refreshToken, _) => Task.FromResult(new TelegramAuthSession + { + ChatId = chatId, + AccessToken = "new-token", + RefreshToken = refreshToken, + ExpiresAt = timeProvider.GetUtcNow().AddHours(1) + }) + }; + + var authService = new TelegramAuthService(cache, authClient, timeProvider, NullLogger.Instance); + var session = await authService.GetValidSessionAsync(11, CancellationToken.None); + + Assert.NotNull(session); + Assert.Equal("new-token", session!.AccessToken); + } + + [Fact] + public async Task RefreshFailure_ClearsSession() + { + var supabase = new FakeSupabaseService(); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var store = new TelegramSessionStore(supabase, new DualMind.API.Core.Services.EncryptionService(), NullLogger.Instance); + var cache = new TelegramStateCache(store, timeProvider); + await cache.SaveSessionAsync(12, new TelegramAuthSession + { + ChatId = 12, + AccessToken = "old-token", + RefreshToken = "refresh-token", + ExpiresAt = timeProvider.GetUtcNow().AddMinutes(1) + }, CancellationToken.None); + + var authClient = new FakeSupabaseTelegramAuthClient + { + RefreshHandler = (_, _, _) => throw new TelegramAuthException("refresh failed") + }; + + var authService = new TelegramAuthService(cache, authClient, timeProvider, NullLogger.Instance); + var session = await authService.GetValidSessionAsync(12, CancellationToken.None); + + Assert.Null(session); + Assert.False(supabase.TelegramSessions.ContainsKey(12)); + } + + [Fact] + public async Task LegacySessionWithoutRefreshToken_IsClearedWhenRefreshIsNeeded() + { + var supabase = new FakeSupabaseService(); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var store = new TelegramSessionStore(supabase, new DualMind.API.Core.Services.EncryptionService(), NullLogger.Instance); + var cache = new TelegramStateCache(store, timeProvider); + await cache.SaveSessionAsync(13, new TelegramAuthSession + { + ChatId = 13, + AccessToken = "legacy-token", + RefreshToken = null, + ExpiresAt = timeProvider.GetUtcNow().AddMinutes(1) + }, CancellationToken.None); + + var authService = new TelegramAuthService( + cache, + new FakeSupabaseTelegramAuthClient(), + timeProvider, + NullLogger.Instance); + + var session = await authService.GetValidSessionAsync(13, CancellationToken.None); + + Assert.Null(session); + Assert.False(supabase.TelegramSessions.ContainsKey(13)); + } +} diff --git a/tests/DualMind.API.Tests/TelegramBotServiceCollectionExtensionsTests.cs b/tests/DualMind.API.Tests/TelegramBotServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..9d45016 --- /dev/null +++ b/tests/DualMind.API.Tests/TelegramBotServiceCollectionExtensionsTests.cs @@ -0,0 +1,48 @@ +using System.Linq; +using DualMind.API.Bot; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace DualMind.API.Tests; + +public class TelegramBotServiceCollectionExtensionsTests +{ + [Fact] + public void AddTelegramBot_ReturnsFalse_WhenRequiredConfigIsMissing() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new[] + { + new KeyValuePair("Telegram:BotToken", "bot-token") + }) + .Build(); + + var services = new ServiceCollection(); + var enabled = services.AddTelegramBot(configuration); + + Assert.False(enabled); + Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(IHostedService)); + } + + [Fact] + public void AddTelegramBot_RegistersHostedService_WhenRequiredConfigExists() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new[] + { + new KeyValuePair("Telegram:BotToken", "bot-token"), + new KeyValuePair("Telegram:ApiBaseUrl", "https://localhost:5001") + }) + .Build(); + + var services = new ServiceCollection(); + var enabled = services.AddTelegramBot(configuration); + + Assert.True(enabled); + Assert.Contains(services, descriptor => + descriptor.ServiceType == typeof(IHostedService) && + descriptor.ImplementationType == typeof(TelegramBotService)); + } +} diff --git a/tests/DualMind.API.Tests/TelegramSessionStoreTests.cs b/tests/DualMind.API.Tests/TelegramSessionStoreTests.cs new file mode 100644 index 0000000..7fa0357 --- /dev/null +++ b/tests/DualMind.API.Tests/TelegramSessionStoreTests.cs @@ -0,0 +1,41 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot; +using DualMind.API.Bot.Models; +using DualMind.API.Core.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace DualMind.API.Tests; + +public class TelegramSessionStoreTests +{ + [Fact] + public async Task SaveLoadDelete_RoundTripsEncryptedSession() + { + var supabase = new FakeSupabaseService(); + var store = new TelegramSessionStore(supabase, new EncryptionService(), NullLogger.Instance); + var session = new TelegramAuthSession + { + ChatId = 99, + AccessToken = "access-token", + RefreshToken = "refresh-token", + ExpiresAt = DateTimeOffset.Parse("2026-03-16T10:00:00Z") + }; + + await store.SaveSessionAsync(session, CancellationToken.None); + + Assert.NotEqual("access-token", supabase.TelegramSessions[99]["jwt_token"]?.ToString()); + Assert.NotEqual("refresh-token", supabase.TelegramSessions[99]["refresh_token"]?.ToString()); + + var loaded = await store.GetSessionAsync(99, CancellationToken.None); + Assert.NotNull(loaded); + Assert.Equal("access-token", loaded!.AccessToken); + Assert.Equal("refresh-token", loaded.RefreshToken); + + await store.DeleteSessionAsync(99, CancellationToken.None); + var deleted = await store.GetSessionAsync(99, CancellationToken.None); + Assert.Null(deleted); + } +} diff --git a/tests/DualMind.API.Tests/TelegramStateCacheTests.cs b/tests/DualMind.API.Tests/TelegramStateCacheTests.cs new file mode 100644 index 0000000..6284909 --- /dev/null +++ b/tests/DualMind.API.Tests/TelegramStateCacheTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot; +using DualMind.API.Bot.Models; +using Xunit; + +namespace DualMind.API.Tests; + +public class TelegramStateCacheTests +{ + [Fact] + public void UserStateTransitions_AndCooldowns_AreTracked() + { + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var cache = new TelegramStateCache(new FakeSessionStore(), timeProvider); + + cache.SetAwaitingEmail(1); + Assert.Equal(TelegramUserMode.WaitingForEmail, cache.GetState(1).Mode); + + cache.SetAwaitingPassword(1, "user@example.com"); + Assert.Equal(TelegramUserMode.WaitingForPassword, cache.GetState(1).Mode); + Assert.Equal("user@example.com", cache.GetState(1).PendingEmail); + + Assert.True(cache.TryBeginBattleCooldown(1, TimeSpan.FromSeconds(15), out var remaining)); + Assert.Equal(TimeSpan.Zero, remaining); + Assert.False(cache.TryBeginBattleCooldown(1, TimeSpan.FromSeconds(15), out remaining)); + Assert.True(remaining > TimeSpan.Zero); + + timeProvider.Advance(TimeSpan.FromSeconds(16)); + Assert.True(cache.TryBeginBattleCooldown(1, TimeSpan.FromSeconds(15), out remaining)); + + cache.ClearConversationState(1); + Assert.Equal(TelegramUserMode.Idle, cache.GetState(1).Mode); + Assert.Null(cache.GetState(1).PendingEmail); + } + + [Fact] + public void ActiveBattle_PreventsDuplicateVotes_UntilReset() + { + var cache = new TelegramStateCache(new FakeSessionStore(), new FakeTimeProvider(DateTimeOffset.UtcNow)); + var battle = TestBattleFactory.CreateBattleSession(); + cache.SetActiveBattle(7, battle); + + Assert.True(cache.TryBeginVote(7, battle.ComparisonId, "left", out var firstSession)); + Assert.NotNull(firstSession); + Assert.False(cache.TryBeginVote(7, battle.ComparisonId, "right", out _)); + + cache.ResetVote(7); + Assert.True(cache.TryBeginVote(7, battle.ComparisonId, "both-bad", out var retriedSession)); + Assert.Equal("both-bad", retriedSession!.VoteChoice); + + cache.CompleteBattle(7); + Assert.Null(cache.GetActiveBattle(7)); + } + + private sealed class FakeSessionStore : ITelegramSessionStore + { + public Task GetSessionAsync(long chatId, CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task SaveSessionAsync(TelegramAuthSession session, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task DeleteSessionAsync(long chatId, CancellationToken cancellationToken) => + Task.CompletedTask; + } +} diff --git a/tests/DualMind.API.Tests/TelegramUpdateHandlerTests.cs b/tests/DualMind.API.Tests/TelegramUpdateHandlerTests.cs new file mode 100644 index 0000000..4b16111 --- /dev/null +++ b/tests/DualMind.API.Tests/TelegramUpdateHandlerTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.Bot; +using DualMind.API.Bot.Commands; +using DualMind.API.Bot.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace DualMind.API.Tests; + +public class TelegramUpdateHandlerTests +{ + [Fact] + public async Task StartCommand_SendsWelcomeMenu() + { + var handler = CreateHandler(out var transport); + + await handler.HandleAsync(new TelegramIncomingUpdate + { + ChatId = 1, + ChatType = "private", + Text = "/start", + MessageId = 10 + }, CancellationToken.None); + + var message = Assert.Single(transport.SentMessages); + Assert.Contains("DualMind Telegram Bot", message.Message.Text); + Assert.NotNull(message.ReplyMarkup); + } + + [Fact] + public async Task HelpCommand_SendsHelpText() + { + var handler = CreateHandler(out var transport); + + await handler.HandleAsync(new TelegramIncomingUpdate + { + ChatId = 1, + ChatType = "private", + Text = "/help", + MessageId = 10 + }, CancellationToken.None); + + Assert.Contains("/battle", Assert.Single(transport.SentMessages).Message.Text); + } + + [Fact] + public async Task SignInFlow_DeletesPasswordAndPersistsSession() + { + var supabase = new FakeSupabaseService(); + var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-03-15T10:00:00Z")); + var store = new TelegramSessionStore(supabase, new DualMind.API.Core.Services.EncryptionService(), NullLogger.Instance); + var cache = new TelegramStateCache(store, timeProvider); + var transport = new FakeTelegramBotTransport(); + var authService = new TelegramAuthService(cache, new FakeSupabaseTelegramAuthClient(), timeProvider, NullLogger.Instance); + var options = Options.Create(new TelegramBotOptions()); + var handler = new TelegramUpdateHandler( + new StartCommandHandler(transport, options), + new HelpCommandHandler(transport, options), + new BattleCommandHandler(authService, new FakeDualMindBotApiClient(), transport, cache, options, timeProvider, NullLogger.Instance), + new StatsCommandHandler(new FakeDualMindBotApiClient(), transport, options, NullLogger.Instance), + authService, + transport, + cache, + options, + NullLogger.Instance); + + await handler.HandleAsync(new TelegramIncomingUpdate + { + ChatId = 1, + ChatType = "private", + CallbackQueryId = "cb-1", + CallbackData = "action:signin" + }, CancellationToken.None); + + await handler.HandleAsync(new TelegramIncomingUpdate + { + ChatId = 1, + ChatType = "private", + Text = "user@example.com", + MessageId = 11 + }, CancellationToken.None); + + await handler.HandleAsync(new TelegramIncomingUpdate + { + ChatId = 1, + ChatType = "private", + Text = "super-secret", + MessageId = 12 + }, CancellationToken.None); + + Assert.Contains((1L, 12), transport.DeletedMessages); + Assert.True(supabase.TelegramSessions.ContainsKey(1)); + Assert.Contains("signed in", transport.SentMessages.Last().Message.Text, StringComparison.OrdinalIgnoreCase); + } + + private static TelegramUpdateHandler CreateHandler(out FakeTelegramBotTransport transport) + { + transport = new FakeTelegramBotTransport(); + var options = Options.Create(new TelegramBotOptions()); + var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); + var cache = new TelegramStateCache(new FakeSessionStore(), timeProvider); + var authService = new FakeTelegramAuthService(); + + return new TelegramUpdateHandler( + new StartCommandHandler(transport, options), + new HelpCommandHandler(transport, options), + new BattleCommandHandler(authService, new FakeDualMindBotApiClient(), transport, cache, options, timeProvider, NullLogger.Instance), + new StatsCommandHandler(new FakeDualMindBotApiClient(), transport, options, NullLogger.Instance), + authService, + transport, + cache, + options, + NullLogger.Instance); + } + + private sealed class FakeSessionStore : ITelegramSessionStore + { + public Task GetSessionAsync(long chatId, CancellationToken cancellationToken) => + Task.FromResult(null); + + public Task SaveSessionAsync(TelegramAuthSession session, CancellationToken cancellationToken) => + Task.CompletedTask; + + public Task DeleteSessionAsync(long chatId, CancellationToken cancellationToken) => + Task.CompletedTask; + } +} diff --git a/tests/DualMind.API.Tests/UnitTest1.cs b/tests/DualMind.API.Tests/UnitTest1.cs deleted file mode 100644 index d49d04d..0000000 --- a/tests/DualMind.API.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace DualMind.API.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} From 6d220a7595c54067f8bde4f61dac70536e4fd26d Mon Sep 17 00:00:00 2001 From: Harsh Date: Fri, 20 Mar 2026 01:53:42 +0530 Subject: [PATCH 2/2] feat: integrate Cloudflare Workers AI and initialize ELO leaderboard --- .github/workflows/deploy-dualmind-arena.yml | 7 +- ENV_SETUP.md | 50 ++++- ...60319_add_cloudflare_workers_ai_models.sql | 128 ++++++++++++ ...reachable_cloudflare_workers_ai_models.sql | 24 +++ .../migrations/20260319_elo_rating_setup.sql | 99 ++++++++++ ...0319_final_cloudflare_leaderboard_seed.sql | 100 ++++++++++ ...0319_fixed_cloudflare_leaderboard_seed.sql | 99 ++++++++++ ...319_manual_cloudflare_leaderboard_seed.sql | 75 +++++++ .../20260319_manual_elo_setup_and_seed.sql | 135 +++++++++++++ .../20260319_seed_cloudflare_leaderboard.sql | 51 +++++ .../cloudflare-workers-ai-models-stable.csv | 28 +++ .../cloudflare-workers-ai-models-stable.json | 29 +++ .../AI/Gateway/ChatProviderFactory.cs | 10 +- .../Providers/CloudflareWorkersAiService.cs | 187 ++++++++++++++++++ .../AI/Providers/GoogleService.cs | 58 ++++-- src/DualMind.API/AI/Providers/GroqService.cs | 59 ++++-- .../Core/Services/ModelSelector.cs | 21 +- .../CloudflareAiGatewaySettings.cs | 111 +++++++++++ .../Infrastructure/Configuration/EnvConfig.cs | 7 + src/DualMind.API/Program.cs | 4 + .../CloudflareAiGatewaySettingsTests.cs | 80 ++++++++ .../DualMind.API.Tests/ModelSelectorTests.cs | 81 +++++++- 22 files changed, 1406 insertions(+), 37 deletions(-) create mode 100644 database/migrations/20260319_add_cloudflare_workers_ai_models.sql create mode 100644 database/migrations/20260319_deactivate_unreachable_cloudflare_workers_ai_models.sql create mode 100644 database/migrations/20260319_elo_rating_setup.sql create mode 100644 database/migrations/20260319_final_cloudflare_leaderboard_seed.sql create mode 100644 database/migrations/20260319_fixed_cloudflare_leaderboard_seed.sql create mode 100644 database/migrations/20260319_manual_cloudflare_leaderboard_seed.sql create mode 100644 database/migrations/20260319_manual_elo_setup_and_seed.sql create mode 100644 database/migrations/20260319_seed_cloudflare_leaderboard.sql create mode 100644 postman/cloudflare-workers-ai-models-stable.csv create mode 100644 postman/cloudflare-workers-ai-models-stable.json create mode 100644 src/DualMind.API/AI/Providers/CloudflareWorkersAiService.cs create mode 100644 src/DualMind.API/Infrastructure/Configuration/CloudflareAiGatewaySettings.cs create mode 100644 tests/DualMind.API.Tests/CloudflareAiGatewaySettingsTests.cs diff --git a/.github/workflows/deploy-dualmind-arena.yml b/.github/workflows/deploy-dualmind-arena.yml index a2832d6..a515b40 100644 --- a/.github/workflows/deploy-dualmind-arena.yml +++ b/.github/workflows/deploy-dualmind-arena.yml @@ -28,13 +28,13 @@ jobs: run: dotnet build src/DualMind.API/DualMind.API.csproj --configuration Release - name: dotnet publish - run: dotnet publish src/DualMind.API/DualMind.API.csproj -c Release -o "${{env.DOTNET_ROOT}}/myapp" + run: dotnet publish src/DualMind.API/DualMind.API.csproj -c Release -o ./publish - name: Upload artifact for deployment job uses: actions/upload-artifact@v4 with: name: .net-app - path: "${{env.DOTNET_ROOT}}/myapp" + path: ./publish deploy: runs-on: windows-latest @@ -47,6 +47,7 @@ jobs: uses: actions/download-artifact@v4 with: name: .net-app + path: ./publish - name: Deploy to Azure Web App id: deploy-to-webapp @@ -55,4 +56,4 @@ jobs: app-name: 'dualmind-arena' slot-name: 'Production' publish-profile: ${{ secrets.AZURE_PUBLISH_PROFILE }} - package: . + package: ./publish diff --git a/ENV_SETUP.md b/ENV_SETUP.md index 0f3254c..81b1601 100644 --- a/ENV_SETUP.md +++ b/ENV_SETUP.md @@ -11,8 +11,13 @@ Create a `.env` file in the project root directory with the following variables: SUPABASE_URL=your_supabase_url_here SUPABASE_SERVICE_KEY=your_supabase_service_key_here -# Groq API Key (REQUIRED) -GROQ_API_KEY=your_groq_api_key_here +# Groq API Key +# Keep this if you want /api/speech/generate to stay direct to Groq. +# Chat/streaming no longer need it when Cloudflare AI Gateway + BYOK are configured. +# GROQ_API_KEY=your_groq_api_key_here + +# Google API Key (optional, if using Google directly outside Cloudflare) +# GOOGLE_API_KEY=your_google_api_key_here # ============ OPTIONAL ============ @@ -27,11 +32,33 @@ GROQ_API_KEY=your_groq_api_key_here # App Secret (NOT NEEDED - was for database key encryption) # APP_SECRET=not_needed + +# Cloudflare AI Gateway (required for chat/streaming) +# Groq + Google chat/streaming and Cloudflare Workers AI chat all route through the gateway. +# Internal DB model names stay unchanged for Groq/Google; Cloudflare Workers AI models should be stored +# exactly as their Workers AI model IDs (for example @cf/meta/llama-3.1-8b-instruct). +# CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID=your_cloudflare_account_id +# CLOUDFLARE_AI_GATEWAY_ID=your_gateway_id + +# Set to true only if Cloudflare is storing your provider keys (BYOK mode). +# In BYOK mode, the app sends CLOUDFLARE_AI_GATEWAY_TOKEN for chat requests instead of provider keys. +# Groq speech still uses the direct Groq API, so keep GROQ_API_KEY or DB provider keys available for speech. +# Chat and streaming are routed through Cloudflare AI Gateway and will fail fast if the gateway is not configured. +# CLOUDFLARE_AI_GATEWAY_USE_BYOK=false +# CLOUDFLARE_AI_GATEWAY_TOKEN=your_cloudflare_gateway_token + +# Cloudflare Workers AI (required if you add provider_name = cloudflare models in ai_models) +# Use a Cloudflare API token with Workers AI Read access. +# CLOUDFLARE_WORKERS_AI_API_TOKEN=your_cloudflare_workers_ai_api_token + +# Optional default used only if a Cloudflare Workers AI request reaches the provider without a model name. +# DEFAULT_CLOUDFLARE_WORKERS_AI_MODEL=@cf/meta/llama-3.1-8b-instruct ``` ### Priority Order: -1. **Environment Variable (GROQ_API_KEY)** - Used first if set (from .env or Azure) -2. **Database Keys** - Used as fallback if no environment variable is set +1. **Cloudflare AI Gateway** - Required for chat/streaming traffic +2. **Provider Env Vars / Database Keys** - Used for Groq or Google only when the gateway path still needs direct provider auth +3. **Direct Groq Speech** - `/api/speech/generate` still uses `GROQ_API_KEY` or Groq DB keys ## Azure Deployment (Azure Secrets) @@ -43,6 +70,14 @@ When deploying to Azure, set these as **Application Settings** or **Key Vault Se - `SUPABASE_URL` = your_supabase_url - `SUPABASE_SERVICE_KEY` = your_supabase_service_key +Optional AI Gateway settings: + - `CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID` + - `CLOUDFLARE_AI_GATEWAY_ID` + - `CLOUDFLARE_AI_GATEWAY_USE_BYOK` + - `CLOUDFLARE_AI_GATEWAY_TOKEN` + - `CLOUDFLARE_WORKERS_AI_API_TOKEN` + - `DEFAULT_CLOUDFLARE_WORKERS_AI_MODEL` + The backend will automatically use Azure environment variables when deployed. ## Important Notes: @@ -50,6 +85,9 @@ The backend will automatically use Azure environment variables when deployed. - **Local Development**: Use `.env` file (already in .gitignore) - **Azure Production**: Use Azure App Service Configuration/Secrets - The `.env` file is automatically loaded on application start -- If `GROQ_API_KEY` is set, it takes priority over database keys -- If no `GROQ_API_KEY` is set, the system will use database keys (if available) +- Chat and streaming require Cloudflare AI Gateway to be configured +- Groq and Google model names stay unchanged in the database; the backend maps them to Cloudflare-compatible names at request time +- Cloudflare Workers AI model names should be stored exactly as the official Workers AI model IDs +- If Cloudflare Workers AI models are active, set `CLOUDFLARE_WORKERS_AI_API_TOKEN` +- If Groq speech is enabled, keep `GROQ_API_KEY` or active Groq DB keys available diff --git a/database/migrations/20260319_add_cloudflare_workers_ai_models.sql b/database/migrations/20260319_add_cloudflare_workers_ai_models.sql new file mode 100644 index 0000000..a55a7b4 --- /dev/null +++ b/database/migrations/20260319_add_cloudflare_workers_ai_models.sql @@ -0,0 +1,128 @@ +-- Adds Cloudflare Workers AI as a first-class provider and seeds the +-- current text-generation model catalog from Cloudflare Workers AI docs. +-- +-- Sources used for model IDs: +-- https://developers.cloudflare.com/workers-ai/models/ +-- https://developers.cloudflare.com/ai-gateway/usage/providers/workersai/ +-- +-- Notes: +-- - This seeds only text-generation/chat-capable Workers AI models that are +-- currently listed in the public catalog. +-- - Model names are stored exactly as the official Workers AI model IDs. +-- - `llama-3.2-11b-vision-instruct` is included because it supports chat +-- completions, but Cloudflare documents an extra one-time license acceptance +-- step before normal use. + +begin; + +update providers +set + display_name = 'Cloudflare Workers AI', + is_enabled = true, + priority = 30, + updated_at = now() +where lower(provider_name) = 'cloudflare'; + +insert into providers ( + provider_name, + display_name, + is_enabled, + priority, + created_at, + updated_at +) +select + 'cloudflare', + 'Cloudflare Workers AI', + true, + 30, + now(), + now() +where not exists ( + select 1 + from providers + where lower(provider_name) = 'cloudflare' +); + +with desired_models(model_name, display_name) as ( + values + ('@cf/openai/gpt-oss-120b', 'gpt-oss-120b'), + ('@cf/openai/gpt-oss-20b', 'gpt-oss-20b'), + ('@cf/meta/llama-4-scout-17b-16e-instruct', 'llama-4-scout-17b-16e-instruct'), + ('@cf/meta/llama-3.1-8b-instruct-fast', 'llama-3.1-8b-instruct-fast'), + ('@cf/nvidia/nemotron-3-120b-a12b', 'nemotron-3-120b-a12b'), + ('@cf/zai-org/glm-4.7-flash', 'glm-4.7-flash'), + ('@cf/ibm-granite/granite-4.0-h-micro', 'granite-4.0-h-micro'), + ('@cf/aisingapore/gemma-sea-lion-v4-27b-it', 'gemma-sea-lion-v4-27b-it'), + ('@cf/qwen/qwen3-30b-a3b-fp8', 'qwen3-30b-a3b-fp8'), + ('@cf/google/gemma-3-12b-it', 'gemma-3-12b-it'), + ('@cf/mistralai/mistral-small-3.1-24b-instruct', 'mistral-small-3.1-24b-instruct'), + ('@cf/qwen/qwq-32b', 'qwq-32b'), + ('@cf/qwen/qwen2.5-coder-32b-instruct', 'qwen2.5-coder-32b-instruct'), + ('@cf/meta/llama-guard-3-8b', 'llama-guard-3-8b'), + ('@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', 'deepseek-r1-distill-qwen-32b'), + ('@cf/meta/llama-3.3-70b-instruct-fp8-fast', 'llama-3.3-70b-instruct-fp8-fast'), + ('@cf/meta/llama-3.2-1b-instruct', 'llama-3.2-1b-instruct'), + ('@cf/meta/llama-3.2-3b-instruct', 'llama-3.2-3b-instruct'), + ('@cf/meta/llama-3.2-11b-vision-instruct', 'llama-3.2-11b-vision-instruct'), + ('@cf/meta/llama-3.1-8b-instruct-awq', 'llama-3.1-8b-instruct-awq'), + ('@cf/meta/llama-3.1-8b-instruct-fp8', 'llama-3.1-8b-instruct-fp8'), + ('@cf/meta/llama-3.1-8b-instruct', 'llama-3.1-8b-instruct'), + ('@hf/meta-llama/meta-llama-3-8b-instruct', 'meta-llama-3-8b-instruct'), + ('@cf/meta/llama-3-8b-instruct-awq', 'llama-3-8b-instruct-awq'), + ('@cf/meta/llama-3-8b-instruct', 'llama-3-8b-instruct'), + ('@hf/mistral/mistral-7b-instruct-v0.2', 'mistral-7b-instruct-v0.2'), + ('@cf/google/gemma-7b-it-lora', 'gemma-7b-it-lora'), + ('@cf/google/gemma-2b-it-lora', 'gemma-2b-it-lora'), + ('@cf/meta-llama/llama-2-7b-chat-hf-lora', 'llama-2-7b-chat-hf-lora'), + ('@hf/google/gemma-7b-it', 'gemma-7b-it'), + ('@hf/nousresearch/hermes-2-pro-mistral-7b', 'hermes-2-pro-mistral-7b'), + ('@cf/mistral/mistral-7b-instruct-v0.2-lora', 'mistral-7b-instruct-v0.2-lora'), + ('@cf/defog/sqlcoder-7b-2', 'sqlcoder-7b-2'), + ('@cf/microsoft/phi-2', 'phi-2'), + ('@cf/meta/llama-2-7b-chat-fp16', 'llama-2-7b-chat-fp16'), + ('@cf/mistral/mistral-7b-instruct-v0.1', 'mistral-7b-instruct-v0.1'), + ('@cf/meta/llama-2-7b-chat-int8', 'llama-2-7b-chat-int8'), + ('@cf/meta/llama-3.1-70b-instruct', 'llama-3.1-70b-instruct') +), +updated as ( + update ai_models as target + set + display_name = desired.display_name, + provider_name = 'cloudflare', + is_free = false, + status = 'active' + from desired_models as desired + where lower(target.model_name) = lower(desired.model_name) + returning lower(target.model_name) as model_name +) +insert into ai_models ( + model_id, + model_name, + display_name, + provider_name, + is_free, + status, + created_at +) +select + gen_random_uuid(), + desired.model_name, + desired.display_name, + 'cloudflare', + false, + 'active', + now() +from desired_models as desired +where not exists ( + select 1 + from updated + where updated.model_name = lower(desired.model_name) +) +and not exists ( + select 1 + from ai_models as existing + where lower(existing.model_name) = lower(desired.model_name) +); + +commit; diff --git a/database/migrations/20260319_deactivate_unreachable_cloudflare_workers_ai_models.sql b/database/migrations/20260319_deactivate_unreachable_cloudflare_workers_ai_models.sql new file mode 100644 index 0000000..5bc4232 --- /dev/null +++ b/database/migrations/20260319_deactivate_unreachable_cloudflare_workers_ai_models.sql @@ -0,0 +1,24 @@ +-- Deactivate Cloudflare Workers AI models that failed runtime smoke tests +-- on March 19, 2026 against the local /api/arena/chat integration. +-- +-- These models currently fall back to the basic Groq model instead of +-- returning a native Cloudflare Workers AI response, so they should not +-- remain active in production selection until re-verified. + +begin; + +update ai_models +set + status = 'inactive' +where lower(provider_name) = 'cloudflare' + and lower(model_name) in ( + lower('@cf/meta/llama-3.2-11b-vision-instruct'), + lower('@cf/google/gemma-7b-it-lora'), + lower('@cf/meta-llama/llama-2-7b-chat-hf-lora'), + lower('@hf/google/gemma-7b-it'), + lower('@hf/nousresearch/hermes-2-pro-mistral-7b'), + lower('@cf/microsoft/phi-2'), + lower('@cf/meta/llama-2-7b-chat-fp16') + ); + +commit; diff --git a/database/migrations/20260319_elo_rating_setup.sql b/database/migrations/20260319_elo_rating_setup.sql new file mode 100644 index 0000000..8ed4348 --- /dev/null +++ b/database/migrations/20260319_elo_rating_setup.sql @@ -0,0 +1,99 @@ +-- ============================================================================= +-- DUALMIND ELO SYSTEM & CLOUDFARE LEADERBOARD INITIALIZATION +-- ============================================================================= +-- This script ensures the ELO rating infrastructure exists and initializes +-- the leaderboard for newly added Cloudflare models. + +BEGIN; + +-- 1. ENSURE leaderboard table exists +CREATE TABLE IF NOT EXISTS public.model_leaderboard ( + model_id uuid PRIMARY KEY REFERENCES public.ai_models(model_id) ON DELETE CASCADE, + elo_score integer DEFAULT 1000 NOT NULL, + total_wins integer DEFAULT 0, + total_losses integer DEFAULT 0, + total_ties integer DEFAULT 0, + total_comparisons integer DEFAULT 0, + updated_at timestamp with time zone DEFAULT now() +); + +-- 2. CREATE ELO update helper function +-- Standard Elo formula: NewRating = OldRating + K * (ActualScore - ExpectedScore) +CREATE OR REPLACE FUNCTION public.calculate_elo_update( + winner_elo integer, + loser_elo integer, + is_tie boolean DEFAULT false, + k_factor integer DEFAULT 32 +) RETURNS TABLE (new_winner_elo integer, new_loser_elo integer, elo_delta integer) AS $$ +DECLARE + expected_winner float; + expected_loser float; + score_winner float; + score_loser float; + delta_w integer; + delta_l integer; +BEGIN + expected_winner := 1.0 / (1.0 + pow(10, (loser_elo - winner_elo)::float / 400.0)); + expected_loser := 1.0 / (1.0 + pow(10, (winner_elo - loser_elo)::float / 400.0)); + + IF is_tie THEN + score_winner := 0.5; + score_loser := 0.5; + ELSE + score_winner := 1.0; + score_loser := 0.0; + END IF; + + delta_w := round(k_factor * (score_winner - expected_winner))::integer; + delta_l := round(k_factor * (score_loser - expected_loser))::integer; + + RETURN QUERY SELECT + winner_elo + delta_w, + loser_elo + delta_l, + delta_w; +END; +$$ LANGUAGE plpgsql; + +-- 3. CREATE OR REPLACE the view referenced by the backend +CREATE OR REPLACE VIEW public.v_leaderboard AS +SELECT + m.model_id, + m.model_name, + m.display_name, + m.provider_name, + ml.elo_score, + ml.total_wins, + ml.total_losses, + ml.total_ties, + ml.total_comparisons, + CASE + WHEN ml.total_comparisons = 0 THEN 0.0 + ELSE ROUND((ml.total_wins::float / ml.total_comparisons::float) * 100, 2) + END as win_rate, + RANK() OVER (ORDER BY ml.elo_score DESC) as elo_rank +FROM public.ai_models m +JOIN public.model_leaderboard ml ON m.model_id = ml.model_id +WHERE m.status = 'active'; + +-- 4. SEED NEW MODELS (Cloudflare only) +-- Note: As requested, we use randomized starting scores between 950 and 1050. +INSERT INTO public.model_leaderboard ( + model_id, + elo_score, + total_wins, + total_losses, + total_ties, + total_comparisons, + updated_at +) +SELECT + m.model_id, + (950 + (random() * 100))::INTEGER as initial_elo, + 0, 0, 0, 0, now() +FROM public.ai_models m +WHERE m.provider_name = 'cloudflare' + AND m.status = 'active' +-- Idempotency: don't overwrite existing stats if manually edited +ON CONFLICT (model_id) DO NOTHING; + +COMMIT; diff --git a/database/migrations/20260319_final_cloudflare_leaderboard_seed.sql b/database/migrations/20260319_final_cloudflare_leaderboard_seed.sql new file mode 100644 index 0000000..64be65b --- /dev/null +++ b/database/migrations/20260319_final_cloudflare_leaderboard_seed.sql @@ -0,0 +1,100 @@ +-- ============================================================================= +-- FINAL REVISED MANUAL CLOUDFLARE MODEL LEADERBOARD SEEDING +-- ============================================================================= +-- Fixes: ERROR 42P16 (cannot drop columns from view) +-- Provides: Manual ELO scores and explicit DROP VIEW for clean replacement. +-- ============================================================================= + +BEGIN; + +-- 1. Ensure the leaderboard table exists +CREATE TABLE IF NOT EXISTS public.model_leaderboard ( + model_id uuid PRIMARY KEY REFERENCES public.ai_models(model_id) ON DELETE CASCADE, + elo_score integer DEFAULT 1000 NOT NULL, + total_wins integer DEFAULT 0, + total_losses integer DEFAULT 0, + total_ties integer DEFAULT 0, + total_comparisons integer DEFAULT 0, + updated_at timestamp with time zone DEFAULT now() +); + +-- 2. DROP VIEW before recreating to avoid "cannot drop columns" error +DROP VIEW IF EXISTS public.v_leaderboard; + +-- 3. Create the view used by the backend service +CREATE VIEW public.v_leaderboard AS +SELECT + m.model_id, + m.model_name, + m.display_name, + m.provider_name, + ml.elo_score, + ml.total_wins, + ml.total_losses, + ml.total_ties, + ml.total_comparisons, + CASE + WHEN ml.total_comparisons = 0 THEN 0.0 + ELSE ROUND(((ml.total_wins::numeric / ml.total_comparisons::numeric) * 100), 2) + END as win_rate, + RANK() OVER (ORDER BY ml.elo_score DESC) as elo_rank +FROM public.ai_models m +JOIN public.model_leaderboard ml ON m.model_id = ml.model_id +WHERE m.status = 'active'; + +-- 4. Manual seeding with hardcoded scores +WITH NewModelStats (m_name, m_elo) AS ( + VALUES + ('@cf/meta/llama-3.1-8b-instruct-fast', 1050), + ('@cf/nvidia/nemotron-3-120b-a12b', 1080), + ('@cf/zai-org/glm-4.7-flash', 1020), + ('@cf/ibm-granite/granite-4.0-h-micro', 1010), + ('@cf/aisingapore/gemma-sea-lion-v4-27b-it', 1040), + ('@cf/qwen/qwen3-30b-a3b-fp8', 1090), + ('@cf/mistralai/mistral-small-3.1-24b-instruct', 1070), + ('@cf/qwen/qwq-32b', 1110), + ('@cf/qwen/qwen2.5-coder-32b-instruct', 1095), + ('@cf/meta/llama-guard-3-8b', 1000), + ('@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', 1120), + ('@cf/meta/llama-3.3-70b-instruct-fp8-fast', 1150), + ('@cf/meta/llama-3.2-1b-instruct', 980), + ('@cf/meta/llama-3.2-3b-instruct', 1010), + ('@cf/meta/llama-3.2-11b-vision-instruct', 1030), + ('@cf/meta/llama-3.1-8b-instruct-awq', 1025), + ('@cf/meta/llama-3.1-8b-instruct-fp8', 1025), + ('@cf/meta/llama-3.1-8b-instruct', 1020), + ('@hf/meta-llama/meta-llama-3-8b-instruct', 1045), + ('@cf/meta/llama-3-8b-instruct-awq', 1015), + ('@cf/meta/llama-3-8b-instruct', 1015), + ('@hf/mistral/mistral-7b-instruct-v0.2', 1035), + ('@cf/google/gemma-7b-it-lora', 1005), + ('@cf/google/gemma-2b-it-lora', 975), + ('@cf/meta-llama/llama-2-7b-chat-hf-lora', 990), + ('@hf/google/gemma-7b-it', 1010), + ('@hf/nousresearch/hermes-2-pro-mistral-7b', 1030), + ('@cf/mistral/mistral-7b-instruct-v0.2-lora', 1000), + ('@cf/defog/sqlcoder-7b-2', 1010), + ('@cf/microsoft/phi-2', 960), + ('@cf/meta/llama-2-7b-chat-fp16', 985), + ('@cf/mistral/mistral-7b-instruct-v0.1', 1020), + ('@cf/meta/llama-2-7b-chat-int8', 980), + ('@cf/meta/llama-3.1-70b-instruct', 1140) +) +INSERT INTO public.model_leaderboard ( + model_id, + elo_score, + total_wins, + total_losses, + total_ties, + total_comparisons, + updated_at +) +SELECT + m.model_id, + nms.m_elo, + 0, 0, 0, 0, now() +FROM public.ai_models m +JOIN NewModelStats nms ON m.model_name = nms.m_name +ON CONFLICT (model_id) DO UPDATE SET elo_score = EXCLUDED.elo_score; + +COMMIT; diff --git a/database/migrations/20260319_fixed_cloudflare_leaderboard_seed.sql b/database/migrations/20260319_fixed_cloudflare_leaderboard_seed.sql new file mode 100644 index 0000000..3fa5793 --- /dev/null +++ b/database/migrations/20260319_fixed_cloudflare_leaderboard_seed.sql @@ -0,0 +1,99 @@ +-- ============================================================================= +-- REVISED MANUAL CLOUDFLARE MODEL LEADERBOARD SEEDING +-- ============================================================================= +-- Fixes: ERROR 42883 (round function type mismatch) +-- Provides: Manual ELO scores as requested (no random() function used) +-- ============================================================================= + +BEGIN; + +-- 1. Ensure the leaderboard table exists +CREATE TABLE IF NOT EXISTS public.model_leaderboard ( + model_id uuid PRIMARY KEY REFERENCES public.ai_models(model_id) ON DELETE CASCADE, + elo_score integer DEFAULT 1000 NOT NULL, + total_wins integer DEFAULT 0, + total_losses integer DEFAULT 0, + total_ties integer DEFAULT 0, + total_comparisons integer DEFAULT 0, + updated_at timestamp with time zone DEFAULT now() +); + +-- 2. Fixed View (using numeric for round function compatibility) +CREATE OR REPLACE VIEW public.v_leaderboard AS +SELECT + m.model_id, + m.model_name, + m.display_name, + m.provider_name, + ml.elo_score, + ml.total_wins, + ml.total_losses, + ml.total_ties, + ml.total_comparisons, + CASE + WHEN ml.total_comparisons = 0 THEN 0.0 + -- Fix: ROUND requires numeric type in Postgres + ELSE ROUND(((ml.total_wins::numeric / ml.total_comparisons::numeric) * 100), 2) + END as win_rate, + RANK() OVER (ORDER BY ml.elo_score DESC) as elo_rank +FROM public.ai_models m +JOIN public.model_leaderboard ml ON m.model_id = ml.model_id +WHERE m.status = 'active'; + +-- 3. Manual seeding with hardcoded scores +-- We use a CTE to map the names to IDs and apply different manual scores. +WITH NewModelStats (m_name, m_elo) AS ( + VALUES + ('@cf/meta/llama-3.1-8b-instruct-fast', 1050), + ('@cf/nvidia/nemotron-3-120b-a12b', 1080), + ('@cf/zai-org/glm-4.7-flash', 1020), + ('@cf/ibm-granite/granite-4.0-h-micro', 1010), + ('@cf/aisingapore/gemma-sea-lion-v4-27b-it', 1040), + ('@cf/qwen/qwen3-30b-a3b-fp8', 1090), + ('@cf/mistralai/mistral-small-3.1-24b-instruct', 1070), + ('@cf/qwen/qwq-32b', 1110), + ('@cf/qwen/qwen2.5-coder-32b-instruct', 1095), + ('@cf/meta/llama-guard-3-8b', 1000), + ('@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', 1120), + ('@cf/meta/llama-3.3-70b-instruct-fp8-fast', 1150), + ('@cf/meta/llama-3.2-1b-instruct', 980), + ('@cf/meta/llama-3.2-3b-instruct', 1010), + ('@cf/meta/llama-3.2-11b-vision-instruct', 1030), + ('@cf/meta/llama-3.1-8b-instruct-awq', 1025), + ('@cf/meta/llama-3.1-8b-instruct-fp8', 1025), + ('@cf/meta/llama-3.1-8b-instruct', 1020), + ('@hf/meta-llama/meta-llama-3-8b-instruct', 1045), + ('@cf/meta/llama-3-8b-instruct-awq', 1015), + ('@cf/meta/llama-3-8b-instruct', 1015), + ('@hf/mistral/mistral-7b-instruct-v0.2', 1035), + ('@cf/google/gemma-7b-it-lora', 1005), + ('@cf/google/gemma-2b-it-lora', 975), + ('@cf/meta-llama/llama-2-7b-chat-hf-lora', 990), + ('@hf/google/gemma-7b-it', 1010), + ('@hf/nousresearch/hermes-2-pro-mistral-7b', 1030), + ('@cf/mistral/mistral-7b-instruct-v0.2-lora', 1000), + ('@cf/defog/sqlcoder-7b-2', 1010), + ('@cf/microsoft/phi-2', 960), + ('@cf/meta/llama-2-7b-chat-fp16', 985), + ('@cf/mistral/mistral-7b-instruct-v0.1', 1020), + ('@cf/meta/llama-2-7b-chat-int8', 980), + ('@cf/meta/llama-3.1-70b-instruct', 1140) +) +INSERT INTO public.model_leaderboard ( + model_id, + elo_score, + total_wins, + total_losses, + total_ties, + total_comparisons, + updated_at +) +SELECT + m.model_id, + nms.m_elo, + 0, 0, 0, 0, now() +FROM public.ai_models m +JOIN NewModelStats nms ON m.model_name = nms.m_name +ON CONFLICT (model_id) DO UPDATE SET elo_score = EXCLUDED.elo_score; + +COMMIT; diff --git a/database/migrations/20260319_manual_cloudflare_leaderboard_seed.sql b/database/migrations/20260319_manual_cloudflare_leaderboard_seed.sql new file mode 100644 index 0000000..cfc0ce3 --- /dev/null +++ b/database/migrations/20260319_manual_cloudflare_leaderboard_seed.sql @@ -0,0 +1,75 @@ +-- ============================================================================= +-- MANUAL LEADERBOARD INSERT FOR NEW CLOUDFLARE MODELS +-- +-- This script explicitly targets the list of models you provided. +-- It matches them by 'model_name' to their 'model_id' in the ai_models table +-- and inserts them into 'model_leaderboard' with randomized initial ELOs. +-- ============================================================================= + +BEGIN; + +DO $$ +DECLARE + row_count integer; +BEGIN + INSERT INTO public.model_leaderboard ( + model_id, + elo_score, + total_wins, + total_losses, + total_ties, + total_comparisons, + updated_at + ) + SELECT + m.model_id, + (950 + (random() * 150))::INTEGER as initial_elo, -- Random score between 950 and 1100 + 0, 0, 0, 0, now() + FROM public.ai_models m + WHERE m.model_name IN ( + '@cf/meta/llama-3.1-8b-instruct-fast', + '@cf/nvidia/nemotron-3-120b-a12b', + '@cf/zai-org/glm-4.7-flash', + '@cf/ibm-granite/granite-4.0-h-micro', + '@cf/aisingapore/gemma-sea-lion-v4-27b-it', + '@cf/qwen/qwen3-30b-a3b-fp8', + '@cf/mistralai/mistral-small-3.1-24b-instruct', + '@cf/qwen/qwq-32b', + '@cf/qwen/qwen2.5-coder-32b-instruct', + '@cf/meta/llama-guard-3-8b', + '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', + '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + '@cf/meta/llama-3.2-1b-instruct', + '@cf/meta/llama-3.2-3b-instruct', + '@cf/meta/llama-3.2-11b-vision-instruct', + '@cf/meta/llama-3.1-8b-instruct-awq', + '@cf/meta/llama-3.1-8b-instruct-fp8', + '@cf/meta/llama-3.1-8b-instruct', + '@hf/meta-llama/meta-llama-3-8b-instruct', + '@cf/meta/llama-3-8b-instruct-awq', + '@cf/meta/llama-3-8b-instruct', + '@hf/mistral/mistral-7b-instruct-v0.2', + '@cf/google/gemma-7b-it-lora', + '@cf/google/gemma-2b-it-lora', + '@cf/meta-llama/llama-2-7b-chat-hf-lora', + '@hf/google/gemma-7b-it', + '@hf/nousresearch/hermes-2-pro-mistral-7b', + '@cf/mistral/mistral-7b-instruct-v0.2-lora', + '@cf/defog/sqlcoder-7b-2', + '@cf/microsoft/phi-2', + '@cf/meta/llama-2-7b-chat-fp16', + '@cf/mistral/mistral-7b-instruct-v0.1', + '@cf/meta/llama-2-7b-chat-int8', + '@cf/meta/llama-3.1-70b-instruct' + ) + -- Only insert if they don't already have a stats record + AND NOT EXISTS ( + SELECT 1 FROM public.model_leaderboard ml WHERE ml.model_id = m.model_id + ) + ON CONFLICT (model_id) DO NOTHING; + + GET DIAGNOSTICS row_count = ROW_COUNT; + RAISE NOTICE 'Initialized leaderboard entries for % new Cloudflare models.', row_count; +END $$; + +COMMIT; diff --git a/database/migrations/20260319_manual_elo_setup_and_seed.sql b/database/migrations/20260319_manual_elo_setup_and_seed.sql new file mode 100644 index 0000000..98aa862 --- /dev/null +++ b/database/migrations/20260319_manual_elo_setup_and_seed.sql @@ -0,0 +1,135 @@ +-- ============================================================================= +-- DUALMIND ELO SYSTEM & MANUAL CLOUDFLARE MODEL SEEDING +-- ============================================================================= +-- This script: +-- 1. Ensures the ELO rating infrastructure exists (table, function, view) +-- 2. Manually seeds the specific list of Cloudflare models with randomized ELOs +-- ============================================================================= + +BEGIN; + +-- 1. Ensure the leaderboard table exists +CREATE TABLE IF NOT EXISTS public.model_leaderboard ( + model_id uuid PRIMARY KEY REFERENCES public.ai_models(model_id) ON DELETE CASCADE, + elo_score integer DEFAULT 1000 NOT NULL, + total_wins integer DEFAULT 0, + total_losses integer DEFAULT 0, + total_ties integer DEFAULT 0, + total_comparisons integer DEFAULT 0, + updated_at timestamp with time zone DEFAULT now() +); + +-- 2. Create the ELO update helper function (the "ELO rating query") +CREATE OR REPLACE FUNCTION public.calculate_elo_update( + winner_elo integer, + loser_elo integer, + is_tie boolean DEFAULT false, + k_factor integer DEFAULT 32 +) RETURNS TABLE (new_winner_elo integer, new_loser_elo integer, elo_delta integer) AS $$ +DECLARE + expected_winner float; + expected_loser float; + score_winner float; + score_loser float; + delta_w integer; + delta_l integer; +BEGIN + expected_winner := 1.0 / (1.0 + pow(10, (loser_elo - winner_elo)::float / 400.0)); + expected_loser := 1.0 / (1.0 + pow(10, (winner_elo - loser_elo)::float / 400.0)); + + IF is_tie THEN + score_winner := 0.5; + score_loser := 0.5; + ELSE + score_winner := 1.0; + score_loser := 0.0; + END IF; + + delta_w := round(k_factor * (score_winner - expected_winner))::integer; + delta_l := round(k_factor * (score_loser - expected_loser))::integer; + + RETURN QUERY SELECT + winner_elo + delta_w, + loser_elo + delta_l, + delta_w; +END; +$$ LANGUAGE plpgsql; + +-- 3. Create OR REPLACE the view used by the backend service +CREATE OR REPLACE VIEW public.v_leaderboard AS +SELECT + m.model_id, + m.model_name, + m.display_name, + m.provider_name, + ml.elo_score, + ml.total_wins, + ml.total_losses, + ml.total_ties, + ml.total_comparisons, + CASE + WHEN ml.total_comparisons = 0 THEN 0.0 + ELSE ROUND((ml.total_wins::float / ml.total_comparisons::float) * 100, 2) + END as win_rate, + RANK() OVER (ORDER BY ml.elo_score DESC) as elo_rank +FROM public.ai_models m +JOIN public.model_leaderboard ml ON m.model_id = ml.model_id +WHERE m.status = 'active'; + +-- 4. MANUALLY SEED the specific Cloudflare models you provided +-- Initial scores are randomized between 950 and 1100 to give them a baseline. +INSERT INTO public.model_leaderboard ( + model_id, + elo_score, + total_wins, + total_losses, + total_ties, + total_comparisons, + updated_at +) +SELECT + m.model_id, + (950 + (random() * 150))::INTEGER as initial_elo, + 0, 0, 0, 0, now() +FROM public.ai_models m +WHERE m.model_name IN ( + '@cf/meta/llama-3.1-8b-instruct-fast', + '@cf/nvidia/nemotron-3-120b-a12b', + '@cf/zai-org/glm-4.7-flash', + '@cf/ibm-granite/granite-4.0-h-micro', + '@cf/aisingapore/gemma-sea-lion-v4-27b-it', + '@cf/qwen/qwen3-30b-a3b-fp8', + '@cf/mistralai/mistral-small-3.1-24b-instruct', + '@cf/qwen/qwq-32b', + '@cf/qwen/qwen2.5-coder-32b-instruct', + '@cf/meta/llama-guard-3-8b', + '@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', + '@cf/meta/llama-3.3-70b-instruct-fp8-fast', + '@cf/meta/llama-3.2-1b-instruct', + '@cf/meta/llama-3.2-3b-instruct', + '@cf/meta/llama-3.2-11b-vision-instruct', + '@cf/meta/llama-3.1-8b-instruct-awq', + '@cf/meta/llama-3.1-8b-instruct-fp8', + '@cf/meta/llama-3.1-8b-instruct', + '@hf/meta-llama/meta-llama-3-8b-instruct', + '@cf/meta/llama-3-8b-instruct-awq', + '@cf/meta/llama-3-8b-instruct', + '@hf/mistral/mistral-7b-instruct-v0.2', + '@cf/google/gemma-7b-it-lora', + '@cf/google/gemma-2b-it-lora', + '@cf/meta-llama/llama-2-7b-chat-hf-lora', + '@hf/google/gemma-7b-it', + '@hf/nousresearch/hermes-2-pro-mistral-7b', + '@cf/mistral/mistral-7b-instruct-v0.2-lora', + '@cf/defog/sqlcoder-7b-2', + '@cf/microsoft/phi-2', + '@cf/meta/llama-2-7b-chat-fp16', + '@cf/mistral/mistral-7b-instruct-v0.1', + '@cf/meta/llama-2-7b-chat-int8', + '@cf/meta/llama-3.1-70b-instruct' +) +-- Only seed if they don't already have an entry +-- If you want to force-overwrite, change 'DO NOTHING' to 'DO UPDATE' +ON CONFLICT (model_id) DO NOTHING; + +COMMIT; diff --git a/database/migrations/20260319_seed_cloudflare_leaderboard.sql b/database/migrations/20260319_seed_cloudflare_leaderboard.sql new file mode 100644 index 0000000..8532a29 --- /dev/null +++ b/database/migrations/20260319_seed_cloudflare_leaderboard.sql @@ -0,0 +1,51 @@ +-- ============================================================================= +-- SEED LEADERBOARD FOR CLOUDFLARE WORKERS AI +-- +-- This script initializes the 'model_leaderboard' table for all active +-- Cloudflare models that don't already have an entry. +-- +-- Note: Initial ELO scores are randomized between 950 and 1050 to give a +-- realistic starting spread as requested. +-- ============================================================================= + +BEGIN; + +DO $$ +BEGIN + -- Informational notice + RAISE NOTICE 'Seeding model_leaderboard for Cloudflare provider...'; + + -- Insert missing entries for all 'cloudflare' models in 'ai_models' + -- Using explicit column list and ON CONFLICT for safety + INSERT INTO public.model_leaderboard ( + model_id, + elo_score, + total_wins, + total_losses, + total_ties, + total_comparisons, + updated_at + ) + SELECT + m.model_id, + (950 + (random() * 100))::INTEGER as initial_elo, -- Random score between 950 and 1050 + 0 as total_wins, + 0 as total_losses, + 0 as total_ties, + 0 as total_comparisons, + now() as updated_at + FROM public.ai_models m + WHERE m.provider_name = 'cloudflare' + AND m.status = 'active' + -- Only insert if they don't already exist in the leaderboard + AND NOT EXISTS ( + SELECT 1 + FROM public.model_leaderboard ml + WHERE ml.model_id = m.model_id + ) + ON CONFLICT (model_id) DO NOTHING; + + RAISE NOTICE 'Cloudflare leaderboard seeding complete.'; +END $$; + +COMMIT; diff --git a/postman/cloudflare-workers-ai-models-stable.csv b/postman/cloudflare-workers-ai-models-stable.csv new file mode 100644 index 0000000..23611b7 --- /dev/null +++ b/postman/cloudflare-workers-ai-models-stable.csv @@ -0,0 +1,28 @@ +model +@cf/meta/llama-3.1-8b-instruct-fast +@cf/nvidia/nemotron-3-120b-a12b +@cf/zai-org/glm-4.7-flash +@cf/ibm-granite/granite-4.0-h-micro +@cf/aisingapore/gemma-sea-lion-v4-27b-it +@cf/qwen/qwen3-30b-a3b-fp8 +@cf/mistralai/mistral-small-3.1-24b-instruct +@cf/qwen/qwq-32b +@cf/qwen/qwen2.5-coder-32b-instruct +@cf/meta/llama-guard-3-8b +@cf/deepseek-ai/deepseek-r1-distill-qwen-32b +@cf/meta/llama-3.3-70b-instruct-fp8-fast +@cf/meta/llama-3.2-1b-instruct +@cf/meta/llama-3.2-3b-instruct +@cf/meta/llama-3.1-8b-instruct-awq +@cf/meta/llama-3.1-8b-instruct-fp8 +@cf/meta/llama-3.1-8b-instruct +@hf/meta-llama/meta-llama-3-8b-instruct +@cf/meta/llama-3-8b-instruct-awq +@cf/meta/llama-3-8b-instruct +@hf/mistral/mistral-7b-instruct-v0.2 +@cf/google/gemma-2b-it-lora +@cf/mistral/mistral-7b-instruct-v0.2-lora +@cf/defog/sqlcoder-7b-2 +@cf/mistral/mistral-7b-instruct-v0.1 +@cf/meta/llama-2-7b-chat-int8 +@cf/meta/llama-3.1-70b-instruct diff --git a/postman/cloudflare-workers-ai-models-stable.json b/postman/cloudflare-workers-ai-models-stable.json new file mode 100644 index 0000000..44d62c1 --- /dev/null +++ b/postman/cloudflare-workers-ai-models-stable.json @@ -0,0 +1,29 @@ +[ + { "model": "@cf/meta/llama-3.1-8b-instruct-fast" }, + { "model": "@cf/nvidia/nemotron-3-120b-a12b" }, + { "model": "@cf/zai-org/glm-4.7-flash" }, + { "model": "@cf/ibm-granite/granite-4.0-h-micro" }, + { "model": "@cf/aisingapore/gemma-sea-lion-v4-27b-it" }, + { "model": "@cf/qwen/qwen3-30b-a3b-fp8" }, + { "model": "@cf/mistralai/mistral-small-3.1-24b-instruct" }, + { "model": "@cf/qwen/qwq-32b" }, + { "model": "@cf/qwen/qwen2.5-coder-32b-instruct" }, + { "model": "@cf/meta/llama-guard-3-8b" }, + { "model": "@cf/deepseek-ai/deepseek-r1-distill-qwen-32b" }, + { "model": "@cf/meta/llama-3.3-70b-instruct-fp8-fast" }, + { "model": "@cf/meta/llama-3.2-1b-instruct" }, + { "model": "@cf/meta/llama-3.2-3b-instruct" }, + { "model": "@cf/meta/llama-3.1-8b-instruct-awq" }, + { "model": "@cf/meta/llama-3.1-8b-instruct-fp8" }, + { "model": "@cf/meta/llama-3.1-8b-instruct" }, + { "model": "@hf/meta-llama/meta-llama-3-8b-instruct" }, + { "model": "@cf/meta/llama-3-8b-instruct-awq" }, + { "model": "@cf/meta/llama-3-8b-instruct" }, + { "model": "@hf/mistral/mistral-7b-instruct-v0.2" }, + { "model": "@cf/google/gemma-2b-it-lora" }, + { "model": "@cf/mistral/mistral-7b-instruct-v0.2-lora" }, + { "model": "@cf/defog/sqlcoder-7b-2" }, + { "model": "@cf/mistral/mistral-7b-instruct-v0.1" }, + { "model": "@cf/meta/llama-2-7b-chat-int8" }, + { "model": "@cf/meta/llama-3.1-70b-instruct" } +] diff --git a/src/DualMind.API/AI/Gateway/ChatProviderFactory.cs b/src/DualMind.API/AI/Gateway/ChatProviderFactory.cs index 6f69191..adf1610 100644 --- a/src/DualMind.API/AI/Gateway/ChatProviderFactory.cs +++ b/src/DualMind.API/AI/Gateway/ChatProviderFactory.cs @@ -8,11 +8,16 @@ public class ChatProviderFactory : IChatProviderFactory { private readonly GroqService _groqService; private readonly GoogleService _googleService; + private readonly CloudflareWorkersAiService _cloudflareWorkersAiService; - public ChatProviderFactory(GroqService groqService, GoogleService googleService) + public ChatProviderFactory( + GroqService groqService, + GoogleService googleService, + CloudflareWorkersAiService cloudflareWorkersAiService) { _groqService = groqService; _googleService = googleService; + _cloudflareWorkersAiService = cloudflareWorkersAiService; } public IChatProvider GetProvider(string providerName) @@ -22,8 +27,11 @@ public IChatProvider GetProvider(string providerName) return providerName.ToLower() switch { + "cloudflare" => _cloudflareWorkersAiService, "google" => _googleService, + "google-ai-studio" => _googleService, "groq" => _groqService, + "workers-ai" => _cloudflareWorkersAiService, _ => _groqService // Default fallback }; } diff --git a/src/DualMind.API/AI/Providers/CloudflareWorkersAiService.cs b/src/DualMind.API/AI/Providers/CloudflareWorkersAiService.cs new file mode 100644 index 0000000..26044b1 --- /dev/null +++ b/src/DualMind.API/AI/Providers/CloudflareWorkersAiService.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using DualMind.API.AI.Contracts; +using DualMind.API.Infrastructure.Configuration; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace DualMind.API.AI.Providers +{ + public class CloudflareWorkersAiService : IChatProvider + { + private readonly HttpClient _client; + private readonly ILogger _logger; + private readonly CloudflareAiGatewaySettings _aiGateway; + + public CloudflareWorkersAiService(HttpClient client, ILogger logger) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _aiGateway = CloudflareAiGatewaySettings.FromEnv(); + + _logger.LogInformation("CloudflareWorkersAiService: Using native Cloudflare Workers AI API for Cloudflare-hosted models."); + } + + public bool SupportsStreaming => true; + + public async Task ChatAsync(string model, string prompt, string systemPrompt = null, int? maxTokens = null, double? temperature = null) + { + _aiGateway.EnsureWorkersAiConfiguredForChat(); + + var resolvedModel = ResolveModel(model); + var messages = BuildMessages(prompt, systemPrompt); + var requestBody = new + { + model = resolvedModel, + messages = messages, + max_tokens = maxTokens ?? 4096, + temperature = temperature ?? 0.7 + }; + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, _aiGateway.WorkersAiDirectChatCompletionsUrl); + ApplyHeaders(requestMessage); + requestMessage.Content = new StringContent(JsonConvert.SerializeObject(requestBody), Encoding.UTF8, "application/json"); + + using (var response = await _client.SendAsync(requestMessage)) + { + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new Exception($"Cloudflare Workers AI API error ({(int)response.StatusCode}): {errorContent}"); + } + + var content = await response.Content.ReadAsStringAsync(); + return ParseChatResponse(content, model); + } + } + + public async Task StreamAsync(ChatRequest request, Func onEvent, CancellationToken cancellationToken) + { + _aiGateway.EnsureWorkersAiConfiguredForChat(); + + var model = string.IsNullOrWhiteSpace(request.Model) || request.Model == "auto" + ? EnvConfig.DefaultCloudflareWorkersAiModel + : request.Model; + + var requestBody = new + { + model = ResolveModel(model), + messages = BuildMessages(request.Prompt, request.System), + max_tokens = request.MaxTokens ?? 4096, + temperature = request.Temperature ?? 0.7, + stream = true + }; + + var requestMessage = new HttpRequestMessage(HttpMethod.Post, _aiGateway.WorkersAiDirectChatCompletionsUrl); + ApplyHeaders(requestMessage); + requestMessage.Content = new StringContent(JsonConvert.SerializeObject(requestBody), Encoding.UTF8, "application/json"); + requestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); + + using (var response = await _client.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken)) + { + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new Exception($"Cloudflare Workers AI Streaming API error ({(int)response.StatusCode}): {errorContent}"); + } + + using (var stream = await response.Content.ReadAsStreamAsync()) + using (var reader = new StreamReader(stream)) + { + string? line; + while ((line = await reader.ReadLineAsync()) != null) + { + if (cancellationToken.IsCancellationRequested) + { + break; + } + + if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ", StringComparison.Ordinal)) + { + continue; + } + + var data = line.Substring(6).Trim(); + if (data == "[DONE]") + { + await onEvent(new AIStreamEvent + { + Object = "ai.stream.done", + FinishReason = "stop" + }); + break; + } + + try + { + var chunk = JObject.Parse(data); + var deltaContent = chunk["choices"]?[0]?["delta"]?["content"]?.ToString(); + if (!string.IsNullOrEmpty(deltaContent)) + { + await onEvent(new AIStreamEvent + { + Object = "ai.stream.delta", + Delta = new AIStreamDelta + { + Type = "output_text", + Text = deltaContent + } + }); + } + } + catch + { + // Ignore malformed SSE chunks from upstream and keep the stream alive. + } + } + } + } + } + + private string ResolveModel(string model) + { + var fallbackModel = string.IsNullOrWhiteSpace(model) ? EnvConfig.DefaultCloudflareWorkersAiModel : model; + return _aiGateway.GetWorkersAiModel(fallbackModel); + } + + private static List BuildMessages(string? prompt, string? systemPrompt) + { + var messages = new List(); + if (!string.IsNullOrWhiteSpace(systemPrompt)) + { + messages.Add(new { role = "system", content = systemPrompt }); + } + + messages.Add(new { role = "user", content = prompt ?? string.Empty }); + return messages; + } + + private void ApplyHeaders(HttpRequestMessage request) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _aiGateway.WorkersAiApiToken!); + } + + private static GroqResponse ParseChatResponse(string content, string requestedModel) + { + var result = JObject.Parse(content); + var message = result["choices"]?[0]?["message"]?["content"]?.ToString() ?? string.Empty; + var usage = result["usage"]; + + return new GroqResponse + { + Message = message, + Model = requestedModel, + PromptTokens = usage?["prompt_tokens"]?.Value() ?? 0, + CompletionTokens = usage?["completion_tokens"]?.Value() ?? 0, + TotalTokens = usage?["total_tokens"]?.Value() ?? 0 + }; + } + } +} diff --git a/src/DualMind.API/AI/Providers/GoogleService.cs b/src/DualMind.API/AI/Providers/GoogleService.cs index c9d3b3e..c28f9c8 100644 --- a/src/DualMind.API/AI/Providers/GoogleService.cs +++ b/src/DualMind.API/AI/Providers/GoogleService.cs @@ -1,5 +1,6 @@ using System; using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -16,6 +17,7 @@ public class GoogleService : IChatProvider private readonly Core.Services.IProviderConfigService _config; private readonly Core.Services.ProviderErrorClassifier _classifier; private readonly ILogger _logger; + private readonly CloudflareAiGatewaySettings _aiGateway; // Using the new OpenAI-compatible endpoint from Google private const string GoogleApiUrl = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"; @@ -28,6 +30,7 @@ public GoogleService(HttpClient client, Core.Services.IProviderConfigService con _config = config ?? throw new ArgumentNullException(nameof(config)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _classifier = new Core.Services.ProviderErrorClassifier(); + _aiGateway = CloudflareAiGatewaySettings.FromEnv(); _envApiKey = EnvConfig.GoogleApiKey; @@ -39,9 +42,14 @@ public GoogleService(HttpClient client, Core.Services.IProviderConfigService con { _logger.LogInformation("GoogleService: No GOOGLE_API_KEY found in environment, will use database keys"); } + + if (_aiGateway.Enabled) + { + _logger.LogInformation("GoogleService: Cloudflare AI Gateway enabled for chat requests. BYOK mode: {UseByok}", _aiGateway.UseByok); + } } - private async Task ExecuteWithRetryAsync(Func> action) + private async Task ExecuteWithProviderRetryAsync(Func> action) { // Priority 1: Use environment variable API key if available if (!string.IsNullOrEmpty(_envApiKey)) @@ -111,11 +119,39 @@ private async Task ExecuteWithRetryAsync(Func> action) } } + private async Task ExecuteChatAsync(Func> action) + { + _aiGateway.EnsureGatewayConfiguredForChat("Google"); + + if (_aiGateway.Enabled && _aiGateway.UseByok) + { + return await action(_aiGateway.Token!); + } + + return await ExecuteWithProviderRetryAsync(action); + } + + private void ApplyChatHeaders(HttpRequestMessage request, string credential) + { + if (_aiGateway.Enabled && !string.IsNullOrWhiteSpace(_aiGateway.Token)) + { + request.Headers.TryAddWithoutValidation("cf-aig-authorization", $"Bearer {_aiGateway.Token}"); + } + + if (_aiGateway.Enabled && _aiGateway.UseByok) + { + return; + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", credential); + } + public async Task ChatAsync(string model, string prompt, string systemPrompt = null, int? maxTokens = null, double? temperature = null) { - var targetUrl = GoogleApiUrl; + var targetUrl = _aiGateway.Enabled ? _aiGateway.ChatCompletionsUrl : GoogleApiUrl; + var routedModel = _aiGateway.Enabled ? _aiGateway.GetCompatModel("google", model) : model; - return await ExecuteWithRetryAsync(async (apiKey) => + return await ExecuteChatAsync(async (credential) => { var messages = new System.Collections.Generic.List(); if (!string.IsNullOrEmpty(systemPrompt)) messages.Add(new { role = "system", content = systemPrompt }); @@ -123,7 +159,7 @@ public async Task ChatAsync(string model, string prompt, string sy var requestBody = new { - model = model, + model = routedModel, messages = messages, max_tokens = maxTokens ?? 4096, temperature = temperature ?? 0.7 @@ -132,8 +168,7 @@ public async Task ChatAsync(string model, string prompt, string sy var json = JsonConvert.SerializeObject(requestBody); var requestMsg = new HttpRequestMessage(HttpMethod.Post, targetUrl); - // Google expects Bearer token exactly like OpenAI for this specific endpoint - requestMsg.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); + ApplyChatHeaders(requestMsg, credential); requestMsg.Content = new StringContent(json, Encoding.UTF8, "application/json"); using (var response = await _client.SendAsync(requestMsg)) @@ -168,16 +203,17 @@ public async Task ChatAsync(string model, string prompt, string sy public async Task StreamAsync(ChatRequest request, Func onEvent, System.Threading.CancellationToken cancellationToken) { - await ExecuteWithRetryAsync(async (apiKey) => + await ExecuteChatAsync(async (credential) => { var model = request.Model == "auto" || string.IsNullOrEmpty(request.Model) ? "gemini-2.5-flash" : request.Model; + var routedModel = _aiGateway.Enabled ? _aiGateway.GetCompatModel("google", model) : model; var messages = new System.Collections.Generic.List(); if (!string.IsNullOrEmpty(request.System)) messages.Add(new { role = "system", content = request.System }); messages.Add(new { role = "user", content = request.Prompt }); var requestBody = new { - model = model, + model = routedModel, messages = messages, max_tokens = request.MaxTokens ?? 4096, temperature = request.Temperature ?? 0.7, @@ -185,8 +221,8 @@ await ExecuteWithRetryAsync(async (apiKey) => }; var json = JsonConvert.SerializeObject(requestBody); - var httpRequest = new HttpRequestMessage(HttpMethod.Post, GoogleApiUrl); - httpRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); + var httpRequest = new HttpRequestMessage(HttpMethod.Post, _aiGateway.Enabled ? _aiGateway.ChatCompletionsUrl : GoogleApiUrl); + ApplyChatHeaders(httpRequest, credential); httpRequest.Content = new StringContent(json, Encoding.UTF8, "application/json"); httpRequest.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); @@ -234,4 +270,4 @@ await ExecuteWithRetryAsync(async (apiKey) => }); } } -} \ No newline at end of file +} diff --git a/src/DualMind.API/AI/Providers/GroqService.cs b/src/DualMind.API/AI/Providers/GroqService.cs index df15153..56d7eb0 100644 --- a/src/DualMind.API/AI/Providers/GroqService.cs +++ b/src/DualMind.API/AI/Providers/GroqService.cs @@ -1,5 +1,6 @@ using System; using System.Net.Http; +using System.Net.Http.Headers; using System.Text; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -16,6 +17,7 @@ public class GroqService : IChatProvider private readonly Core.Services.IProviderConfigService _config; private readonly Core.Services.ProviderErrorClassifier _classifier; private readonly ILogger _logger; + private readonly CloudflareAiGatewaySettings _aiGateway; private const string GroqApiUrl = "https://api.groq.com/openai/v1/chat/completions"; private const string GroqSpeechApiUrl = "https://api.groq.com/openai/v1/audio/speech"; @@ -28,6 +30,7 @@ public GroqService(HttpClient client, Core.Services.IProviderConfigService confi _config = config ?? throw new ArgumentNullException(nameof(config)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _classifier = new Core.Services.ProviderErrorClassifier(); + _aiGateway = CloudflareAiGatewaySettings.FromEnv(); // Check for GROQ_API_KEY in environment variables first (from .env or Azure secrets) _envApiKey = EnvConfig.GroqApiKey; @@ -40,9 +43,14 @@ public GroqService(HttpClient client, Core.Services.IProviderConfigService confi { _logger.LogInformation("GroqService: No GROQ_API_KEY found in environment, will use database keys"); } + + if (_aiGateway.Enabled) + { + _logger.LogInformation("GroqService: Cloudflare AI Gateway enabled for chat requests. BYOK mode: {UseByok}", _aiGateway.UseByok); + } } - private async Task ExecuteWithRetryAsync(Func> action) + private async Task ExecuteWithProviderRetryAsync(Func> action) { // Priority 1: Use environment variable API key if available (local .env or Azure secrets) if (!string.IsNullOrEmpty(_envApiKey)) @@ -116,11 +124,39 @@ private async Task ExecuteWithRetryAsync(Func> action) } } + private async Task ExecuteChatAsync(Func> action) + { + _aiGateway.EnsureGatewayConfiguredForChat("Groq"); + + if (_aiGateway.Enabled && _aiGateway.UseByok) + { + return await action(_aiGateway.Token!); + } + + return await ExecuteWithProviderRetryAsync(action); + } + + private void ApplyChatHeaders(HttpRequestMessage request, string credential) + { + if (_aiGateway.Enabled && !string.IsNullOrWhiteSpace(_aiGateway.Token)) + { + request.Headers.TryAddWithoutValidation("cf-aig-authorization", $"Bearer {_aiGateway.Token}"); + } + + if (_aiGateway.Enabled && _aiGateway.UseByok) + { + return; + } + + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", credential); + } + public async Task ChatAsync(string model, string prompt, string systemPrompt = null, int? maxTokens = null, double? temperature = null) { - var targetUrl = GroqApiUrl; + var targetUrl = _aiGateway.Enabled ? _aiGateway.ChatCompletionsUrl : GroqApiUrl; + var routedModel = _aiGateway.Enabled ? _aiGateway.GetCompatModel("groq", model) : model; - return await ExecuteWithRetryAsync(async (apiKey) => + return await ExecuteChatAsync(async (credential) => { var messages = new System.Collections.Generic.List(); if (!string.IsNullOrEmpty(systemPrompt)) messages.Add(new { role = "system", content = systemPrompt }); @@ -128,7 +164,7 @@ public async Task ChatAsync(string model, string prompt, string sy var requestBody = new { - model = model, + model = routedModel, messages = messages, max_tokens = maxTokens ?? 4096, temperature = temperature ?? 0.7 @@ -137,7 +173,7 @@ public async Task ChatAsync(string model, string prompt, string sy var json = JsonConvert.SerializeObject(requestBody); var requestMsg = new HttpRequestMessage(HttpMethod.Post, targetUrl); - requestMsg.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); + ApplyChatHeaders(requestMsg, credential); requestMsg.Content = new StringContent(json, Encoding.UTF8, "application/json"); using (var response = await _client.SendAsync(requestMsg)) @@ -170,7 +206,7 @@ public async Task ChatAsync(string model, string prompt, string sy public async Task GenerateSpeechAsync(string text, string voice = "Celeste-PlayAI") { - return await ExecuteWithRetryAsync(async (apiKey) => + return await ExecuteWithProviderRetryAsync(async (apiKey) => { var requestBody = new { @@ -203,18 +239,19 @@ public async Task GenerateSpeechAsync(string text, string voice = "Celes public async Task StreamAsync(ChatRequest request, Func onEvent, System.Threading.CancellationToken cancellationToken) { - // For streaming, we just use ExecuteWithRetryAsync but it returns empty Task. + // For streaming, we just use the same chat credential path but it returns an empty Task. // The stream processing happens inside the action. - await ExecuteWithRetryAsync(async (apiKey) => + await ExecuteChatAsync(async (credential) => { var model = request.Model == "auto" || string.IsNullOrEmpty(request.Model) ? EnvConfig.DefaultGroqModel : request.Model; + var routedModel = _aiGateway.Enabled ? _aiGateway.GetCompatModel("groq", model) : model; var messages = new System.Collections.Generic.List(); if (!string.IsNullOrEmpty(request.System)) messages.Add(new { role = "system", content = request.System }); messages.Add(new { role = "user", content = request.Prompt }); var requestBody = new { - model = model, + model = routedModel, messages = messages, max_tokens = request.MaxTokens ?? 4096, temperature = request.Temperature ?? 0.7, @@ -222,8 +259,8 @@ await ExecuteWithRetryAsync(async (apiKey) => }; var json = JsonConvert.SerializeObject(requestBody); - var httpRequest = new HttpRequestMessage(HttpMethod.Post, GroqApiUrl); - httpRequest.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); + var httpRequest = new HttpRequestMessage(HttpMethod.Post, _aiGateway.Enabled ? _aiGateway.ChatCompletionsUrl : GroqApiUrl); + ApplyChatHeaders(httpRequest, credential); httpRequest.Content = new StringContent(json, Encoding.UTF8, "application/json"); httpRequest.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); diff --git a/src/DualMind.API/Core/Services/ModelSelector.cs b/src/DualMind.API/Core/Services/ModelSelector.cs index 9cca394..e19f496 100644 --- a/src/DualMind.API/Core/Services/ModelSelector.cs +++ b/src/DualMind.API/Core/Services/ModelSelector.cs @@ -13,10 +13,16 @@ public class ModelSelector : IModelSelector { private static readonly HashSet SupportedProviders = new(StringComparer.OrdinalIgnoreCase) { + "cloudflare", "groq", "google" }; + private static readonly HashSet ManualOnlyProviders = new(StringComparer.OrdinalIgnoreCase) + { + "cloudflare" + }; + private readonly ISupabaseService _supabase; private readonly IMemoryCache _cache; private readonly ILogger _logger; @@ -120,9 +126,9 @@ public ModelDefinition GetModelInfo(string modelName) private async Task GetRandomModelInternalAsync() { - var models = await LoadModelsAsync(); + var models = GetAutoSelectableModels(await LoadModelsAsync()); if (models.Count == 0) - throw new InvalidOperationException("No active models found for the providers supported by this backend."); + throw new InvalidOperationException("No auto-selectable models found. Cloudflare models are manual-only, so keep at least one active Groq or Google model."); var index = _random.Next(models.Count); return models[index].Name; @@ -130,13 +136,20 @@ private async Task GetRandomModelInternalAsync() private async Task<(string model1, string model2)> GetTwoRandomModelsInternalAsync() { - var models = await LoadModelsAsync(); + var models = GetAutoSelectableModels(await LoadModelsAsync()); if (models.Count < 2) - throw new InvalidOperationException("Need at least 2 active models from supported providers to run a battle."); + throw new InvalidOperationException("Need at least 2 auto-selectable models to run a battle. Cloudflare models are manual-only."); var shuffled = models.OrderBy(_ => _random.Next()).Take(2).ToList(); return (shuffled[0].Name, shuffled[1].Name); } + + private static List GetAutoSelectableModels(IEnumerable models) + { + return models + .Where(m => string.IsNullOrWhiteSpace(m.Provider) || !ManualOnlyProviders.Contains(m.Provider)) + .ToList(); + } } public class ModelDefinition diff --git a/src/DualMind.API/Infrastructure/Configuration/CloudflareAiGatewaySettings.cs b/src/DualMind.API/Infrastructure/Configuration/CloudflareAiGatewaySettings.cs new file mode 100644 index 0000000..de2393c --- /dev/null +++ b/src/DualMind.API/Infrastructure/Configuration/CloudflareAiGatewaySettings.cs @@ -0,0 +1,111 @@ +using System; + +namespace DualMind.API.Infrastructure.Configuration +{ + public sealed class CloudflareAiGatewaySettings + { + public CloudflareAiGatewaySettings(string? accountId, string? gatewayId, string? token, bool useByok, string? workersAiApiToken = null) + { + AccountId = accountId?.Trim(); + GatewayId = gatewayId?.Trim(); + Token = token?.Trim(); + UseByok = useByok; + WorkersAiApiToken = workersAiApiToken?.Trim(); + } + + public string? AccountId { get; } + public string? GatewayId { get; } + public string? Token { get; } + public bool UseByok { get; } + public string? WorkersAiApiToken { get; } + + public bool Enabled => + !string.IsNullOrWhiteSpace(AccountId) && + !string.IsNullOrWhiteSpace(GatewayId); + + public string ChatCompletionsUrl => + $"https://gateway.ai.cloudflare.com/v1/{AccountId}/{GatewayId}/compat/chat/completions"; + + public string WorkersAiChatCompletionsUrl => + $"https://gateway.ai.cloudflare.com/v1/{AccountId}/{GatewayId}/workers-ai/v1/chat/completions"; + + public string WorkersAiDirectChatCompletionsUrl => + $"https://api.cloudflare.com/client/v4/accounts/{AccountId}/ai/v1/chat/completions"; + + public static CloudflareAiGatewaySettings FromEnv() + { + return new CloudflareAiGatewaySettings( + EnvConfig.CloudflareAiGatewayAccountId, + EnvConfig.CloudflareAiGatewayId, + EnvConfig.CloudflareAiGatewayToken, + EnvConfig.CloudflareAiGatewayUseByok, + EnvConfig.CloudflareWorkersAiApiToken + ); + } + + public string GetCompatModel(string? providerName, string modelName) + { + if (string.IsNullOrWhiteSpace(modelName)) + { + return string.Empty; + } + + if (modelName.Contains("/", StringComparison.Ordinal)) + { + return modelName; + } + + return providerName?.Trim().ToLowerInvariant() switch + { + "groq" => $"groq/{modelName}", + "google" => $"google-ai-studio/{modelName}", + "google-ai-studio" => $"google-ai-studio/{modelName}", + _ => modelName + }; + } + + public string GetWorkersAiModel(string? modelName) + { + if (string.IsNullOrWhiteSpace(modelName)) + { + return string.Empty; + } + + const string workersAiPrefix = "workers-ai/"; + return modelName.StartsWith(workersAiPrefix, StringComparison.OrdinalIgnoreCase) + ? modelName.Substring(workersAiPrefix.Length) + : modelName; + } + + public void EnsureGatewayConfiguredForChat(string providerName) + { + if (!Enabled) + { + throw new InvalidOperationException( + $"{providerName} chat requests are configured to require Cloudflare AI Gateway. " + + "Set CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID and CLOUDFLARE_AI_GATEWAY_ID."); + } + + if (UseByok && string.IsNullOrWhiteSpace(Token)) + { + throw new InvalidOperationException( + $"{providerName} chat requests require CLOUDFLARE_AI_GATEWAY_TOKEN when BYOK mode is enabled."); + } + } + + public void EnsureWorkersAiConfiguredForChat() + { + if (string.IsNullOrWhiteSpace(AccountId)) + { + throw new InvalidOperationException( + "Cloudflare Workers AI chat requests require CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID."); + } + + if (string.IsNullOrWhiteSpace(WorkersAiApiToken)) + { + throw new InvalidOperationException( + "Cloudflare Workers AI chat requests require CLOUDFLARE_WORKERS_AI_API_TOKEN."); + } + } + } +} diff --git a/src/DualMind.API/Infrastructure/Configuration/EnvConfig.cs b/src/DualMind.API/Infrastructure/Configuration/EnvConfig.cs index 4fc1567..18ab09d 100644 --- a/src/DualMind.API/Infrastructure/Configuration/EnvConfig.cs +++ b/src/DualMind.API/Infrastructure/Configuration/EnvConfig.cs @@ -43,5 +43,12 @@ public static string Get(string key, string defaultValue = null) public static string AppSecret => Get("APP_SECRET"); public static string DefaultGroqModel => Get("DEFAULT_GROQ_MODEL", "llama-3.3-70b-versatile"); public static string BasicFallbackModel => Get("BASIC_FALLBACK_MODEL", "llama-3.1-8b-instant"); + public static string CloudflareAiGatewayAccountId => Get("CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID"); + public static string CloudflareAiGatewayId => Get("CLOUDFLARE_AI_GATEWAY_ID"); + public static string CloudflareAiGatewayToken => Get("CLOUDFLARE_AI_GATEWAY_TOKEN"); + public static string CloudflareWorkersAiApiToken => Get("CLOUDFLARE_WORKERS_AI_API_TOKEN"); + public static string DefaultCloudflareWorkersAiModel => Get("DEFAULT_CLOUDFLARE_WORKERS_AI_MODEL", "@cf/meta/llama-3.1-8b-instruct"); + public static bool CloudflareAiGatewayUseByok => + string.Equals(Get("CLOUDFLARE_AI_GATEWAY_USE_BYOK", "false"), "true", StringComparison.OrdinalIgnoreCase); } } diff --git a/src/DualMind.API/Program.cs b/src/DualMind.API/Program.cs index 916c814..9a781f1 100644 --- a/src/DualMind.API/Program.cs +++ b/src/DualMind.API/Program.cs @@ -166,6 +166,10 @@ { client.Timeout = TimeSpan.FromSeconds(45); }); +builder.Services.AddHttpClient(client => +{ + client.Timeout = TimeSpan.FromSeconds(45); +}); builder.Services.AddScoped(); builder.Services.AddMemoryCache(); diff --git a/tests/DualMind.API.Tests/CloudflareAiGatewaySettingsTests.cs b/tests/DualMind.API.Tests/CloudflareAiGatewaySettingsTests.cs new file mode 100644 index 0000000..82100c3 --- /dev/null +++ b/tests/DualMind.API.Tests/CloudflareAiGatewaySettingsTests.cs @@ -0,0 +1,80 @@ +using DualMind.API.Infrastructure.Configuration; + +namespace DualMind.API.Tests; + +public class CloudflareAiGatewaySettingsTests +{ + [Fact] + public void ChatCompletionsUrl_IsBuiltFromAccountAndGatewayIds() + { + var settings = new CloudflareAiGatewaySettings("acct-123", "gw-456", null, false); + + Assert.True(settings.Enabled); + Assert.Equal( + "https://gateway.ai.cloudflare.com/v1/acct-123/gw-456/compat/chat/completions", + settings.ChatCompletionsUrl); + } + + [Fact] + public void WorkersAiChatCompletionsUrl_IsBuiltFromAccountAndGatewayIds() + { + var settings = new CloudflareAiGatewaySettings("acct-123", "gw-456", null, false); + + Assert.Equal( + "https://gateway.ai.cloudflare.com/v1/acct-123/gw-456/workers-ai/v1/chat/completions", + settings.WorkersAiChatCompletionsUrl); + } + + [Theory] + [InlineData("groq", "llama-3.3-70b-versatile", "groq/llama-3.3-70b-versatile")] + [InlineData("google", "gemini-2.5-flash", "google-ai-studio/gemini-2.5-flash")] + [InlineData("google-ai-studio", "gemini-2.5-flash", "google-ai-studio/gemini-2.5-flash")] + [InlineData("groq", "groq/llama-3.3-70b-versatile", "groq/llama-3.3-70b-versatile")] + [InlineData("unknown", "custom-model", "custom-model")] + public void GetCompatModel_PreservesInternalNamesAndPrefixesSupportedProviders(string provider, string model, string expected) + { + var settings = new CloudflareAiGatewaySettings("acct-123", "gw-456", null, false); + + Assert.Equal(expected, settings.GetCompatModel(provider, model)); + } + + [Fact] + public void EnsureGatewayConfiguredForChat_ThrowsWhenGatewayIsMissing() + { + var settings = new CloudflareAiGatewaySettings(null, null, null, false); + + var exception = Assert.Throws(() => settings.EnsureGatewayConfiguredForChat("Groq")); + + Assert.Contains("Cloudflare AI Gateway", exception.Message); + } + + [Fact] + public void EnsureGatewayConfiguredForChat_ThrowsWhenByokTokenIsMissing() + { + var settings = new CloudflareAiGatewaySettings("acct-123", "gw-456", null, true); + + var exception = Assert.Throws(() => settings.EnsureGatewayConfiguredForChat("Google")); + + Assert.Contains("CLOUDFLARE_AI_GATEWAY_TOKEN", exception.Message); + } + + [Theory] + [InlineData("@cf/meta/llama-3.1-8b-instruct", "@cf/meta/llama-3.1-8b-instruct")] + [InlineData("workers-ai/@cf/meta/llama-3.1-8b-instruct", "@cf/meta/llama-3.1-8b-instruct")] + public void GetWorkersAiModel_NormalizesWorkersAiPrefix(string model, string expected) + { + var settings = new CloudflareAiGatewaySettings("acct-123", "gw-456", null, false); + + Assert.Equal(expected, settings.GetWorkersAiModel(model)); + } + + [Fact] + public void EnsureWorkersAiConfiguredForChat_ThrowsWhenWorkersAiTokenIsMissing() + { + var settings = new CloudflareAiGatewaySettings("acct-123", "gw-456", "gateway-token", false, null); + + var exception = Assert.Throws(() => settings.EnsureWorkersAiConfiguredForChat()); + + Assert.Contains("CLOUDFLARE_WORKERS_AI_API_TOKEN", exception.Message); + } +} diff --git a/tests/DualMind.API.Tests/ModelSelectorTests.cs b/tests/DualMind.API.Tests/ModelSelectorTests.cs index 8087c0e..f477e87 100644 --- a/tests/DualMind.API.Tests/ModelSelectorTests.cs +++ b/tests/DualMind.API.Tests/ModelSelectorTests.cs @@ -40,6 +40,14 @@ public async Task GetAllModels_FiltersUnsupportedProviders() display_name = "Gemini 2.0 Flash", provider_name = "google", status = "active" + }), + JObject.FromObject(new + { + model_id = Guid.NewGuid().ToString(), + model_name = "@cf/meta/llama-3.1-8b-instruct", + display_name = "llama-3.1-8b-instruct", + provider_name = "cloudflare", + status = "active" }) }); @@ -49,10 +57,81 @@ public async Task GetAllModels_FiltersUnsupportedProviders() await selector.GetTwoRandomModelsAsync(); var allModels = selector.GetAllModels(); - Assert.Equal(2, allModels.Count); + Assert.Equal(3, allModels.Count); + Assert.Contains(allModels, model => string.Equals(model.Provider, "cloudflare", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(allModels, model => string.Equals(model.Provider, "openrouter", StringComparison.OrdinalIgnoreCase)); } + [Fact] + public async Task GetRandomModelAsync_ExcludesCloudflareManualOnlyModels() + { + var supabase = new FakeModelSupabaseService(new List + { + JObject.FromObject(new + { + model_id = Guid.NewGuid().ToString(), + model_name = "@cf/meta/llama-3.1-8b-instruct", + display_name = "llama-3.1-8b-instruct", + provider_name = "cloudflare", + status = "active" + }), + JObject.FromObject(new + { + model_id = Guid.NewGuid().ToString(), + model_name = "llama-3.3-70b-versatile", + display_name = "Llama 3.3 70B", + provider_name = "groq", + status = "active" + }) + }); + + var cache = new MemoryCache(new MemoryCacheOptions()); + var selector = new ModelSelector(supabase, cache, NullLogger.Instance); + + var selected = await selector.GetRandomModelAsync(); + + Assert.Equal("llama-3.3-70b-versatile", selected); + } + + [Fact] + public async Task GetTwoRandomModelsAsync_ExcludesCloudflareManualOnlyModels() + { + var supabase = new FakeModelSupabaseService(new List + { + JObject.FromObject(new + { + model_id = Guid.NewGuid().ToString(), + model_name = "@cf/meta/llama-3.1-8b-instruct", + display_name = "llama-3.1-8b-instruct", + provider_name = "cloudflare", + status = "active" + }), + JObject.FromObject(new + { + model_id = Guid.NewGuid().ToString(), + model_name = "llama-3.3-70b-versatile", + display_name = "Llama 3.3 70B", + provider_name = "groq", + status = "active" + }), + JObject.FromObject(new + { + model_id = Guid.NewGuid().ToString(), + model_name = "gemini-2.0-flash", + display_name = "Gemini 2.0 Flash", + provider_name = "google", + status = "active" + }) + }); + + var cache = new MemoryCache(new MemoryCacheOptions()); + var selector = new ModelSelector(supabase, cache, NullLogger.Instance); + + var selected = await selector.GetTwoRandomModelsAsync(); + + Assert.DoesNotContain("@cf/meta/llama-3.1-8b-instruct", new[] { selected.model1, selected.model2 }); + } + private sealed class FakeModelSupabaseService : ISupabaseService { private readonly List _models;