diff --git a/.github/workflows/publish-nuget.yml b/.github/workflows/publish-nuget.yml new file mode 100644 index 0000000..4b30da9 --- /dev/null +++ b/.github/workflows/publish-nuget.yml @@ -0,0 +1,84 @@ +name: publish-nuget + +on: + push: + branches: [ main ] + workflow_dispatch: + inputs: + version: + description: 'Override version (optional, e.g. 0.1.2)' + required: false + type: string + +jobs: + pack-and-push: + runs-on: ubuntu-latest + + env: + LIB_PROJ: PromptProvider/PromptProvider.csproj + TEST_PROJ: Tests/PromptProvider.Tests/PromptProvider.Tests.csproj + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Verify projects exist + shell: bash + run: | + set -euo pipefail + [[ -f "$LIB_PROJ" ]] || { echo "Library not found: $LIB_PROJ"; exit 1; } + echo "Library: $LIB_PROJ" + - name: Restore + shell: bash + run: | + dotnet restore "$TEST_PROJ" + + - name: Build library + shell: bash + run: | + dotnet build "$LIB_PROJ" -c Release -p:ContinuousIntegrationBuild=true --no-restore + - name: Resolve version + id: ver + shell: bash + run: | + set -euo pipefail + if [ -n "${{ github.event.inputs.version }}" ]; then + PKGVER="${{ github.event.inputs.version }}" + else + PKGVER=$(grep -oPm1 '(?<=)[^<]+' "$LIB_PROJ" || true) + if [ -z "$PKGVER" ]; then + echo " not found in $LIB_PROJ" >&2 + exit 1 + fi + fi + echo "PKGVER=$PKGVER" >> $GITHUB_ENV + echo "Using version: $PKGVER" + + - name: Pack + shell: bash + run: | + dotnet pack "$LIB_PROJ" -c Release \ + -p:Version="${PKGVER}" \ + -p:IncludeSymbols=true -p:SymbolPackageFormat=snupkg \ + -o ./artifacts \ + --no-build + + - name: Push to nuget.org + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} + shell: bash + run: | + set -euo pipefail + if [ -z "${NUGET_API_KEY:-}" ]; then + echo "NUGET_API_KEY secret is not set." >&2 + exit 1 + fi + dotnet nuget push "./artifacts/*.nupkg" --skip-duplicate --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json + if ls ./artifacts/*.snupkg 1> /dev/null 2>&1; then + dotnet nuget push "./artifacts/*.snupkg" --skip-duplicate --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json + else + echo "No symbol packages (.snupkg) found to push." + fi diff --git a/DependencyInjection.cs b/DependencyInjection.cs new file mode 100644 index 0000000..20c2a91 --- /dev/null +++ b/DependencyInjection.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using PromptProvider.Interfaces; +using PromptProvider.Options; +using PromptProvider.Services; + +namespace PromptProvider; + +public static class DependencyInjection +{ + public static IServiceCollection AddPromptProvider( + this IServiceCollection services, + Action? configureLangfuse = null, + Action? configurePrompts = null) + { + if (configureLangfuse is not null) services.Configure(configureLangfuse); + if (configurePrompts is not null) services.Configure(configurePrompts); + + services.AddHttpClient((serviceProvider, client) => + { + var options = serviceProvider.GetRequiredService>().Value; + if (options.IsConfigured() && !string.IsNullOrWhiteSpace(options.BaseUrl)) + { + client.BaseAddress = new Uri(options.BaseUrl); + } + }); + services.AddScoped(); + + // Register default prompts provider (users may replace with custom implementation) + services.AddSingleton(); + + return services; + } +} diff --git a/Interfaces/IDefaultPromptsProvider.cs b/Interfaces/IDefaultPromptsProvider.cs new file mode 100644 index 0000000..e9509e7 --- /dev/null +++ b/Interfaces/IDefaultPromptsProvider.cs @@ -0,0 +1,6 @@ +namespace PromptProvider.Interfaces; + +public interface IDefaultPromptsProvider +{ + IReadOnlyDictionary GetDefaults(); +} diff --git a/Interfaces/ILangfuseService.cs b/Interfaces/ILangfuseService.cs new file mode 100644 index 0000000..8ce8898 --- /dev/null +++ b/Interfaces/ILangfuseService.cs @@ -0,0 +1,51 @@ +using PromptProvider.Models; + +namespace PromptProvider.Interfaces; + +public interface ILangfuseService +{ + /// + /// Get a prompt by name from Langfuse API. + /// + /// The name of the prompt + /// Optional version of the prompt to retrieve + /// Optional label of the prompt (defaults to "production" if no label or version is set) + /// Cancellation token + /// The Langfuse prompt model + Task GetPromptAsync( + string promptName, + int? version = null, + string? label = null, + CancellationToken cancellationToken = default); + + /// + /// Get all prompts from Langfuse API. + /// + /// Cancellation token + /// List of Langfuse prompt list items + Task> GetAllPromptsAsync(CancellationToken cancellationToken = default); + + /// + /// Create a new version for the prompt with the given name in Langfuse API. + /// + /// The prompt creation request + /// Cancellation token + /// The created prompt response + Task CreatePromptAsync( + CreateLangfusePromptRequest request, + CancellationToken cancellationToken = default); + + /// + /// Update labels for a specific prompt version in Langfuse API. + /// + /// The name of the prompt + /// The version number to update + /// The update request containing new labels + /// Cancellation token + /// The updated prompt model + Task UpdatePromptLabelsAsync( + string promptName, + int version, + UpdatePromptLabelsRequest request, + CancellationToken cancellationToken = default); +} diff --git a/Interfaces/IPromptService.cs b/Interfaces/IPromptService.cs new file mode 100644 index 0000000..a01d645 --- /dev/null +++ b/Interfaces/IPromptService.cs @@ -0,0 +1,35 @@ +using PromptProvider.Models; + +namespace PromptProvider.Interfaces; + +public interface IPromptService +{ + /// + /// Create a new prompt version in Langfuse + /// + Task CreatePromptAsync(CreatePromptRequest request, CancellationToken cancellationToken = default); + + /// + /// Get a prompt by key with optional version or label. Falls back to local defaults if Langfuse is unavailable. + /// + /// The prompt key/name + /// Optional specific version number + /// Optional label (e.g., "production", "latest") + /// Cancellation token + Task GetPromptAsync(string promptKey, int? version = null, string? label = null, CancellationToken cancellationToken = default); + + /// + /// Get all prompts from Langfuse + /// + Task> GetAllPromptsAsync(CancellationToken cancellationToken = default); + + /// + /// Get multiple prompts by keys with optional label. Falls back to local defaults if Langfuse is unavailable. + /// + Task> GetPromptsAsync(IEnumerable promptKeys, string? label = null, CancellationToken cancellationToken = default); + + /// + /// Update labels for a specific prompt version in Langfuse + /// + Task UpdatePromptLabelsAsync(string promptKey, int version, UpdatePromptLabelsRequest request, CancellationToken cancellationToken = default); +} diff --git a/LICENSE b/LICENSE index 748de55..f4a800f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 ZioNet +Copyright (c) 2025 [copyright holder] Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/Models/CreateLangfusePromptRequest.cs b/Models/CreateLangfusePromptRequest.cs new file mode 100644 index 0000000..cd8e6da --- /dev/null +++ b/Models/CreateLangfusePromptRequest.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace PromptProvider.Models; +public record CreateLangfusePromptRequest +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("prompt")] + public required string Prompt { get; set; } + + [JsonPropertyName("type")] + public required string Type { get; set; } + + [JsonPropertyName("commitMessage")] + public string? CommitMessage { get; set; } + + [JsonPropertyName("config")] + public object? Config { get; set; } + + [JsonPropertyName("labels")] + public string[]? Labels { get; set; } + + [JsonPropertyName("tags")] + public string[]? Tags { get; set; } +} diff --git a/Models/CreateLangfusePromptResponse.cs b/Models/CreateLangfusePromptResponse.cs new file mode 100644 index 0000000..bc51860 --- /dev/null +++ b/Models/CreateLangfusePromptResponse.cs @@ -0,0 +1,33 @@ +using System.Text.Json.Serialization; + +namespace PromptProvider.Models; + +public record CreateLangfusePromptResponse +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("prompt")] + public required string Prompt { get; set; } + + [JsonPropertyName("type")] + public required string Type { get; set; } + + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("config")] + public object? Config { get; set; } + + [JsonPropertyName("labels")] + public required string[] Labels { get; set; } + + [JsonPropertyName("tags")] + public required string[] Tags { get; set; } + + [JsonPropertyName("commitMessage")] + public string? CommitMessage { get; set; } + + [JsonPropertyName("resolutionGraph")] + public object? ResolutionGraph { get; set; } +} diff --git a/Models/CreatePromptRequest.cs b/Models/CreatePromptRequest.cs new file mode 100644 index 0000000..c0eb961 --- /dev/null +++ b/Models/CreatePromptRequest.cs @@ -0,0 +1,10 @@ +namespace PromptProvider.Models; + +public record CreatePromptRequest +{ + public required string PromptKey { get; set; } + public required string Content { get; set; } + public string? CommitMessage { get; set; } + public string[]? Labels { get; set; } + public string[]? Tags { get; set; } +} diff --git a/Models/GetPromptsBatchRequest.cs b/Models/GetPromptsBatchRequest.cs new file mode 100644 index 0000000..22204e2 --- /dev/null +++ b/Models/GetPromptsBatchRequest.cs @@ -0,0 +1,6 @@ +namespace PromptProvider.Models; + +public sealed record GetPromptsBatchRequest +{ + public required List Prompts { get; init; } +} diff --git a/Models/GetPromptsBatchResponse.cs b/Models/GetPromptsBatchResponse.cs new file mode 100644 index 0000000..eb58caa --- /dev/null +++ b/Models/GetPromptsBatchResponse.cs @@ -0,0 +1,7 @@ +namespace PromptProvider.Models; + +public sealed record GetPromptsBatchResponse +{ + public required List Prompts { get; init; } + public required List NotFound { get; init; } +} \ No newline at end of file diff --git a/Models/LangfusePromptListItem.cs b/Models/LangfusePromptListItem.cs new file mode 100644 index 0000000..a2dbbe3 --- /dev/null +++ b/Models/LangfusePromptListItem.cs @@ -0,0 +1,27 @@ +using System.Text.Json.Serialization; + +namespace PromptProvider.Models; + +public record LangfusePromptListItem +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("type")] + public required string Type { get; set; } + + [JsonPropertyName("tags")] + public required string[] Tags { get; set; } + + [JsonPropertyName("labels")] + public required string[] Labels { get; set; } + + [JsonPropertyName("lastUpdatedAt")] + public string? LastUpdatedAt { get; set; } + + [JsonPropertyName("versions")] + public int[]? Versions { get; set; } + + [JsonPropertyName("lastConfig")] + public object? LastConfig { get; set; } +} diff --git a/Models/LangfusePromptModel.cs b/Models/LangfusePromptModel.cs new file mode 100644 index 0000000..64f7b8b --- /dev/null +++ b/Models/LangfusePromptModel.cs @@ -0,0 +1,39 @@ +using System.Text.Json.Serialization; + +namespace PromptProvider.Models; + +public record LangfusePromptModel +{ + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("type")] + public required string Type { get; set; } + + [JsonPropertyName("prompt")] + public required string Prompt { get; set; } + + [JsonPropertyName("config")] + public LangfusePromptConfiguration? Config { get; set; } + + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("labels")] + public required string[] Labels { get; set; } + + [JsonPropertyName("tags")] + public required string[] Tags { get; set; } +} + +public record LangfusePromptConfiguration +{ + [JsonPropertyName("model")] + public string? Model { get; set; } + + [JsonPropertyName("temperature")] + public double? Temperature { get; set; } + + [JsonPropertyName("supported_languages")] + public string[]? SupportedLanguages { get; set; } +} diff --git a/Models/LangfusePromptsListResponse.cs b/Models/LangfusePromptsListResponse.cs new file mode 100644 index 0000000..b6a53a3 --- /dev/null +++ b/Models/LangfusePromptsListResponse.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace PromptProvider.Models; + +public record LangfusePromptsListResponse +{ + [JsonPropertyName("data")] + public List Data { get; set; } = new(); + + [JsonPropertyName("meta")] + public LangfuseListMeta? Meta { get; set; } + + [JsonPropertyName("pagination")] + public LangfuseListMeta? Pagination { get; set; } +} + +public record LangfuseListMeta +{ + [JsonPropertyName("page")] + public int Page { get; set; } + + [JsonPropertyName("limit")] + public int Limit { get; set; } + + [JsonPropertyName("totalItems")] + public int TotalItems { get; set; } + + [JsonPropertyName("totalPages")] + public int TotalPages { get; set; } +} diff --git a/Models/PromptConfiguration.cs b/Models/PromptConfiguration.cs new file mode 100644 index 0000000..1a5f63e --- /dev/null +++ b/Models/PromptConfiguration.cs @@ -0,0 +1,20 @@ +namespace PromptProvider.Models; + +public sealed class PromptConfiguration +{ + /// + /// The prompt key/name + /// + public required string Key { get; set; } + + /// + /// Optional specific version number to use + /// + public int? Version { get; set; } + + /// + /// Optional label to use (e.g., "production", "staging", "latest") + /// If neither version nor label is specified, defaults to "production" + /// + public string? Label { get; set; } +} diff --git a/Models/PromptModel.cs b/Models/PromptModel.cs new file mode 100644 index 0000000..237361d --- /dev/null +++ b/Models/PromptModel.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PromptProvider.Models; + +[Table("Prompts")] +public record PromptModel +{ + [Key] + public Guid Id { get; set; } + + [Required, MaxLength(120)] + public string PromptKey { get; set; } = null!; + + // Auto-generated immutable timestamp string + [Required, MaxLength(50)] + public string Version { get; init; } = null!; + + [Required] + public string Content { get; set; } = null!; +} \ No newline at end of file diff --git a/Models/PromptResponse.cs b/Models/PromptResponse.cs new file mode 100644 index 0000000..9f3d90d --- /dev/null +++ b/Models/PromptResponse.cs @@ -0,0 +1,13 @@ +namespace PromptProvider.Models; + +public record PromptResponse +{ + public required string PromptKey { get; set; } + public required string Content { get; set; } + public int? Version { get; set; } + public string[]? Labels { get; set; } + public string[]? Tags { get; set; } + public string? Type { get; set; } + public LangfusePromptConfiguration? Config { get; set; } + public string? Source { get; set; } // "Langfuse" or "Local" +} diff --git a/Models/UpdatePromptLabelsRequest.cs b/Models/UpdatePromptLabelsRequest.cs new file mode 100644 index 0000000..cfe271e --- /dev/null +++ b/Models/UpdatePromptLabelsRequest.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace PromptProvider.Models; + +public record UpdatePromptLabelsRequest +{ + [JsonPropertyName("newLabels")] + public required string[] NewLabels { get; set; } +} diff --git a/Options/LangfuseOptions.cs b/Options/LangfuseOptions.cs new file mode 100644 index 0000000..d288ccc --- /dev/null +++ b/Options/LangfuseOptions.cs @@ -0,0 +1,33 @@ +namespace PromptProvider.Options; + +public class LangfuseOptions +{ + private string? _baseUrl; + private string? _publicKey; + private string? _secretKey; + + public string? BaseUrl + { + get => _baseUrl; + set => _baseUrl = value?.Trim(); + } + + public string? PublicKey + { + get => _publicKey; + set => _publicKey = value?.Trim(); + } + + public string? SecretKey + { + get => _secretKey; + set => _secretKey = value?.Trim(); + } + + public bool IsConfigured() + { + return !string.IsNullOrWhiteSpace(BaseUrl) && + !string.IsNullOrWhiteSpace(PublicKey) && + !string.IsNullOrWhiteSpace(SecretKey); + } +} diff --git a/Options/PromptConfiguration.cs b/Options/PromptConfiguration.cs new file mode 100644 index 0000000..e69de29 diff --git a/Options/PromptsOptions.cs b/Options/PromptsOptions.cs new file mode 100644 index 0000000..db54878 --- /dev/null +++ b/Options/PromptsOptions.cs @@ -0,0 +1,6 @@ +namespace PromptProvider.Options; + +public class PromptsOptions +{ + public Dictionary Defaults { get; set; } = new(); +} diff --git a/PromptProvider.csproj b/PromptProvider.csproj new file mode 100644 index 0000000..a047dce --- /dev/null +++ b/PromptProvider.csproj @@ -0,0 +1,29 @@ + + + net9.0 + enable + enable + true + PromptProvider + 1.0.0 + Author + PromptProvider library with Langfuse integration and default prompts support. + prompts;langfuse;configuration + https://example.com/your-repo + MIT + + + + + + + + + + + + + + + + diff --git a/PromptProvider.sln b/PromptProvider.sln new file mode 100644 index 0000000..9827cce --- /dev/null +++ b/PromptProvider.sln @@ -0,0 +1,18 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PromptProvider", "PromptProvider.csproj", "{A2B1D580-4D71-4D3A-AF2D-7AE402A3929C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A2B1D580-4D71-4D3A-AF2D-7AE402A3929C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2B1D580-4D71-4D3A-AF2D-7AE402A3929C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2B1D580-4D71-4D3A-AF2D-7AE402A3929C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2B1D580-4D71-4D3A-AF2D-7AE402A3929C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc3fb47 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# PromptProvider + +PromptProvider - a library for managing prompts with Langfuse integration and support for default prompts. + +## Installation + +```powershell +dotnet add package PromptProvider +``` + +## Usage + +```csharp +services.AddPromptProvider( + configureLangfuse: options => Configuration.GetSection("Langfuse").Bind(options), + configurePrompts: options => Configuration.GetSection("Prompts").Bind(options) +); +``` + +If you want to provide default prompts from configuration, register a provider or configure `Prompts` section and use the built-in `ConfigurationDefaultPromptsProvider`. + +## Configuration example + +`appsettings.json` example for `Prompts` section: + +```json +{ + "Prompts": { + "Defaults": { + "WelcomePrompt": "Welcome to our system!", + "ErrorPrompt": "An error occurred. Please try again." + } + } +} \ No newline at end of file diff --git a/Services/ConfigurationDefaultPromptsProvider.cs b/Services/ConfigurationDefaultPromptsProvider.cs new file mode 100644 index 0000000..c381baf --- /dev/null +++ b/Services/ConfigurationDefaultPromptsProvider.cs @@ -0,0 +1,20 @@ +using Microsoft.Extensions.Options; +using PromptProvider.Interfaces; +using PromptProvider.Options; + +namespace PromptProvider.Services; + +public class ConfigurationDefaultPromptsProvider : IDefaultPromptsProvider +{ + private readonly PromptsOptions _options; + + public ConfigurationDefaultPromptsProvider(IOptions options) + { + _options = options.Value; + } + + public IReadOnlyDictionary GetDefaults() + { + return _options.Defaults; + } +} diff --git a/Services/LangfuseService.cs b/Services/LangfuseService.cs new file mode 100644 index 0000000..66000b9 --- /dev/null +++ b/Services/LangfuseService.cs @@ -0,0 +1,320 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using PromptProvider.Models; +using PromptProvider.Options; +using Microsoft.Extensions.Options; +using PromptProvider.Interfaces; +using Microsoft.Extensions.Logging; + +namespace PromptProvider.Services; + +public class LangfuseService : ILangfuseService +{ + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + private readonly LangfuseOptions _options; + private readonly bool _isConfigured; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public LangfuseService( + ILogger logger, + HttpClient httpClient, + IOptions options) + { + _logger = logger; + _httpClient = httpClient; + _options = options.Value; + _isConfigured = _options.IsConfigured(); + + if (_isConfigured) + { + ConfigureHttpClient(); + } + else + { + _logger.LogWarning("Langfuse is not configured. Service will not be available."); + } + } + + private void ConfigureHttpClient() + { + // Basic authentication with public key as username and secret key as password + var authValue = Convert.ToBase64String( + Encoding.UTF8.GetBytes($"{_options.PublicKey}:{_options.SecretKey}")); + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue); + _httpClient.BaseAddress = new Uri(_options.BaseUrl); + } + + private void ThrowIfNotConfigured() + { + if (!_isConfigured) + { + throw new InvalidOperationException("Langfuse is not configured. Please check your appsettings configuration."); + } + } + + public async Task GetPromptAsync( + string promptName, + int? version = null, + string? label = null, + CancellationToken cancellationToken = default) + { + ThrowIfNotConfigured(); + + if (string.IsNullOrWhiteSpace(promptName)) + { + throw new ArgumentException("Prompt name is required.", nameof(promptName)); + } + + // Default to "production" label if neither version nor label is specified + if (version is null && string.IsNullOrWhiteSpace(label)) + { + label = "production"; + } + + try + { + var queryParams = new List(); + if (version.HasValue) + { + queryParams.Add($"version={version.Value}"); + } + + if (!string.IsNullOrWhiteSpace(label)) + { + queryParams.Add($"label={Uri.EscapeDataString(label)}"); + } + + var queryString = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty; + var requestUri = $"/api/public/v2/prompts/{Uri.EscapeDataString(promptName)}{queryString}"; + + _logger.LogInformation("Fetching prompt '{PromptName}' from Langfuse (version: {Version}, label: {Label})", + promptName, version, label); + + var response = await _httpClient.GetAsync(requestUri, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogWarning("Prompt '{PromptName}' not found in Langfuse", promptName); + return null; + } + + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + var prompt = JsonSerializer.Deserialize(content, JsonOptions); + + _logger.LogInformation("Successfully fetched prompt '{PromptName}' version {Version}", + promptName, prompt?.Version); + + return prompt; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP error fetching prompt '{PromptName}' from Langfuse", promptName); + throw; + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to deserialize prompt '{PromptName}' from Langfuse", promptName); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error fetching prompt '{PromptName}' from Langfuse", promptName); + throw; + } + } + + public async Task> GetAllPromptsAsync(CancellationToken cancellationToken = default) + { + ThrowIfNotConfigured(); + + try + { + _logger.LogInformation("Fetching all prompts from Langfuse"); + + var response = await _httpClient.GetAsync("/api/public/v2/prompts", cancellationToken); + response.EnsureSuccessStatusCode(); + + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + // Deserialize the paginated response + var paginatedResponse = JsonSerializer.Deserialize(content, JsonOptions); + if (paginatedResponse?.Data != null) + { + _logger.LogInformation("Successfully fetched {Count} prompts from Langfuse", + paginatedResponse.Data.Count); + return paginatedResponse.Data; + } + + _logger.LogWarning("Received empty or null data from Langfuse prompts API"); + return new List(); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP error fetching all prompts from Langfuse"); + throw; + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to deserialize prompts from Langfuse"); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error fetching all prompts from Langfuse"); + throw; + } + } + + public async Task CreatePromptAsync( + CreateLangfusePromptRequest request, + CancellationToken cancellationToken = default) + { + ThrowIfNotConfigured(); + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + throw new ArgumentException("Prompt name is required.", nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Prompt)) + { + throw new ArgumentException("Prompt content is required.", nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Type)) + { + throw new ArgumentException("Prompt type is required.", nameof(request)); + } + + try + { + _logger.LogInformation("Creating new prompt version for '{PromptName}' in Langfuse", request.Name); + + var jsonContent = JsonSerializer.Serialize(request, JsonOptions); + using var httpContent = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync("/api/public/v2/prompts", httpContent, cancellationToken); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var createdPrompt = JsonSerializer.Deserialize(responseContent, JsonOptions) + ?? throw new InvalidOperationException("Failed to deserialize created prompt response"); + + _logger.LogInformation("Successfully created prompt '{PromptName}' version {Version}", + createdPrompt.Name, createdPrompt.Version); + + return createdPrompt; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP error creating prompt '{PromptName}' in Langfuse", request.Name); + throw; + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to serialize/deserialize prompt '{PromptName}'", request.Name); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error creating prompt '{PromptName}' in Langfuse", request.Name); + throw; + } + } + + public async Task UpdatePromptLabelsAsync( + string promptName, + int version, + UpdatePromptLabelsRequest request, + CancellationToken cancellationToken = default) + { + ThrowIfNotConfigured(); + + if (string.IsNullOrWhiteSpace(promptName)) + { + throw new ArgumentException("Prompt name is required.", nameof(promptName)); + } + + if (version <= 0) + { + throw new ArgumentException("Version must be greater than 0.", nameof(version)); + } + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (request.NewLabels is null || request.NewLabels.Length == 0) + { + throw new ArgumentException("At least one label is required.", nameof(request)); + } + + try + { + _logger.LogInformation("Updating labels for prompt '{PromptName}' version {Version} in Langfuse", + promptName, version); + + var requestUri = $"/api/public/v2/prompts/{Uri.EscapeDataString(promptName)}/versions/{version}"; + + var jsonContent = JsonSerializer.Serialize(request, JsonOptions); + var httpContent = new StringContent(jsonContent, Encoding.UTF8, "application/json"); + + var httpRequest = new HttpRequestMessage(HttpMethod.Patch, requestUri) + { + Content = httpContent + }; + + var response = await _httpClient.SendAsync(httpRequest, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + _logger.LogWarning("Prompt '{PromptName}' version {Version} not found in Langfuse", + promptName, version); + throw new InvalidOperationException($"Prompt '{promptName}' version {version} not found"); + } + + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + var updatedPrompt = JsonSerializer.Deserialize(responseContent, JsonOptions) + ?? throw new InvalidOperationException("Failed to deserialize updated prompt response"); + + _logger.LogInformation("Successfully updated labels for prompt '{PromptName}' version {Version}", + updatedPrompt.Name, updatedPrompt.Version); + + return updatedPrompt; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "HTTP error updating prompt '{PromptName}' version {Version} in Langfuse", + promptName, version); + throw; + } + catch (JsonException ex) + { + _logger.LogError(ex, "Failed to serialize/deserialize prompt '{PromptName}' version {Version}", + promptName, version); + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error updating prompt '{PromptName}' version {Version} in Langfuse", + promptName, version); + throw; + } + } +} diff --git a/Services/PromptService.cs b/Services/PromptService.cs new file mode 100644 index 0000000..6d26fa1 --- /dev/null +++ b/Services/PromptService.cs @@ -0,0 +1,253 @@ +using PromptProvider.Models; +using PromptProvider.Interfaces; +using Microsoft.Extensions.Logging; +using PromptProvider.Options; + +namespace PromptProvider.Services; + +public class PromptService : IPromptService +{ + private readonly ILogger _logger; + private readonly ILangfuseService _langfuseService; + private readonly IDefaultPromptsProvider _defaultPromptsProvider; + private readonly Microsoft.Extensions.Options.IOptions _langfuseOptions; + + public PromptService( + ILogger logger, + ILangfuseService langfuseService, + IDefaultPromptsProvider defaultPromptsProvider, + Microsoft.Extensions.Options.IOptions langfuseOptions) + { + _logger = logger; + _langfuseService = langfuseService; + _defaultPromptsProvider = defaultPromptsProvider; + _langfuseOptions = langfuseOptions; + } + + public async Task CreatePromptAsync(CreatePromptRequest request, CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.PromptKey)) + { + throw new ArgumentException("PromptKey is required.", nameof(request)); + } + + if (string.IsNullOrWhiteSpace(request.Content)) + { + throw new ArgumentException("Content is required.", nameof(request)); + } + + if (!_langfuseOptions.Value.IsConfigured()) + { + _logger.LogWarning("Cannot create prompt '{PromptKey}' - Langfuse is not configured", request.PromptKey); + throw new InvalidOperationException("Langfuse is not configured. Cannot create prompts."); + } + + try + { + _logger.LogInformation("Creating prompt '{PromptKey}' in Langfuse", request.PromptKey); + + var langfuseRequest = new CreateLangfusePromptRequest + { + Name = request.PromptKey, + Prompt = request.Content, + Type = "text", + CommitMessage = request.CommitMessage, + Labels = request.Labels ?? Array.Empty(), + Tags = request.Tags ?? Array.Empty() + }; + + var created = await _langfuseService.CreatePromptAsync(langfuseRequest, cancellationToken); + + return new PromptResponse + { + PromptKey = created.Name, + Content = created.Prompt, + Version = created.Version, + Labels = created.Labels, + Tags = created.Tags, + Type = created.Type, + Config = created.Config as LangfusePromptConfiguration, + Source = "Langfuse" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create prompt '{PromptKey}' in Langfuse", request.PromptKey); + throw; + } + } + + public async Task GetPromptAsync( + string promptKey, + int? version = null, + string? label = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(promptKey)) + { + throw new ArgumentException("PromptKey is required.", nameof(promptKey)); + } + + // If Langfuse is configured, try to fetch from it first + if (_langfuseOptions.Value.IsConfigured()) + { + try + { + _logger.LogInformation("Fetching prompt '{PromptKey}' (version: {Version}, label: {Label}) from Langfuse", + promptKey, version, label); + + var langfusePrompt = await _langfuseService.GetPromptAsync(promptKey, version, label, cancellationToken); + + if (langfusePrompt != null) + { + _logger.LogInformation("Successfully retrieved prompt '{PromptKey}' from Langfuse", promptKey); + return new PromptResponse + { + PromptKey = langfusePrompt.Name, + Content = langfusePrompt.Prompt, + Version = langfusePrompt.Version, + Labels = langfusePrompt.Labels, + Tags = langfusePrompt.Tags, + Type = langfusePrompt.Type, + Config = langfusePrompt.Config, + Source = "Langfuse" + }; + } + + _logger.LogWarning("Prompt '{PromptKey}' not found in Langfuse, falling back to local defaults", promptKey); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error retrieving prompt '{PromptKey}' from Langfuse, falling back to local defaults", promptKey); + } + } + else + { + _logger.LogInformation("Langfuse is not configured, using local defaults for prompt '{PromptKey}'", promptKey); + } + + // Always fallback to local defaults + return GetPromptFromDefaults(promptKey); + } + + public async Task> GetAllPromptsAsync(CancellationToken cancellationToken = default) + { + if (!_langfuseOptions.Value.IsConfigured()) + { + _logger.LogWarning("Cannot get all prompts - Langfuse is not configured"); + throw new InvalidOperationException("Langfuse is not configured. Cannot retrieve prompts list."); + } + + try + { + _logger.LogInformation("Fetching all prompts from Langfuse"); + return await _langfuseService.GetAllPromptsAsync(cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve all prompts from Langfuse"); + throw; + } + } + + public async Task> GetPromptsAsync( + IEnumerable promptKeys, + string? label = null, + CancellationToken cancellationToken = default) + { + if (promptKeys is null) + { + throw new ArgumentNullException(nameof(promptKeys)); + } + + var keys = promptKeys.Where(k => !string.IsNullOrWhiteSpace(k)).Distinct().ToList(); + if (keys.Count == 0) + { + throw new ArgumentException("At least one prompt key is required.", nameof(promptKeys)); + } + + var results = new List(); + + foreach (var key in keys) + { + var prompt = await GetPromptAsync(key, label: label, cancellationToken: cancellationToken); + if (prompt != null) + { + results.Add(prompt); + } + } + + return results; + } + + public async Task UpdatePromptLabelsAsync( + string promptKey, + int version, + UpdatePromptLabelsRequest request, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(promptKey)) + { + throw new ArgumentException("PromptKey is required.", nameof(promptKey)); + } + + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + if (!_langfuseOptions.Value.IsConfigured()) + { + _logger.LogWarning("Cannot update prompt '{PromptKey}' - Langfuse is not configured", promptKey); + throw new InvalidOperationException("Langfuse is not configured. Cannot update prompts."); + } + + try + { + _logger.LogInformation("Updating labels for prompt '{PromptKey}' version {Version} in Langfuse", + promptKey, version); + + var updated = await _langfuseService.UpdatePromptLabelsAsync(promptKey, version, request, cancellationToken); + + return new PromptResponse + { + PromptKey = updated.Name, + Content = updated.Prompt, + Version = updated.Version, + Labels = updated.Labels, + Tags = updated.Tags, + Type = updated.Type, + Config = updated.Config, + Source = "Langfuse" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to update labels for prompt '{PromptKey}' version {Version}", promptKey, version); + throw; + } + } + + private PromptResponse? GetPromptFromDefaults(string promptKey) + { + var defaults = _defaultPromptsProvider.GetDefaults(); + if (defaults == null || !defaults.TryGetValue(promptKey, out var content)) + { + _logger.LogWarning("Prompt '{PromptKey}' not found in local defaults either", promptKey); + return null; + } + + _logger.LogInformation("Returning prompt '{PromptKey}' from local defaults", promptKey); + return new PromptResponse + { + PromptKey = promptKey, + Content = content, + Source = "Local" + }; + } +}