diff --git a/dotnet/samples/Concepts/Embeddings/VoyageAI_TextEmbedding.cs b/dotnet/samples/Concepts/Embeddings/VoyageAI_TextEmbedding.cs new file mode 100644 index 000000000000..f3f6569721a4 --- /dev/null +++ b/dotnet/samples/Concepts/Embeddings/VoyageAI_TextEmbedding.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.VoyageAI; + +namespace Embeddings; + +/// +/// Example demonstrating VoyageAI text embeddings with Semantic Kernel. +/// +public class VoyageAI_TextEmbedding(ITestOutputHelper output) : BaseTest(output) +{ + [Fact] + public async Task GenerateTextEmbeddingsAsync() + { + // Get API key from environment + var apiKey = Environment.GetEnvironmentVariable("VOYAGE_AI_API_KEY") + ?? throw new InvalidOperationException("Please set the VOYAGE_AI_API_KEY environment variable"); + + // Create embedding service + var embeddingService = new VoyageAITextEmbeddingGenerationService( + modelId: "voyage-3-large", + apiKey: apiKey + ); + + // Generate embeddings + var texts = new List + { + "Semantic Kernel is an SDK for integrating AI models", + "VoyageAI provides high-quality embeddings", + "C# is a modern, object-oriented programming language" + }; + + Console.WriteLine("Generating embeddings for:"); + for (int i = 0; i < texts.Count; i++) + { + Console.WriteLine($"{i + 1}. {texts[i]}"); + } + + var embeddings = await embeddingService.GenerateEmbeddingsAsync(texts); + + Console.WriteLine($"\nGenerated {embeddings.Count} embeddings"); + Console.WriteLine($"Embedding dimension: {embeddings[0].Length}"); + Console.WriteLine($"First embedding (first 5 values): [{string.Join(", ", embeddings[0].Span[..5].ToArray())}]"); + } +} diff --git a/dotnet/samples/Concepts/Reranking/VoyageAI_Reranking.cs b/dotnet/samples/Concepts/Reranking/VoyageAI_Reranking.cs new file mode 100644 index 000000000000..9071b9a40852 --- /dev/null +++ b/dotnet/samples/Concepts/Reranking/VoyageAI_Reranking.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.SemanticKernel; +using Microsoft.SemanticKernel.Connectors.VoyageAI; + +namespace Reranking; + +/// +/// Example demonstrating VoyageAI reranking with Semantic Kernel. +/// +public class VoyageAI_Reranking(ITestOutputHelper output) : BaseTest(output) +{ + [Fact] + public async Task RerankDocumentsAsync() + { + // Get API key from environment + var apiKey = Environment.GetEnvironmentVariable("VOYAGE_AI_API_KEY") + ?? throw new InvalidOperationException("Please set the VOYAGE_AI_API_KEY environment variable"); + + // Create reranking service + var rerankingService = new VoyageAITextRerankingService( + modelId: "rerank-2.5", + apiKey: apiKey + ); + + // Define query and documents + var query = "What are the key features of Semantic Kernel?"; + + var documents = new List + { + "Semantic Kernel is an open-source SDK that lets you easily build agents that can call your existing code.", + "The capital of France is Paris, a beautiful city known for its art and culture.", + "Python is a high-level, interpreted programming language with dynamic typing.", + "Semantic Kernel provides enterprise-ready AI orchestration with model flexibility and plugin ecosystem.", + "Machine learning models require large amounts of data for training." + }; + + Console.WriteLine($"Query: {query}\n"); + Console.WriteLine("Documents to rerank:"); + for (int i = 0; i < documents.Count; i++) + { + Console.WriteLine($"{i + 1}. {documents[i]}"); + } + + // Rerank documents + var results = await rerankingService.RerankAsync(query, documents); + + Console.WriteLine("\nReranked results (sorted by relevance):"); + for (int i = 0; i < results.Count; i++) + { + var result = results[i]; + Console.WriteLine($"\n{i + 1}. Score: {result.RelevanceScore:F4}"); + Console.WriteLine($" Original Index: {result.Index}"); + Console.WriteLine($" Text: {result.Text}"); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Connectors.VoyageAI.UnitTests.csproj b/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Connectors.VoyageAI.UnitTests.csproj new file mode 100644 index 000000000000..a99f7bef1a18 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Connectors.VoyageAI.UnitTests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + Microsoft.SemanticKernel.Connectors.VoyageAI.UnitTests + Microsoft.SemanticKernel.Connectors.VoyageAI.UnitTests + false + true + enable + $(NoWarn);SKEXP0001 + + + + + + + + + + + + + + + diff --git a/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAIContextualizedEmbeddingGenerationServiceTests.cs b/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAIContextualizedEmbeddingGenerationServiceTests.cs new file mode 100644 index 000000000000..0cadc50bf042 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAIContextualizedEmbeddingGenerationServiceTests.cs @@ -0,0 +1,342 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.SemanticKernel.Connectors.VoyageAI; +using Xunit; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI.UnitTests.Services; + +/// +/// Unit tests for . +/// +public sealed class VoyageAIContextualizedEmbeddingGenerationServiceTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + + public VoyageAIContextualizedEmbeddingGenerationServiceTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub); + } + + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Arrange & Act + var service = new VoyageAIContextualizedEmbeddingGenerationService( + modelId: "voyage-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + // Assert + service.Should().NotBeNull(); + service.Attributes.Should().ContainKey("ModelId"); + service.Attributes["ModelId"].Should().Be("voyage-3"); + } + + [Fact] + public void Constructor_WithNullModelId_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new VoyageAIContextualizedEmbeddingGenerationService( + modelId: null!, + apiKey: "test-api-key" + ) + ); + } + + [Fact] + public void Constructor_WithNullApiKey_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new VoyageAIContextualizedEmbeddingGenerationService( + modelId: "voyage-3", + apiKey: null! + ) + ); + } + + [Fact] + public async Task GenerateContextualizedEmbeddingsAsync_ReturnsEmbeddings() + { + // Arrange + var expectedEmbeddings = new List + { + new[] { 0.1f, 0.2f, 0.3f }, + new[] { 0.4f, 0.5f, 0.6f } + }; + + var responseContent = JsonSerializer.Serialize(new + { + results = new[] + { + new + { + embeddings = new[] + { + new { embedding = expectedEmbeddings[0], index = 0 }, + new { embedding = expectedEmbeddings[1], index = 1 } + } + } + }, + total_tokens = 20 + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAIContextualizedEmbeddingGenerationService( + modelId: "voyage-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var inputs = new List> + { + new List { "chunk1", "chunk2" } + }; + + // Act + var result = await service.GenerateContextualizedEmbeddingsAsync(inputs).ConfigureAwait(false); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result[0].Length.Should().Be(3); + result[1].Length.Should().Be(3); + } + + [Fact] + public async Task GenerateContextualizedEmbeddingsAsync_SendsCorrectRequest() + { + // Arrange + var responseContent = JsonSerializer.Serialize(new + { + results = new[] + { + new + { + embeddings = new[] + { + new { embedding = new[] { 0.1f, 0.2f }, index = 0 } + } + } + }, + total_tokens = 10 + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAIContextualizedEmbeddingGenerationService( + modelId: "voyage-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var inputs = new List> + { + new List { "test chunk" } + }; + + // Act + await service.GenerateContextualizedEmbeddingsAsync(inputs).ConfigureAwait(false); + + // Assert + this._messageHandlerStub.RequestContent.Should().NotBeNull(); + this._messageHandlerStub.RequestContent.Should().Contain("voyage-3"); + this._messageHandlerStub.RequestContent.Should().Contain("test chunk"); + this._messageHandlerStub.RequestHeaders.Should().ContainKey("Authorization"); + } + + [Fact] + public async Task GenerateContextualizedEmbeddingsAsync_SendsCorrectModel() + { + // Arrange + var responseContent = JsonSerializer.Serialize(new + { + results = new[] + { + new + { + embeddings = new[] + { + new { embedding = new[] { 0.1f, 0.2f }, index = 0 } + } + } + }, + total_tokens = 10 + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAIContextualizedEmbeddingGenerationService( + modelId: "voyage-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var inputs = new List> + { + new List { "test chunk" } + }; + + // Act + var result = await service.GenerateContextualizedEmbeddingsAsync(inputs).ConfigureAwait(false); + + // Assert + result.Should().NotBeNull(); + this._messageHandlerStub.RequestContent.Should().NotBeNull(); + this._messageHandlerStub.RequestContent.Should().Contain("voyage-3"); + } + + [Fact] + public async Task GenerateContextualizedEmbeddingsAsync_HandlesApiError() + { + // Arrange + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("API error") + }; + + var service = new VoyageAIContextualizedEmbeddingGenerationService( + modelId: "voyage-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var inputs = new List> + { + new List { "test" } + }; + + // Act & Assert + await Assert.ThrowsAsync( + async () => await service.GenerateContextualizedEmbeddingsAsync(inputs).ConfigureAwait(false) + ).ConfigureAwait(false); + } + + [Fact] + public async Task GenerateEmbeddingsAsync_WrapsToContextualizedCall() + { + // Arrange + var expectedEmbedding = new[] { 0.1f, 0.2f, 0.3f }; + + var responseContent = JsonSerializer.Serialize(new + { + results = new[] + { + new + { + embeddings = new[] + { + new { embedding = expectedEmbedding, index = 0 } + } + } + }, + total_tokens = 10 + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAIContextualizedEmbeddingGenerationService( + modelId: "voyage-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var data = new List { "text1" }; + + // Act + var result = await service.GenerateEmbeddingsAsync(data).ConfigureAwait(false); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(1); + result[0].Length.Should().Be(3); + } + + [Fact] + public void Attributes_ContainsModelId() + { + // Arrange & Act + var service = new VoyageAIContextualizedEmbeddingGenerationService( + modelId: "voyage-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + // Assert + service.Attributes.Should().ContainKey("ModelId"); + service.Attributes["ModelId"].Should().Be("voyage-3"); + } + + [Fact] + public void ServiceShouldUseCustomEndpoint() + { + // Arrange + var customEndpoint = "https://custom.api.com/v1"; + + // Act + var service = new VoyageAIContextualizedEmbeddingGenerationService( + modelId: "voyage-3", + apiKey: "test-api-key", + endpoint: customEndpoint, + httpClient: this._httpClient + ); + + // Assert + service.Should().NotBeNull(); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + private sealed class HttpMessageHandlerStub : HttpMessageHandler + { + public HttpResponseMessage? ResponseToReturn { get; set; } + public string? RequestContent { get; private set; } + public Dictionary RequestHeaders { get; } = new(); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Content is not null) + { + this.RequestContent = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + foreach (var header in request.Headers) + { + this.RequestHeaders[header.Key] = string.Join(",", header.Value); + } + + return this.ResponseToReturn ?? new HttpResponseMessage(HttpStatusCode.OK); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAIMultimodalEmbeddingGenerationServiceTests.cs b/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAIMultimodalEmbeddingGenerationServiceTests.cs new file mode 100644 index 000000000000..e8057b511f30 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAIMultimodalEmbeddingGenerationServiceTests.cs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.SemanticKernel.Connectors.VoyageAI; +using Xunit; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI.UnitTests.Services; + +/// +/// Unit tests for . +/// +public sealed class VoyageAIMultimodalEmbeddingGenerationServiceTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + + public VoyageAIMultimodalEmbeddingGenerationServiceTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub); + } + + [Fact] + public void Constructor_WithValidParameters_CreatesInstance() + { + // Arrange & Act + var service = new VoyageAIMultimodalEmbeddingGenerationService( + modelId: "voyage-multimodal-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + // Assert + service.Should().NotBeNull(); + service.Attributes.Should().ContainKey("ModelId"); + service.Attributes["ModelId"].Should().Be("voyage-multimodal-3"); + } + + [Fact] + public void Constructor_WithNullModelId_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new VoyageAIMultimodalEmbeddingGenerationService( + modelId: null!, + apiKey: "test-api-key" + ) + ); + } + + [Fact] + public void Constructor_WithNullApiKey_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + new VoyageAIMultimodalEmbeddingGenerationService( + modelId: "voyage-multimodal-3", + apiKey: null! + ) + ); + } + + [Fact] + public async Task GenerateMultimodalEmbeddingsAsync_WithTextInputs_ReturnsEmbeddings() + { + // Arrange + var expectedEmbeddings = new List + { + new[] { 0.1f, 0.2f, 0.3f }, + new[] { 0.4f, 0.5f, 0.6f } + }; + + var responseContent = JsonSerializer.Serialize(new + { + data = new[] + { + new { embedding = expectedEmbeddings[0], index = 0, @object = "embedding" }, + new { embedding = expectedEmbeddings[1], index = 1, @object = "embedding" } + }, + usage = new { total_tokens = 20 } + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAIMultimodalEmbeddingGenerationService( + modelId: "voyage-multimodal-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var inputs = new List { "text1", "text2" }; + + // Act + var result = await service.GenerateMultimodalEmbeddingsAsync(inputs).ConfigureAwait(false); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result[0].Length.Should().Be(3); + result[1].Length.Should().Be(3); + } + + [Fact] + public async Task GenerateMultimodalEmbeddingsAsync_WithMixedInputs_ReturnsEmbeddings() + { + // Arrange + var expectedEmbeddings = new List + { + new[] { 0.1f, 0.2f, 0.3f }, + new[] { 0.4f, 0.5f, 0.6f }, + new[] { 0.7f, 0.8f, 0.9f } + }; + + var responseContent = JsonSerializer.Serialize(new + { + data = new[] + { + new { embedding = expectedEmbeddings[0], index = 0, @object = "embedding" }, + new { embedding = expectedEmbeddings[1], index = 1, @object = "embedding" }, + new { embedding = expectedEmbeddings[2], index = 2, @object = "embedding" } + }, + usage = new { total_tokens = 30 } + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAIMultimodalEmbeddingGenerationService( + modelId: "voyage-multimodal-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + // Mix of text and image (as objects) + var inputs = new List + { + "text1", + "base64encodedimage1", + "text2" + }; + + // Act + var result = await service.GenerateMultimodalEmbeddingsAsync(inputs).ConfigureAwait(false); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(3); + result[0].Length.Should().Be(3); + result[1].Length.Should().Be(3); + result[2].Length.Should().Be(3); + } + + [Fact] + public async Task GenerateMultimodalEmbeddingsAsync_SendsCorrectRequest() + { + // Arrange + var responseContent = JsonSerializer.Serialize(new + { + data = new[] + { + new { embedding = new[] { 0.1f, 0.2f }, index = 0, @object = "embedding" } + }, + usage = new { total_tokens = 5 } + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAIMultimodalEmbeddingGenerationService( + modelId: "voyage-multimodal-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var inputs = new List { "test text" }; + + // Act + await service.GenerateMultimodalEmbeddingsAsync(inputs).ConfigureAwait(false); + + // Assert + this._messageHandlerStub.RequestContent.Should().NotBeNull(); + this._messageHandlerStub.RequestContent.Should().Contain("voyage-multimodal-3"); + this._messageHandlerStub.RequestContent.Should().Contain("test text"); + this._messageHandlerStub.RequestHeaders.Should().ContainKey("Authorization"); + } + + [Fact] + public async Task GenerateMultimodalEmbeddingsAsync_SendsCorrectModel() + { + // Arrange + var responseContent = JsonSerializer.Serialize(new + { + data = new[] + { + new { embedding = new[] { 0.1f, 0.2f }, index = 0, @object = "embedding" } + }, + usage = new { total_tokens = 5 } + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAIMultimodalEmbeddingGenerationService( + modelId: "voyage-multimodal-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var inputs = new List { "test text" }; + + // Act + var result = await service.GenerateMultimodalEmbeddingsAsync(inputs).ConfigureAwait(false); + + // Assert + result.Should().NotBeNull(); + this._messageHandlerStub.RequestContent.Should().NotBeNull(); + this._messageHandlerStub.RequestContent.Should().Contain("voyage-multimodal-3"); + } + + [Fact] + public async Task GenerateMultimodalEmbeddingsAsync_HandlesApiError() + { + // Arrange + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("API error") + }; + + var service = new VoyageAIMultimodalEmbeddingGenerationService( + modelId: "voyage-multimodal-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var inputs = new List { "test" }; + + // Act & Assert + await Assert.ThrowsAsync( + async () => await service.GenerateMultimodalEmbeddingsAsync(inputs).ConfigureAwait(false) + ).ConfigureAwait(false); + } + + [Fact] + public async Task GenerateEmbeddingsAsync_WrapsToMultimodalCall() + { + // Arrange + var expectedEmbedding = new[] { 0.1f, 0.2f, 0.3f }; + + var responseContent = JsonSerializer.Serialize(new + { + data = new[] + { + new { embedding = expectedEmbedding, index = 0, @object = "embedding" } + }, + usage = new { total_tokens = 10 } + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAIMultimodalEmbeddingGenerationService( + modelId: "voyage-multimodal-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var data = new List { "text1" }; + + // Act + var result = await service.GenerateEmbeddingsAsync(data).ConfigureAwait(false); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(1); + result[0].Length.Should().Be(3); + } + + [Fact] + public void Attributes_ContainsModelId() + { + // Arrange & Act + var service = new VoyageAIMultimodalEmbeddingGenerationService( + modelId: "voyage-multimodal-3", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + // Assert + service.Attributes.Should().ContainKey("ModelId"); + service.Attributes["ModelId"].Should().Be("voyage-multimodal-3"); + } + + [Fact] + public void ServiceShouldUseCustomEndpoint() + { + // Arrange + var customEndpoint = "https://custom.api.com/v1"; + + // Act + var service = new VoyageAIMultimodalEmbeddingGenerationService( + modelId: "voyage-multimodal-3", + apiKey: "test-api-key", + endpoint: customEndpoint, + httpClient: this._httpClient + ); + + // Assert + service.Should().NotBeNull(); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + private sealed class HttpMessageHandlerStub : HttpMessageHandler + { + public HttpResponseMessage? ResponseToReturn { get; set; } + public string? RequestContent { get; private set; } + public Dictionary RequestHeaders { get; } = new(); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Content is not null) + { + this.RequestContent = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + foreach (var header in request.Headers) + { + this.RequestHeaders[header.Key] = string.Join(",", header.Value); + } + + return this.ResponseToReturn ?? new HttpResponseMessage(HttpStatusCode.OK); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAITextEmbeddingGenerationServiceTests.cs b/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAITextEmbeddingGenerationServiceTests.cs new file mode 100644 index 000000000000..600e9c0343e4 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAITextEmbeddingGenerationServiceTests.cs @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.SemanticKernel.Connectors.VoyageAI; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI.UnitTests.Services; + +/// +/// Unit tests for . +/// +public sealed class VoyageAITextEmbeddingGenerationServiceTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + + public VoyageAITextEmbeddingGenerationServiceTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub); + } + + [Fact] + public void ConstructorShouldInitializeService() + { + // Arrange & Act + var service = new VoyageAITextEmbeddingGenerationService( + modelId: "voyage-3-large", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + // Assert + service.Should().NotBeNull(); + service.Attributes.Should().ContainKey("ModelId"); + } + + [Fact] + public void ConstructorShouldThrowWhenModelIdIsNull() + { + // Act & Assert + Assert.Throws(() => + new VoyageAITextEmbeddingGenerationService( + modelId: null!, + apiKey: "test-api-key" + ) + ); + } + + [Fact] + public void ConstructorShouldThrowWhenApiKeyIsNull() + { + // Act & Assert + Assert.Throws(() => + new VoyageAITextEmbeddingGenerationService( + modelId: "voyage-3-large", + apiKey: null! + ) + ); + } + + [Fact] + public async Task GenerateEmbeddingsAsyncShouldReturnEmbeddings() + { + // Arrange + var responseContent = JsonSerializer.Serialize(new + { + data = new[] + { + new { embedding = new[] { 0.1f, 0.2f, 0.3f }, index = 0, @object = "embedding" }, + new { embedding = new[] { 0.4f, 0.5f, 0.6f }, index = 1, @object = "embedding" } + }, + usage = new { total_tokens = 10 } + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAITextEmbeddingGenerationService( + modelId: "voyage-3-large", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var data = new List { "text1", "text2" }; + + // Act + var result = await service.GenerateEmbeddingsAsync(data).ConfigureAwait(false); + + // Assert + result.Should().NotBeNull(); + result.Should().HaveCount(2); + result[0].Length.Should().Be(3); + result[1].Length.Should().Be(3); + } + + [Fact] + public async Task GenerateEmbeddingsAsyncShouldSendCorrectRequest() + { + // Arrange + var responseContent = JsonSerializer.Serialize(new + { + data = new[] + { + new { embedding = new[] { 0.1f, 0.2f }, index = 0, @object = "embedding" } + }, + usage = new { total_tokens = 5 } + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAITextEmbeddingGenerationService( + modelId: "voyage-3-large", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var data = new List { "test text" }; + + // Act + await service.GenerateEmbeddingsAsync(data).ConfigureAwait(false); + + // Assert + this._messageHandlerStub.RequestContent.Should().NotBeNull(); + this._messageHandlerStub.RequestContent.Should().Contain("voyage-3-large"); + this._messageHandlerStub.RequestContent.Should().Contain("test text"); + this._messageHandlerStub.RequestHeaders.Should().ContainKey("Authorization"); + } + + [Fact] + public async Task GenerateEmbeddingsAsyncShouldHandleApiError() + { + // Arrange + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.BadRequest) + { + Content = new StringContent("API error") + }; + + var service = new VoyageAITextEmbeddingGenerationService( + modelId: "voyage-3-large", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var data = new List { "test" }; + + // Act & Assert + await Assert.ThrowsAsync( + async () => await service.GenerateEmbeddingsAsync(data).ConfigureAwait(false) + ).ConfigureAwait(false); + } + + [Fact] + public void ServiceShouldUseCustomEndpoint() + { + // Arrange + var customEndpoint = "https://custom.api.com/v1"; + + // Act + var service = new VoyageAITextEmbeddingGenerationService( + modelId: "voyage-3-large", + apiKey: "test-api-key", + endpoint: customEndpoint, + httpClient: this._httpClient + ); + + // Assert + service.Should().NotBeNull(); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + private sealed class HttpMessageHandlerStub : HttpMessageHandler + { + public HttpResponseMessage? ResponseToReturn { get; set; } + public string? RequestContent { get; private set; } + public Dictionary RequestHeaders { get; } = new(); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Content is not null) + { + this.RequestContent = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + foreach (var header in request.Headers) + { + this.RequestHeaders[header.Key] = string.Join(",", header.Value); + } + + return this.ResponseToReturn ?? new HttpResponseMessage(HttpStatusCode.OK); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAITextRerankingServiceTests.cs b/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAITextRerankingServiceTests.cs new file mode 100644 index 000000000000..3c7cb4f7f275 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI.UnitTests/Services/VoyageAITextRerankingServiceTests.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.SemanticKernel.Connectors.VoyageAI; +using Microsoft.SemanticKernel.Reranking; +using Xunit; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI.UnitTests.Services; + +/// +/// Unit tests for . +/// +public sealed class VoyageAITextRerankingServiceTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + + public VoyageAITextRerankingServiceTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._httpClient = new HttpClient(this._messageHandlerStub); + } + + [Fact] + public void ConstructorShouldInitializeService() + { + // Arrange & Act + var service = new VoyageAITextRerankingService( + modelId: "rerank-2.5", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + // Assert + service.Should().NotBeNull(); + service.Attributes.Should().ContainKey("ModelId"); + } + + [Fact] + public async Task RerankAsyncShouldReturnRankedResults() + { + // Arrange + var responseContent = JsonSerializer.Serialize(new + { + data = new[] + { + new { index = 2, relevance_score = 0.95 }, + new { index = 0, relevance_score = 0.75 }, + new { index = 1, relevance_score = 0.50 } + }, + usage = new { total_tokens = 20 } + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAITextRerankingService( + modelId: "rerank-2.5", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var query = "test query"; + var documents = new List { "doc1", "doc2", "doc3" }; + + // Act + var results = await service.RerankAsync(query, documents).ConfigureAwait(false); + + // Assert + results.Should().NotBeNull(); + results.Should().HaveCount(3); + results[0].Index.Should().Be(2); + results[0].RelevanceScore.Should().Be(0.95); + results[1].Index.Should().Be(0); + results[1].RelevanceScore.Should().Be(0.75); + } + + [Fact] + public async Task RerankAsyncShouldSendCorrectRequest() + { + // Arrange + var responseContent = JsonSerializer.Serialize(new + { + data = new[] { new { index = 0, relevance_score = 0.5 } }, + usage = new { total_tokens = 5 } + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAITextRerankingService( + modelId: "rerank-2.5", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var query = "What is SK?"; + var documents = new List { "Semantic Kernel is an SDK" }; + + // Act + await service.RerankAsync(query, documents).ConfigureAwait(false); + + // Assert + this._messageHandlerStub.RequestContent.Should().NotBeNull(); + this._messageHandlerStub.RequestContent.Should().Contain("What is SK?"); + this._messageHandlerStub.RequestContent.Should().Contain("rerank-2.5"); + this._messageHandlerStub.RequestHeaders.Should().ContainKey("Authorization"); + } + + [Fact] + public async Task RerankAsyncShouldHandleEmptyResults() + { + // Arrange + var responseContent = JsonSerializer.Serialize(new + { + data = Array.Empty(), + usage = new { total_tokens = 0 } + }); + + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseContent) + }; + + var service = new VoyageAITextRerankingService( + modelId: "rerank-2.5", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var query = "test"; + var documents = new List { "doc" }; + + // Act + var results = await service.RerankAsync(query, documents).ConfigureAwait(false); + + // Assert + results.Should().NotBeNull(); + results.Should().BeEmpty(); + } + + [Fact] + public async Task RerankAsyncShouldHandleApiError() + { + // Arrange + this._messageHandlerStub.ResponseToReturn = new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("API error") + }; + + var service = new VoyageAITextRerankingService( + modelId: "rerank-2.5", + apiKey: "test-api-key", + httpClient: this._httpClient + ); + + var query = "test"; + var documents = new List { "doc" }; + + // Act & Assert + await Assert.ThrowsAsync( + async () => await service.RerankAsync(query, documents).ConfigureAwait(false) + ).ConfigureAwait(false); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + private sealed class HttpMessageHandlerStub : HttpMessageHandler + { + public HttpResponseMessage? ResponseToReturn { get; set; } + public string? RequestContent { get; private set; } + public Dictionary RequestHeaders { get; } = new(); + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (request.Content is not null) + { + this.RequestContent = await request.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + } + + foreach (var header in request.Headers) + { + this.RequestHeaders[header.Key] = string.Join(",", header.Value); + } + + return this.ResponseToReturn ?? new HttpResponseMessage(HttpStatusCode.OK); + } + } +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI/Connectors.VoyageAI.csproj b/dotnet/src/Connectors/Connectors.VoyageAI/Connectors.VoyageAI.csproj new file mode 100644 index 000000000000..d75e1537bd1c --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI/Connectors.VoyageAI.csproj @@ -0,0 +1,24 @@ + + + + Microsoft.SemanticKernel.Connectors.VoyageAI + Microsoft.SemanticKernel.Connectors.VoyageAI + net8.0 + 12 + enable + enable + $(NoWarn);CA2227;SKEXP0001;SKEXP0070 + + + + + + + + + + + + + + diff --git a/dotnet/src/Connectors/Connectors.VoyageAI/Core/VoyageAIClient.cs b/dotnet/src/Connectors/Connectors.VoyageAI/Core/VoyageAIClient.cs new file mode 100644 index 000000000000..18362e5da61c --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI/Core/VoyageAIClient.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI.Core; + +/// +/// HTTP client for VoyageAI API. +/// +internal sealed class VoyageAIClient +{ + private readonly HttpClient _httpClient; + private readonly string _apiKey; + private readonly string _endpoint; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) } + }; + + public VoyageAIClient( + string apiKey, + string? endpoint = null, + HttpClient? httpClient = null, + ILogger? logger = null) + { + this._apiKey = apiKey ?? throw new ArgumentNullException(nameof(apiKey)); + this._endpoint = endpoint ?? "https://api.voyageai.com/v1"; + this._httpClient = httpClient ?? new HttpClient(); + this._logger = logger ?? NullLogger.Instance; + } + + public async Task SendRequestAsync( + string path, + object requestBody, + CancellationToken cancellationToken = default) + { + var requestUri = $"{this._endpoint}/{path}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, requestUri); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", this._apiKey); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + var json = JsonSerializer.Serialize(requestBody, s_jsonOptions); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + + this._logger.LogDebug("Sending VoyageAI request to {Uri}", requestUri); + + using var response = await this._httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + this._logger.LogError("VoyageAI API request failed with status {StatusCode}: {Response}", + response.StatusCode, responseContent); + throw new HttpRequestException( + $"VoyageAI API request failed with status {response.StatusCode}: {responseContent}"); + } + + var result = JsonSerializer.Deserialize(responseContent, s_jsonOptions); + if (result is null) + { + throw new JsonException($"Failed to deserialize VoyageAI response: {responseContent}"); + } + + return result; + } +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI/Core/VoyageAIModels.cs b/dotnet/src/Connectors/Connectors.VoyageAI/Core/VoyageAIModels.cs new file mode 100644 index 000000000000..39b0ac5adf26 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI/Core/VoyageAIModels.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI.Core; + +#region Embedding Models + +internal sealed class EmbeddingRequest +{ + [JsonPropertyName("input")] + public required IList Input { get; set; } + + [JsonPropertyName("model")] + public required string Model { get; set; } + + [JsonPropertyName("input_type")] + public string? InputType { get; set; } + + [JsonPropertyName("truncation")] + public bool? Truncation { get; set; } + + [JsonPropertyName("output_dimension")] + public int? OutputDimension { get; set; } + + [JsonPropertyName("output_dtype")] + public string? OutputDtype { get; set; } +} + +internal sealed class EmbeddingResponse +{ + [JsonPropertyName("data")] + public required IList Data { get; set; } + + [JsonPropertyName("usage")] + public required EmbeddingUsage Usage { get; set; } +} + +internal sealed class EmbeddingDataItem +{ + [JsonPropertyName("object")] + public string? Object { get; set; } + + [JsonPropertyName("embedding")] + public required float[] Embedding { get; set; } + + [JsonPropertyName("index")] + public int Index { get; set; } +} + +internal sealed class EmbeddingUsage +{ + [JsonPropertyName("total_tokens")] + public int TotalTokens { get; set; } +} + +#endregion + +#region Contextualized Embedding Models + +internal sealed class ContextualizedEmbeddingRequest +{ + [JsonPropertyName("inputs")] + public required IList> Inputs { get; set; } + + [JsonPropertyName("model")] + public required string Model { get; set; } + + [JsonPropertyName("input_type")] + public string? InputType { get; set; } + + [JsonPropertyName("truncation")] + public bool? Truncation { get; set; } + + [JsonPropertyName("output_dimension")] + public int? OutputDimension { get; set; } + + [JsonPropertyName("output_dtype")] + public string? OutputDtype { get; set; } +} + +internal sealed class ContextualizedEmbeddingResponse +{ + [JsonPropertyName("results")] + public required IList Results { get; set; } + + [JsonPropertyName("total_tokens")] + public int TotalTokens { get; set; } +} + +internal sealed class ContextualizedEmbeddingResult +{ + [JsonPropertyName("embeddings")] + public required IList Embeddings { get; set; } +} + +internal sealed class EmbeddingItem +{ + [JsonPropertyName("embedding")] + public required float[] Embedding { get; set; } + + [JsonPropertyName("chunk")] + public string? Chunk { get; set; } + + [JsonPropertyName("index")] + public int Index { get; set; } +} + +#endregion + +#region Reranking Models + +internal sealed class RerankRequest +{ + [JsonPropertyName("query")] + public required string Query { get; set; } + + [JsonPropertyName("documents")] + public required IList Documents { get; set; } + + [JsonPropertyName("model")] + public required string Model { get; set; } + + [JsonPropertyName("top_k")] + public int? TopK { get; set; } + + [JsonPropertyName("truncation")] + public bool? Truncation { get; set; } +} + +internal sealed class RerankResponse +{ + [JsonPropertyName("data")] + public required IList Data { get; set; } + + [JsonPropertyName("usage")] + public required EmbeddingUsage Usage { get; set; } +} + +internal sealed class RerankDataItem +{ + [JsonPropertyName("index")] + public int Index { get; set; } + + [JsonPropertyName("relevance_score")] + public double RelevanceScore { get; set; } +} + +#endregion + +#region Multimodal Embedding Models + +internal sealed class MultimodalEmbeddingRequest +{ + [JsonPropertyName("inputs")] + public required IList Inputs { get; set; } + + [JsonPropertyName("model")] + public required string Model { get; set; } + + [JsonPropertyName("input_type")] + public string? InputType { get; set; } + + [JsonPropertyName("truncation")] + public bool? Truncation { get; set; } +} + +internal sealed class MultimodalEmbeddingResponse +{ + [JsonPropertyName("data")] + public required IList Data { get; set; } + + [JsonPropertyName("usage")] + public required EmbeddingUsage Usage { get; set; } +} + +#endregion diff --git a/dotnet/src/Connectors/Connectors.VoyageAI/Extensions/VoyageAIServiceCollectionExtensions.cs b/dotnet/src/Connectors/Connectors.VoyageAI/Extensions/VoyageAIServiceCollectionExtensions.cs new file mode 100644 index 000000000000..67ef0b5f2c7b --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI/Extensions/VoyageAIServiceCollectionExtensions.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Connectors.VoyageAI; +using Microsoft.SemanticKernel.Embeddings; +using Microsoft.SemanticKernel.Http; +using Microsoft.SemanticKernel.Reranking; + +#pragma warning disable IDE0039 // Use local function + +namespace Microsoft.SemanticKernel; + +/// +/// Provides extension methods for to configure VoyageAI connectors. +/// +[Experimental("SKEXP0001")] +public static class VoyageAIServiceCollectionExtensions +{ + #region Text Embedding + + /// + /// Adds the VoyageAI text embedding generation service to the . + /// + /// The instance to augment. + /// The VoyageAI model ID (e.g., "voyage-3-large", "voyage-code-3"). + /// The VoyageAI API key. + /// Optional API endpoint. Defaults to https://api.voyageai.com/v1. + /// A local identifier for the given AI service. + /// The same instance as . + public static IServiceCollection AddVoyageAITextEmbeddingGeneration( + this IServiceCollection services, + string modelId, + string apiKey, + string? endpoint = null, + string? serviceId = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNullOrWhiteSpace(apiKey); + + Func factory = + (serviceProvider, _) => + new(modelId, + apiKey, + endpoint, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService()); + + services.AddKeyedSingleton(serviceId, factory); + + return services; + } + + #endregion + + #region Contextualized Embedding + + /// + /// Adds the VoyageAI contextualized embedding generation service to the . + /// + /// The instance to augment. + /// The VoyageAI model ID (e.g., "voyage-3-large"). + /// The VoyageAI API key. + /// Optional API endpoint. Defaults to https://api.voyageai.com/v1. + /// A local identifier for the given AI service. + /// The same instance as . + public static IServiceCollection AddVoyageAIContextualizedEmbeddingGeneration( + this IServiceCollection services, + string modelId, + string apiKey, + string? endpoint = null, + string? serviceId = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNullOrWhiteSpace(apiKey); + + Func factory = + (serviceProvider, _) => + new(modelId, + apiKey, + endpoint, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService()); + + services.AddKeyedSingleton(serviceId, factory); + + return services; + } + + #endregion + + #region Multimodal Embedding + + /// + /// Adds the VoyageAI multimodal embedding generation service to the . + /// + /// The instance to augment. + /// The VoyageAI model ID (e.g., "voyage-multimodal-3"). + /// The VoyageAI API key. + /// Optional API endpoint. Defaults to https://api.voyageai.com/v1. + /// A local identifier for the given AI service. + /// The same instance as . + public static IServiceCollection AddVoyageAIMultimodalEmbeddingGeneration( + this IServiceCollection services, + string modelId, + string apiKey, + string? endpoint = null, + string? serviceId = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNullOrWhiteSpace(apiKey); + + Func factory = + (serviceProvider, _) => + new(modelId, + apiKey, + endpoint, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService()); + + services.AddKeyedSingleton(serviceId, factory); + + return services; + } + + #endregion + + #region Reranking + + /// + /// Adds the VoyageAI text reranking service to the . + /// + /// The instance to augment. + /// The VoyageAI model ID (e.g., "rerank-2.5", "rerank-2.5-lite"). + /// The VoyageAI API key. + /// Optional API endpoint. Defaults to https://api.voyageai.com/v1. + /// A local identifier for the given AI service. + /// The same instance as . + public static IServiceCollection AddVoyageAITextReranking( + this IServiceCollection services, + string modelId, + string apiKey, + string? endpoint = null, + string? serviceId = null) + { + Verify.NotNull(services); + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNullOrWhiteSpace(apiKey); + + Func factory = + (serviceProvider, _) => + new(modelId, + apiKey, + endpoint, + HttpClientProvider.GetHttpClient(serviceProvider), + serviceProvider.GetService()); + + services.AddKeyedSingleton(serviceId, factory); + + return services; + } + + #endregion +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAIContextualizedEmbeddingGenerationService.cs b/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAIContextualizedEmbeddingGenerationService.cs new file mode 100644 index 000000000000..a6ca2fc0d328 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAIContextualizedEmbeddingGenerationService.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Connectors.VoyageAI.Core; +using Microsoft.SemanticKernel.Embeddings; +using Microsoft.SemanticKernel.Services; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI; + +/// +/// VoyageAI contextualized embedding generation service. +/// Generates embeddings that capture both local chunk details and global document-level metadata. +/// +[Experimental("SKEXP0001")] +public sealed class VoyageAIContextualizedEmbeddingGenerationService : ITextEmbeddingGenerationService +{ + private readonly VoyageAIClient _client; + private readonly string _modelId; + private readonly Dictionary _attributes = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The VoyageAI model ID (e.g., voyage-3). + /// The VoyageAI API key. + /// Optional API endpoint (defaults to https://api.voyageai.com/v1). + /// Optional HTTP client. + /// Optional logger factory. + public VoyageAIContextualizedEmbeddingGenerationService( + string modelId, + string apiKey, + string? endpoint = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(modelId); + ArgumentNullException.ThrowIfNullOrWhiteSpace(apiKey); + + this._modelId = modelId; + this._client = new VoyageAIClient( + apiKey, + endpoint, + httpClient, + loggerFactory?.CreateLogger(typeof(VoyageAIContextualizedEmbeddingGenerationService))); + + this._attributes.Add(AIServiceExtensions.ModelIdKey, modelId); + } + + /// + public IReadOnlyDictionary Attributes => this._attributes; + + /// + /// Generates contextualized embeddings for document chunks. + /// + /// List of lists where each inner list contains document chunks. + /// The containing services, plugins, and other state. + /// The to monitor for cancellation requests. + /// A list of embeddings for all chunks across all documents. + public async Task>> GenerateContextualizedEmbeddingsAsync( + IList> inputs, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + var request = new ContextualizedEmbeddingRequest + { + Inputs = inputs, + Model = this._modelId, + InputType = null, + Truncation = true + }; + + var response = await this._client.SendRequestAsync( + "contextualizedembeddings", + request, + cancellationToken).ConfigureAwait(false); + + var embeddings = new List>(); + foreach (var result in response.Results) + { + foreach (var item in result.Embeddings) + { + embeddings.Add(new ReadOnlyMemory(item.Embedding)); + } + } + + return embeddings; + } + + /// + public async Task>> GenerateEmbeddingsAsync( + IList data, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + // Wrap data as a single input for contextualized embeddings + var inputs = new List> { data }; + return await this.GenerateContextualizedEmbeddingsAsync(inputs, kernel, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAIMultimodalEmbeddingGenerationService.cs b/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAIMultimodalEmbeddingGenerationService.cs new file mode 100644 index 000000000000..180d9dfcccde --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAIMultimodalEmbeddingGenerationService.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Connectors.VoyageAI.Core; +using Microsoft.SemanticKernel.Embeddings; +using Microsoft.SemanticKernel.Services; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI; + +/// +/// VoyageAI multimodal embedding generation service. +/// Generates embeddings for text, images, or interleaved text and images. +/// Supports the voyage-multimodal-3 model. +/// +/// +/// Constraints: +/// - Maximum 1,000 inputs per request +/// - Images: ≤16 million pixels, ≤20 MB +/// - Total tokens per input: ≤32,000 (560 pixels = 1 token) +/// - Aggregate tokens across inputs: ≤320,000 +/// +[Experimental("SKEXP0001")] +public sealed class VoyageAIMultimodalEmbeddingGenerationService : ITextEmbeddingGenerationService +{ + private readonly VoyageAIClient _client; + private readonly string _modelId; + private readonly Dictionary _attributes = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The VoyageAI model ID (e.g., voyage-multimodal-3). + /// The VoyageAI API key. + /// Optional API endpoint (defaults to https://api.voyageai.com/v1). + /// Optional HTTP client. + /// Optional logger factory. + public VoyageAIMultimodalEmbeddingGenerationService( + string modelId, + string apiKey, + string? endpoint = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + Verify.NotNullOrWhiteSpace(modelId); + Verify.NotNullOrWhiteSpace(apiKey); + + this._modelId = modelId; + this._client = new VoyageAIClient( + apiKey, + endpoint, + httpClient, + loggerFactory?.CreateLogger(typeof(VoyageAIMultimodalEmbeddingGenerationService))); + + this._attributes.Add(AIServiceExtensions.ModelIdKey, modelId); + } + + /// + public IReadOnlyDictionary Attributes => this._attributes; + + /// + /// Generates multimodal embeddings for text and/or images. + /// + /// List of inputs. Each input can be: + /// - A string (text) + /// - A base64-encoded image string + /// - A dictionary with "type" and "content" keys for mixed inputs + /// The containing services, plugins, and other state. + /// The to monitor for cancellation requests. + /// A list of multimodal embeddings. + public async Task>> GenerateMultimodalEmbeddingsAsync( + IList inputs, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + var request = new MultimodalEmbeddingRequest + { + Inputs = inputs, + Model = this._modelId, + InputType = null, + Truncation = true + }; + + var response = await this._client.SendRequestAsync( + "multimodalembeddings", + request, + cancellationToken).ConfigureAwait(false); + + var embeddings = response.Data + .OrderBy(d => d.Index) + .Select(d => new ReadOnlyMemory(d.Embedding)) + .ToList(); + + return embeddings; + } + + /// + public async Task>> GenerateEmbeddingsAsync( + IList data, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + // Convert text-only inputs to multimodal format + var inputs = data.Cast().ToList(); + return await this.GenerateMultimodalEmbeddingsAsync(inputs, kernel, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAITextEmbeddingGenerationService.cs b/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAITextEmbeddingGenerationService.cs new file mode 100644 index 000000000000..8b792ba11148 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAITextEmbeddingGenerationService.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Connectors.VoyageAI.Core; +using Microsoft.SemanticKernel.Embeddings; +using Microsoft.SemanticKernel.Services; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI; + +/// +/// VoyageAI text embedding generation service. +/// Supports models like voyage-3-large, voyage-3.5, voyage-code-3, voyage-finance-2, voyage-law-2. +/// +[Experimental("SKEXP0001")] +public sealed class VoyageAITextEmbeddingGenerationService : ITextEmbeddingGenerationService +{ + private readonly VoyageAIClient _client; + private readonly string _modelId; + private readonly Dictionary _attributes = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The VoyageAI model ID. + /// The VoyageAI API key. + /// Optional API endpoint (defaults to https://api.voyageai.com/v1). + /// Optional HTTP client. + /// Optional logger factory. + public VoyageAITextEmbeddingGenerationService( + string modelId, + string apiKey, + string? endpoint = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(modelId); + ArgumentNullException.ThrowIfNullOrWhiteSpace(apiKey); + + this._modelId = modelId; + this._client = new VoyageAIClient( + apiKey, + endpoint, + httpClient, + loggerFactory?.CreateLogger(typeof(VoyageAITextEmbeddingGenerationService))); + + this._attributes.Add(AIServiceExtensions.ModelIdKey, modelId); + } + + /// + public IReadOnlyDictionary Attributes => this._attributes; + + /// + public async Task>> GenerateEmbeddingsAsync( + IList data, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + var request = new EmbeddingRequest + { + Input = data, + Model = this._modelId, + InputType = null, + Truncation = true, + OutputDimension = null, + OutputDtype = null + }; + + var response = await this._client.SendRequestAsync( + "embeddings", + request, + cancellationToken).ConfigureAwait(false); + + var embeddings = response.Data + .OrderBy(d => d.Index) + .Select(d => new ReadOnlyMemory(d.Embedding)) + .ToList(); + + return embeddings; + } +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAITextRerankingService.cs b/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAITextRerankingService.cs new file mode 100644 index 000000000000..35de54bd49bb --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI/Services/VoyageAITextRerankingService.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Logging; +using Microsoft.SemanticKernel.Connectors.VoyageAI.Core; +using Microsoft.SemanticKernel.Reranking; +using Microsoft.SemanticKernel.Services; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI; + +/// +/// VoyageAI text reranking service. +/// Supports models like rerank-2.5, rerank-2.5-lite, rerank-2, rerank-2-lite, rerank-1. +/// +[Experimental("SKEXP0001")] +public sealed class VoyageAITextRerankingService : ITextRerankingService +{ + private readonly VoyageAIClient _client; + private readonly string _modelId; + private readonly Dictionary _attributes = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The VoyageAI reranker model ID (e.g., rerank-2.5). + /// The VoyageAI API key. + /// Optional API endpoint (defaults to https://api.voyageai.com/v1). + /// Optional HTTP client. + /// Optional logger factory. + public VoyageAITextRerankingService( + string modelId, + string apiKey, + string? endpoint = null, + HttpClient? httpClient = null, + ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNullOrWhiteSpace(modelId); + ArgumentNullException.ThrowIfNullOrWhiteSpace(apiKey); + + this._modelId = modelId; + this._client = new VoyageAIClient( + apiKey, + endpoint, + httpClient, + loggerFactory?.CreateLogger(typeof(VoyageAITextRerankingService))); + + this._attributes.Add(AIServiceExtensions.ModelIdKey, modelId); + } + + /// + public IReadOnlyDictionary Attributes => this._attributes; + + /// + public async Task> RerankAsync( + string query, + IList documents, + Kernel? kernel = null, + CancellationToken cancellationToken = default) + { + var request = new RerankRequest + { + Query = query, + Documents = documents, + Model = this._modelId, + TopK = null, + Truncation = true + }; + + var response = await this._client.SendRequestAsync( + "rerank", + request, + cancellationToken).ConfigureAwait(false); + + // Create a map from index to document for lookup + var documentMap = documents.Select((doc, index) => new { index, doc }) + .ToDictionary(x => x.index, x => x.doc); + + var results = response.Data + .Where(r => documentMap.ContainsKey(r.Index)) // Validate index exists + .OrderByDescending(r => r.RelevanceScore) + .Select(r => new RerankResult(r.Index, documentMap[r.Index], r.RelevanceScore)) + .ToList(); + + return results; + } +} diff --git a/dotnet/src/Connectors/Connectors.VoyageAI/VoyageAIPromptExecutionSettings.cs b/dotnet/src/Connectors/Connectors.VoyageAI/VoyageAIPromptExecutionSettings.cs new file mode 100644 index 000000000000..ca88ccc0f5d5 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.VoyageAI/VoyageAIPromptExecutionSettings.cs @@ -0,0 +1,349 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.VoyageAI; + +/// +/// VoyageAI embedding prompt execution settings. +/// +[Experimental("SKEXP0001")] +public sealed class VoyageAIEmbeddingPromptExecutionSettings : PromptExecutionSettings +{ + /// + /// Type of input ('query' or 'document'). + /// Use 'query' for search queries and 'document' for content being searched. + /// + [JsonPropertyName("input_type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? InputType + { + get => this._inputType; + set + { + this.ThrowIfFrozen(); + this._inputType = value; + } + } + + /// + /// Whether to truncate inputs that exceed the model's context length. + /// Default is true. + /// + [JsonPropertyName("truncation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Truncation + { + get => this._truncation; + set + { + this.ThrowIfFrozen(); + this._truncation = value; + } + } + + /// + /// Desired embedding dimension (256, 512, 1024, or 2048). + /// Allows dimension reduction from the model's default. + /// + [JsonPropertyName("output_dimension")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? OutputDimension + { + get => this._outputDimension; + set + { + this.ThrowIfFrozen(); + this._outputDimension = value; + } + } + + /// + /// Output data type ('float', 'int8', 'uint8', 'binary', or 'ubinary'). + /// Default is 'float'. + /// + [JsonPropertyName("output_dtype")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? OutputDtype + { + get => this._outputDtype; + set + { + this.ThrowIfFrozen(); + this._outputDtype = value; + } + } + + /// + public override PromptExecutionSettings Clone() + { + return new VoyageAIEmbeddingPromptExecutionSettings() + { + ModelId = this.ModelId, + ServiceId = this.ServiceId, + InputType = this.InputType, + Truncation = this.Truncation, + OutputDimension = this.OutputDimension, + OutputDtype = this.OutputDtype, + }; + } + + /// + /// Converts PromptExecutionSettings to VoyageAIEmbeddingPromptExecutionSettings. + /// + /// The source settings. + /// VoyageAI-specific execution settings. + public static VoyageAIEmbeddingPromptExecutionSettings? FromExecutionSettings(PromptExecutionSettings? executionSettings) + { + if (executionSettings is null) + { + return null; + } + + if (executionSettings is VoyageAIEmbeddingPromptExecutionSettings settings) + { + return settings; + } + + return new VoyageAIEmbeddingPromptExecutionSettings() + { + ModelId = executionSettings.ModelId, + ServiceId = executionSettings.ServiceId, + }; + } + + #region private + + private string? _inputType; + private bool? _truncation = true; + private int? _outputDimension; + private string? _outputDtype; + + #endregion +} + +/// +/// VoyageAI contextualized embedding prompt execution settings. +/// +[Experimental("SKEXP0001")] +public sealed class VoyageAIContextualizedEmbeddingPromptExecutionSettings : PromptExecutionSettings +{ + /// + /// Type of input ('query' or 'document'). + /// + [JsonPropertyName("input_type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? InputType + { + get => this._inputType; + set + { + this.ThrowIfFrozen(); + this._inputType = value; + } + } + + /// + public override PromptExecutionSettings Clone() + { + return new VoyageAIContextualizedEmbeddingPromptExecutionSettings() + { + ModelId = this.ModelId, + ServiceId = this.ServiceId, + InputType = this.InputType, + }; + } + + /// + /// Converts PromptExecutionSettings to VoyageAIContextualizedEmbeddingPromptExecutionSettings. + /// + /// The source settings. + /// VoyageAI-specific execution settings. + public static VoyageAIContextualizedEmbeddingPromptExecutionSettings? FromExecutionSettings(PromptExecutionSettings? executionSettings) + { + if (executionSettings is null) + { + return null; + } + + if (executionSettings is VoyageAIContextualizedEmbeddingPromptExecutionSettings settings) + { + return settings; + } + + return new VoyageAIContextualizedEmbeddingPromptExecutionSettings() + { + ModelId = executionSettings.ModelId, + ServiceId = executionSettings.ServiceId, + }; + } + + #region private + + private string? _inputType; + + #endregion +} + +/// +/// VoyageAI multimodal embedding prompt execution settings. +/// +[Experimental("SKEXP0001")] +public sealed class VoyageAIMultimodalEmbeddingPromptExecutionSettings : PromptExecutionSettings +{ + /// + /// Type of input ('query' or 'document'). + /// + [JsonPropertyName("input_type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? InputType + { + get => this._inputType; + set + { + this.ThrowIfFrozen(); + this._inputType = value; + } + } + + /// + /// Whether to truncate inputs that exceed the model's context length. + /// Default is true. + /// + [JsonPropertyName("truncation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Truncation + { + get => this._truncation; + set + { + this.ThrowIfFrozen(); + this._truncation = value; + } + } + + /// + public override PromptExecutionSettings Clone() + { + return new VoyageAIMultimodalEmbeddingPromptExecutionSettings() + { + ModelId = this.ModelId, + ServiceId = this.ServiceId, + InputType = this.InputType, + Truncation = this.Truncation, + }; + } + + /// + /// Converts PromptExecutionSettings to VoyageAIMultimodalEmbeddingPromptExecutionSettings. + /// + /// The source settings. + /// VoyageAI-specific execution settings. + public static VoyageAIMultimodalEmbeddingPromptExecutionSettings? FromExecutionSettings(PromptExecutionSettings? executionSettings) + { + if (executionSettings is null) + { + return null; + } + + if (executionSettings is VoyageAIMultimodalEmbeddingPromptExecutionSettings settings) + { + return settings; + } + + return new VoyageAIMultimodalEmbeddingPromptExecutionSettings() + { + ModelId = executionSettings.ModelId, + ServiceId = executionSettings.ServiceId, + }; + } + + #region private + + private string? _inputType; + private bool? _truncation = true; + + #endregion +} + +/// +/// VoyageAI rerank prompt execution settings. +/// +[Experimental("SKEXP0001")] +public sealed class VoyageAIRerankPromptExecutionSettings : PromptExecutionSettings +{ + /// + /// Number of most relevant documents to return. + /// + [JsonPropertyName("top_k")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? TopK + { + get => this._topK; + set + { + this.ThrowIfFrozen(); + this._topK = value; + } + } + + /// + /// Whether to truncate inputs that exceed the model's context length. + /// Default is true. + /// + [JsonPropertyName("truncation")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Truncation + { + get => this._truncation; + set + { + this.ThrowIfFrozen(); + this._truncation = value; + } + } + + /// + public override PromptExecutionSettings Clone() + { + return new VoyageAIRerankPromptExecutionSettings() + { + ModelId = this.ModelId, + ServiceId = this.ServiceId, + TopK = this.TopK, + Truncation = this.Truncation, + }; + } + + /// + /// Converts PromptExecutionSettings to VoyageAIRerankPromptExecutionSettings. + /// + /// The source settings. + /// VoyageAI-specific execution settings. + public static VoyageAIRerankPromptExecutionSettings? FromExecutionSettings(PromptExecutionSettings? executionSettings) + { + if (executionSettings is null) + { + return null; + } + + if (executionSettings is VoyageAIRerankPromptExecutionSettings settings) + { + return settings; + } + + return new VoyageAIRerankPromptExecutionSettings() + { + ModelId = executionSettings.ModelId, + ServiceId = executionSettings.ServiceId, + }; + } + + #region private + + private int? _topK; + private bool? _truncation = true; + + #endregion +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/Reranking/ITextRerankingService.cs b/dotnet/src/SemanticKernel.Abstractions/AI/Reranking/ITextRerankingService.cs new file mode 100644 index 000000000000..c4feec0d1b4d --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/Reranking/ITextRerankingService.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Services; + +namespace Microsoft.SemanticKernel.Reranking; + +/// +/// Interface for text reranking services that can reorder documents based on relevance to a query. +/// +[Experimental("SKEXP0001")] +public interface ITextRerankingService : IAIService +{ + /// + /// Reranks a list of documents based on their relevance to a query. + /// + /// The query to rank documents against. + /// The list of documents to rerank. + /// The containing services, plugins, and other state for use throughout the operation. + /// The to monitor for cancellation requests. + /// A list of sorted by relevance score in descending order. + Task> RerankAsync( + string query, + IList documents, + Kernel? kernel = null, + CancellationToken cancellationToken = default); +} diff --git a/dotnet/src/SemanticKernel.Abstractions/AI/Reranking/RerankResult.cs b/dotnet/src/SemanticKernel.Abstractions/AI/Reranking/RerankResult.cs new file mode 100644 index 000000000000..b39d041d2041 --- /dev/null +++ b/dotnet/src/SemanticKernel.Abstractions/AI/Reranking/RerankResult.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.SemanticKernel.Reranking; + +/// +/// Represents a single reranking result containing a document and its relevance score. +/// +[Experimental("SKEXP0001")] +public sealed class RerankResult +{ + /// + /// Gets the index of the document in the original input list. + /// + public int Index { get; } + + /// + /// Gets the document text. + /// + public string Text { get; } + + /// + /// Gets the relevance score assigned by the reranker. + /// Higher scores indicate greater relevance to the query. + /// + public double RelevanceScore { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The index of the document in the original input list. + /// The document text. + /// The relevance score. + public RerankResult(int index, string text, double relevanceScore) + { + this.Index = index; + this.Text = text ?? throw new ArgumentNullException(nameof(text)); + this.RelevanceScore = relevanceScore; + } +} diff --git a/python/examples/voyage_ai_multimodal_example.py b/python/examples/voyage_ai_multimodal_example.py new file mode 100644 index 000000000000..def61e5ebec2 --- /dev/null +++ b/python/examples/voyage_ai_multimodal_example.py @@ -0,0 +1,62 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example demonstrating VoyageAI multimodal embeddings with Semantic Kernel.""" + +import asyncio +import os + +from semantic_kernel.connectors.ai.voyage_ai import VoyageAIMultimodalEmbedding + + +async def main(): + """Run the VoyageAI multimodal embedding example.""" + # Get API key from environment + api_key = os.getenv("VOYAGE_AI_API_KEY") + if not api_key: + raise ValueError("Please set the VOYAGE_AI_API_KEY environment variable") + + # Create multimodal embedding service + embedding_service = VoyageAIMultimodalEmbedding( + ai_model_id="voyage-multimodal-3", + api_key=api_key, + ) + + # Example 1: Text-only embeddings + print("Example 1: Text-only embeddings") + texts = [ + "A photo of a cat sitting on a windowsill", + "A diagram showing the architecture of a neural network", + ] + + embeddings = await embedding_service.generate_embeddings(texts) + print(f"Generated {len(embeddings)} text embeddings") + print(f"Embedding dimension: {len(embeddings[0])}\n") + + # Example 2: Mixed text and images (requires PIL) + print("Example 2: Mixed text and images") + print("To use images, uncomment the following code and provide image paths:\n") + + example_code = """ + from PIL import Image + + # Load images + image1 = Image.open("path/to/image1.jpg") + image2 = Image.open("path/to/image2.png") + + # Create mixed inputs + inputs = [ + "This is a text description", + image1, + ["Text before image", image2, "Text after image"], + ] + + # Generate multimodal embeddings + embeddings = await embedding_service.generate_multimodal_embeddings(inputs) + print(f"Generated {len(embeddings)} multimodal embeddings") + """ + + print(example_code) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/examples/voyage_ai_reranker_example.py b/python/examples/voyage_ai_reranker_example.py new file mode 100644 index 000000000000..69540aa06192 --- /dev/null +++ b/python/examples/voyage_ai_reranker_example.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example demonstrating VoyageAI reranking with Semantic Kernel.""" + +import asyncio +import os + +from semantic_kernel.connectors.ai.voyage_ai import VoyageAIReranker + + +async def main(): + """Run the VoyageAI reranker example.""" + # Get API key from environment + api_key = os.getenv("VOYAGE_AI_API_KEY") + if not api_key: + raise ValueError("Please set the VOYAGE_AI_API_KEY environment variable") + + # Create reranker service + reranker_service = VoyageAIReranker( + ai_model_id="rerank-2.5", + api_key=api_key, + ) + + # Define query and documents + query = "What are the key features of Semantic Kernel?" + + documents = [ + "Semantic Kernel is an open-source SDK that lets you easily build agents that can call your existing code.", + "The capital of France is Paris, a beautiful city known for its art and culture.", + "Python is a high-level, interpreted programming language with dynamic typing.", + "Semantic Kernel provides enterprise-ready AI orchestration with model flexibility and plugin ecosystem.", + "Machine learning models require large amounts of data for training.", + ] + + print(f"Query: {query}\n") + print("Documents to rerank:") + for i, doc in enumerate(documents, 1): + print(f"{i}. {doc}") + + # Rerank documents + results = await reranker_service.rerank(query, documents) + + print("\nReranked results (sorted by relevance):") + for i, result in enumerate(results, 1): + print(f"\n{i}. Score: {result.relevance_score:.4f}") + print(f" Original Index: {result.index}") + print(f" Text: {result.text}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/examples/voyage_ai_text_embedding_example.py b/python/examples/voyage_ai_text_embedding_example.py new file mode 100644 index 000000000000..857f7a99260b --- /dev/null +++ b/python/examples/voyage_ai_text_embedding_example.py @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Example demonstrating VoyageAI text embeddings with Semantic Kernel.""" + +import asyncio +import os + +from semantic_kernel.connectors.ai.voyage_ai import VoyageAITextEmbedding + + +async def main(): + """Run the VoyageAI text embedding example.""" + # Get API key from environment + api_key = os.getenv("VOYAGE_AI_API_KEY") + if not api_key: + raise ValueError("Please set the VOYAGE_AI_API_KEY environment variable") + + # Create embedding service + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key=api_key, + ) + + # Generate embeddings + texts = [ + "Semantic Kernel is an SDK for integrating AI models", + "VoyageAI provides high-quality embeddings", + "Python is a popular programming language", + ] + + print("Generating embeddings for:") + for i, text in enumerate(texts, 1): + print(f"{i}. {text}") + + embeddings = await embedding_service.generate_embeddings(texts) + + print(f"\nGenerated {len(embeddings)} embeddings") + print(f"Embedding dimension: {len(embeddings[0])}") + print(f"First embedding (first 5 values): {embeddings[0][:5]}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/semantic_kernel/connectors/ai/reranker_base.py b/python/semantic_kernel/connectors/ai/reranker_base.py new file mode 100644 index 000000000000..878e850d920f --- /dev/null +++ b/python/semantic_kernel/connectors/ai/reranker_base.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft. All rights reserved. + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +from semantic_kernel.services.ai_service_client_base import AIServiceClientBase +from semantic_kernel.utils.feature_stage_decorator import experimental + +if TYPE_CHECKING: + from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings + + +class RerankResult: + """Represents a single reranking result.""" + + def __init__(self, index: int, text: str, relevance_score: float): + """Initialize a rerank result. + + Args: + index: The index of the document in the original list. + text: The document text. + relevance_score: The relevance score assigned by the reranker. + """ + self.index = index + self.text = text + self.relevance_score = relevance_score + + def __repr__(self) -> str: + """Return a string representation of the rerank result.""" + return f"RerankResult(index={self.index}, relevance_score={self.relevance_score}, text={self.text[:50]}...)" + + +@experimental +class RerankingServiceBase(AIServiceClientBase, ABC): + """Base class for reranking services.""" + + @abstractmethod + async def rerank( + self, + query: str, + documents: list[str], + settings: "PromptExecutionSettings | None" = None, + **kwargs: Any, + ) -> list[RerankResult]: + """Rerank documents based on their relevance to a query. + + Args: + query (str): The query to rank documents against. + documents (List[str]): The list of documents to rerank. + settings (PromptExecutionSettings): The settings to use for the request, optional. + kwargs (Any): Additional arguments to pass to the request. + + Returns: + List[RerankResult]: List of reranked documents with relevance scores, + sorted by relevance (highest first). + """ + pass diff --git a/python/semantic_kernel/connectors/ai/voyage_ai/__init__.py b/python/semantic_kernel/connectors/ai/voyage_ai/__init__.py new file mode 100644 index 000000000000..4bcd0a51804c --- /dev/null +++ b/python/semantic_kernel/connectors/ai/voyage_ai/__init__.py @@ -0,0 +1,29 @@ +# Copyright (c) Microsoft. All rights reserved. + +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_contextualized_embedding import ( + VoyageAIContextualizedEmbedding, +) +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_multimodal_embedding import ( + VoyageAIMultimodalEmbedding, +) +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_reranker import VoyageAIReranker +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_text_embedding import VoyageAITextEmbedding +from semantic_kernel.connectors.ai.voyage_ai.voyage_ai_prompt_execution_settings import ( + VoyageAIContextualizedEmbeddingPromptExecutionSettings, + VoyageAIEmbeddingPromptExecutionSettings, + VoyageAIMultimodalEmbeddingPromptExecutionSettings, + VoyageAIRerankPromptExecutionSettings, +) +from semantic_kernel.connectors.ai.voyage_ai.voyage_ai_settings import VoyageAISettings + +__all__ = [ + "VoyageAIContextualizedEmbedding", + "VoyageAIContextualizedEmbeddingPromptExecutionSettings", + "VoyageAIEmbeddingPromptExecutionSettings", + "VoyageAIMultimodalEmbedding", + "VoyageAIMultimodalEmbeddingPromptExecutionSettings", + "VoyageAIRerankPromptExecutionSettings", + "VoyageAIReranker", + "VoyageAISettings", + "VoyageAITextEmbedding", +] diff --git a/python/semantic_kernel/connectors/ai/voyage_ai/services/__init__.py b/python/semantic_kernel/connectors/ai/voyage_ai/services/__init__.py new file mode 100644 index 000000000000..e55123354b8d --- /dev/null +++ b/python/semantic_kernel/connectors/ai/voyage_ai/services/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft. All rights reserved. + +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_contextualized_embedding import ( + VoyageAIContextualizedEmbedding, +) +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_multimodal_embedding import ( + VoyageAIMultimodalEmbedding, +) +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_reranker import VoyageAIReranker +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_text_embedding import VoyageAITextEmbedding + +__all__ = [ + "VoyageAIContextualizedEmbedding", + "VoyageAIMultimodalEmbedding", + "VoyageAIReranker", + "VoyageAITextEmbedding", +] diff --git a/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_base.py b/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_base.py new file mode 100644 index 000000000000..9ba456f8f7a6 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_base.py @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import Any + +from semantic_kernel.connectors.ai.voyage_ai.voyage_ai_settings import VoyageAISettings +from semantic_kernel.exceptions.service_exceptions import ServiceInitializationError +from semantic_kernel.services.ai_service_client_base import AIServiceClientBase + + +class VoyageAIBase(AIServiceClientBase): + """Base class for VoyageAI services.""" + + def __init__( + self, + ai_model_id: str, + service_id: str | None = None, + api_key: str | None = None, + client: Any | None = None, + aclient: Any | None = None, + env_file_path: str | None = None, + endpoint: str | None = None, + max_retries: int | None = None, + ): + """Initialize VoyageAI base service. + + Args: + ai_model_id: The VoyageAI model ID. + service_id: The service ID (optional, defaults to ai_model_id). + api_key: The VoyageAI API key (optional, will use env var if not provided). + client: A pre-configured VoyageAI client (optional). + aclient: A pre-configured VoyageAI async client (optional). + env_file_path: Path to .env file (optional). + endpoint: VoyageAI API endpoint (optional). + max_retries: Maximum number of retries for API calls (optional, defaults to 3). + """ + # Load settings from environment if not provided + try: + voyage_settings = VoyageAISettings.create( + api_key=api_key, + env_file_path=env_file_path, + ) + except Exception as e: + raise ServiceInitializationError(f"Failed to load VoyageAI settings: {e}") from e + + super().__init__( + ai_model_id=ai_model_id, + service_id=service_id or ai_model_id, + ) + + # Store settings values as private attributes to avoid Pydantic validation + self._voyage_settings = voyage_settings + self._endpoint = endpoint or voyage_settings.endpoint + self._max_retries = max_retries if max_retries is not None else voyage_settings.max_retries + self._aclient = aclient or self._create_async_client(voyage_settings) + + @property + def aclient(self): + """Get the VoyageAI async client.""" + return self._aclient + + @property + def endpoint(self): + """Get the API endpoint.""" + return self._endpoint + + def _create_async_client(self, settings: VoyageAISettings): + """Create VoyageAI async client. + + Args: + settings: VoyageAI settings. + + Returns: + VoyageAI async client instance. + """ + try: + import voyageai + except ImportError as e: + raise ServiceInitializationError( + "The voyageai package is required to use VoyageAI services. " + "Please install it with: pip install voyageai" + ) from e + + return voyageai.AsyncClient( + api_key=settings.api_key.get_secret_value(), + max_retries=self._max_retries, + ) + + def service_url(self) -> str | None: + """Get the service URL.""" + return self.endpoint diff --git a/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_contextualized_embedding.py b/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_contextualized_embedding.py new file mode 100644 index 000000000000..7a75e798e163 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_contextualized_embedding.py @@ -0,0 +1,142 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from typing import TYPE_CHECKING, Any + +from numpy import array, ndarray + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +from semantic_kernel.connectors.ai.embedding_generator_base import EmbeddingGeneratorBase +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_base import VoyageAIBase +from semantic_kernel.connectors.ai.voyage_ai.voyage_ai_prompt_execution_settings import ( + VoyageAIContextualizedEmbeddingPromptExecutionSettings, +) +from semantic_kernel.exceptions.service_exceptions import ( + ServiceInitializationError, + ServiceResponseException, +) + +if TYPE_CHECKING: + from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings + + +class VoyageAIContextualizedEmbedding(VoyageAIBase, EmbeddingGeneratorBase): + """VoyageAI Contextualized Embedding Service. + + Generates embeddings that capture both local chunk details and + global document-level metadata using the voyage-context-3 model. + + Note: Contextualized embeddings do not support truncation. Inputs that + exceed the model's context length will be rejected. + """ + + def __init__( + self, + ai_model_id: str | None = None, + service_id: str | None = None, + api_key: str | None = None, + client: Any | None = None, + env_file_path: str | None = None, + endpoint: str | None = None, + ): + """Initialize VoyageAI contextualized embedding service. + + Args: + ai_model_id: The VoyageAI model ID (required). + service_id: The service ID (optional). + api_key: The VoyageAI API key (optional). + client: A pre-configured VoyageAI client (optional). + env_file_path: Path to .env file (optional). + endpoint: VoyageAI API endpoint (optional). + """ + # Use contextualized model from settings if not provided + if not ai_model_id: + from semantic_kernel.connectors.ai.voyage_ai.voyage_ai_settings import VoyageAISettings + + settings = VoyageAISettings.create(env_file_path=env_file_path) + ai_model_id = settings.contextualized_embedding_model_id + + if not ai_model_id: + raise ServiceInitializationError( + "No model ID provided. Set ai_model_id parameter or " + "VOYAGE_AI_CONTEXTUALIZED_EMBEDDING_MODEL_ID environment variable." + ) + + super().__init__( + ai_model_id=ai_model_id, + service_id=service_id, + api_key=api_key, + client=client, + env_file_path=env_file_path, + endpoint=endpoint, + ) + + async def generate_contextualized_embeddings( + self, + inputs: list[list[str]], + settings: "PromptExecutionSettings | None" = None, + **kwargs: Any, + ) -> ndarray: + """Generate contextualized embeddings for the given inputs. + + Args: + inputs: List of lists where each inner list contains document chunks. + Example: [["chunk1", "chunk2"], ["doc2_chunk1"]] + settings: Prompt execution settings (optional). + kwargs: Additional arguments to pass to the request. + + Returns: + ndarray: Array of contextualized embeddings. + """ + if not settings: + settings = VoyageAIContextualizedEmbeddingPromptExecutionSettings() + else: + settings = self.get_prompt_execution_settings_from_settings(settings) + + try: + # Call VoyageAI contextualized embeddings API + response = await self.aclient.contextualized_embed( + inputs=inputs, + model=self.ai_model_id, + **settings.prepare_settings_dict(), + ) + + # Extract embeddings from all results + # The response structure: response.results is a list where each result + # contains a list of embeddings (not objects with .embedding attribute) + embeddings = [] + for result in response.results: + # Each result.embeddings is already a list of embedding arrays + embeddings.extend(result.embeddings) + return array(embeddings) + + except Exception as e: + raise ServiceResponseException(f"VoyageAI contextualized embedding request failed: {e}") from e + + @override + async def generate_embeddings( + self, + texts: list[str], + settings: "PromptExecutionSettings | None" = None, + **kwargs: Any, + ) -> ndarray: + """Generate embeddings (wraps texts as single input for contextualized embeddings). + + Args: + texts: List of texts to generate embeddings for. + settings: Prompt execution settings (optional). + kwargs: Additional arguments. + + Returns: + ndarray: Array of embeddings. + """ + return await self.generate_contextualized_embeddings([texts], settings, **kwargs) + + @override + def get_prompt_execution_settings_class(self) -> type["PromptExecutionSettings"]: + """Get the prompt execution settings class.""" + return VoyageAIContextualizedEmbeddingPromptExecutionSettings diff --git a/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_multimodal_embedding.py b/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_multimodal_embedding.py new file mode 100644 index 000000000000..2384412ba749 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_multimodal_embedding.py @@ -0,0 +1,165 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from typing import TYPE_CHECKING, Any, Union + +from numpy import array, ndarray + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +from semantic_kernel.connectors.ai.embedding_generator_base import EmbeddingGeneratorBase +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_base import VoyageAIBase +from semantic_kernel.connectors.ai.voyage_ai.voyage_ai_prompt_execution_settings import ( + VoyageAIMultimodalEmbeddingPromptExecutionSettings, +) +from semantic_kernel.exceptions.service_exceptions import ( + ServiceInitializationError, + ServiceResponseException, +) + +if TYPE_CHECKING: + from PIL.Image import Image + + from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings + + +class VoyageAIMultimodalEmbedding(VoyageAIBase, EmbeddingGeneratorBase): + """VoyageAI Multimodal Embedding Service. + + Generates embeddings for text, images, or interleaved text and images. + Supports the voyage-multimodal-3 model. + + Constraints: + - Maximum 1,000 inputs per request + - Images: ≤16 million pixels, ≤20 MB + - Total tokens per input: ≤32,000 (560 pixels = 1 token) + - Aggregate tokens across inputs: ≤320,000 + """ + + def __init__( + self, + ai_model_id: str | None = None, + service_id: str | None = None, + api_key: str | None = None, + client: Any | None = None, + env_file_path: str | None = None, + endpoint: str | None = None, + ): + """Initialize VoyageAI multimodal embedding service. + + Args: + ai_model_id: The VoyageAI model ID (required). + service_id: The service ID (optional). + api_key: The VoyageAI API key (optional). + client: A pre-configured VoyageAI client (optional). + env_file_path: Path to .env file (optional). + endpoint: VoyageAI API endpoint (optional). + """ + # Use multimodal model from settings if not provided + if not ai_model_id: + from semantic_kernel.connectors.ai.voyage_ai.voyage_ai_settings import VoyageAISettings + + settings = VoyageAISettings.create(env_file_path=env_file_path) + ai_model_id = settings.multimodal_embedding_model_id + + if not ai_model_id: + raise ServiceInitializationError( + "No model ID provided. Set ai_model_id parameter or " + "VOYAGE_AI_MULTIMODAL_EMBEDDING_MODEL_ID environment variable." + ) + + super().__init__( + ai_model_id=ai_model_id, + service_id=service_id, + api_key=api_key, + client=client, + env_file_path=env_file_path, + endpoint=endpoint, + ) + + async def generate_multimodal_embeddings( + self, + inputs: list[Union[str, "Image", list[Union[str, "Image"]]]], + settings: "PromptExecutionSettings | None" = None, + **kwargs: Any, + ) -> ndarray: + """Generate multimodal embeddings for text and/or images. + + Args: + inputs: List of inputs. Each input can be: + - A string (text) + - A PIL Image object + - A list containing text strings and/or PIL Images (interleaved) + settings: Prompt execution settings (optional). + kwargs: Additional arguments to pass to the request. + + Returns: + ndarray: Array of multimodal embeddings. + + Example: + ```python + from PIL import Image + + # Text only + embeddings = await service.generate_multimodal_embeddings(["Text description of image"]) + + # Image only + img = Image.open("photo.jpg") + embeddings = await service.generate_multimodal_embeddings([img]) + + # Interleaved text and images + embeddings = await service.generate_multimodal_embeddings([ + ["Chapter 1: Introduction", img1, "This shows...", img2] + ]) + ``` + """ + if not settings: + settings = VoyageAIMultimodalEmbeddingPromptExecutionSettings() + else: + settings = self.get_prompt_execution_settings_from_settings(settings) + + try: + # Call VoyageAI multimodal embeddings API + response = await self.aclient.multimodal_embed( + inputs=inputs, + model=self.ai_model_id, + **settings.prepare_settings_dict(), + ) + + # Extract embeddings + embeddings = response.embeddings + return array(embeddings) + + except Exception as e: + raise ServiceResponseException(f"VoyageAI multimodal embedding request failed: {e}") from e + + @override + async def generate_embeddings( + self, + texts: list[str], + settings: "PromptExecutionSettings | None" = None, + **kwargs: Any, + ) -> ndarray: + """Generate embeddings for text inputs. + + This method converts text-only inputs to multimodal format. + + Args: + texts: List of text strings to generate embeddings for. + settings: Prompt execution settings (optional). + kwargs: Additional arguments to pass to the request. + + Returns: + ndarray: Array of embeddings. + """ + # Convert each text to a single-item list (required by VoyageAI multimodal API) + multimodal_inputs = [[text] for text in texts] + return await self.generate_multimodal_embeddings(multimodal_inputs, settings, **kwargs) + + @override + def get_prompt_execution_settings_class(self) -> type["PromptExecutionSettings"]: + """Get the prompt execution settings class.""" + return VoyageAIMultimodalEmbeddingPromptExecutionSettings diff --git a/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_reranker.py b/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_reranker.py new file mode 100644 index 000000000000..eed38b52428c --- /dev/null +++ b/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_reranker.py @@ -0,0 +1,126 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from typing import TYPE_CHECKING, Any + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +from semantic_kernel.connectors.ai.reranker_base import RerankingServiceBase, RerankResult +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_base import VoyageAIBase +from semantic_kernel.connectors.ai.voyage_ai.voyage_ai_prompt_execution_settings import ( + VoyageAIRerankPromptExecutionSettings, +) +from semantic_kernel.exceptions.service_exceptions import ( + ServiceInitializationError, + ServiceResponseException, +) + +if TYPE_CHECKING: + from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings + + +class VoyageAIReranker(VoyageAIBase, RerankingServiceBase): + """VoyageAI Reranker Service. + + Supports models like: + - rerank-2.5 + - rerank-2.5-lite + - rerank-2 + - rerank-2-lite + - rerank-1 + - rerank-lite-1 + """ + + def __init__( + self, + ai_model_id: str | None = None, + service_id: str | None = None, + api_key: str | None = None, + client: Any | None = None, + env_file_path: str | None = None, + endpoint: str | None = None, + ): + """Initialize VoyageAI reranker service. + + Args: + ai_model_id: The VoyageAI reranker model ID (required). + service_id: The service ID (optional). + api_key: The VoyageAI API key (optional). + client: A pre-configured VoyageAI client (optional). + env_file_path: Path to .env file (optional). + endpoint: VoyageAI API endpoint (optional). + """ + # Use reranker model from settings if not provided + if not ai_model_id: + from semantic_kernel.connectors.ai.voyage_ai.voyage_ai_settings import VoyageAISettings + + settings = VoyageAISettings.create(env_file_path=env_file_path) + ai_model_id = settings.reranker_model_id + + if not ai_model_id: + raise ServiceInitializationError( + "No model ID provided. Set ai_model_id parameter or VOYAGE_AI_RERANKER_MODEL_ID environment variable." + ) + + super().__init__( + ai_model_id=ai_model_id, + service_id=service_id, + api_key=api_key, + client=client, + env_file_path=env_file_path, + endpoint=endpoint, + ) + + @override + async def rerank( + self, + query: str, + documents: list[str], + settings: "PromptExecutionSettings | None" = None, + **kwargs: Any, + ) -> list[RerankResult]: + """Rerank documents based on relevance to a query. + + Args: + query: The query to rank documents against. + documents: List of documents to rerank (max 1,000 documents). + settings: Prompt execution settings (optional). + kwargs: Additional arguments to pass to the request. + + Returns: + List[RerankResult]: Reranked documents sorted by relevance score (descending). + """ + if not settings: + settings = VoyageAIRerankPromptExecutionSettings() + else: + settings = self.get_prompt_execution_settings_from_settings(settings) + + try: + # Call VoyageAI rerank API + response = await self.aclient.rerank( + query=query, + documents=documents, + model=self.ai_model_id, + **settings.prepare_settings_dict(), + ) + + # Convert to RerankResult objects + return [ + RerankResult( + index=result.index, + text=result.document, + relevance_score=result.relevance_score, + ) + for result in response.results + ] + + except Exception as e: + raise ServiceResponseException(f"VoyageAI rerank request failed: {e}") from e + + @override + def get_prompt_execution_settings_class(self) -> type["PromptExecutionSettings"]: + """Get the prompt execution settings class.""" + return VoyageAIRerankPromptExecutionSettings diff --git a/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_text_embedding.py b/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_text_embedding.py new file mode 100644 index 000000000000..d907be1ac377 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/voyage_ai/services/voyage_ai_text_embedding.py @@ -0,0 +1,76 @@ +# Copyright (c) Microsoft. All rights reserved. + +import sys +from typing import TYPE_CHECKING, Any + +from numpy import array, ndarray + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +from semantic_kernel.connectors.ai.embedding_generator_base import EmbeddingGeneratorBase +from semantic_kernel.connectors.ai.voyage_ai.services.voyage_ai_base import VoyageAIBase +from semantic_kernel.connectors.ai.voyage_ai.voyage_ai_prompt_execution_settings import ( + VoyageAIEmbeddingPromptExecutionSettings, +) +from semantic_kernel.exceptions.service_exceptions import ServiceResponseException + +if TYPE_CHECKING: + from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings + + +class VoyageAITextEmbedding(VoyageAIBase, EmbeddingGeneratorBase): + """VoyageAI Text Embedding Service. + + Supports models like: + - voyage-3-large + - voyage-3.5 + - voyage-3.5-lite + - voyage-code-3 + - voyage-finance-2 + - voyage-law-2 + """ + + @override + async def generate_embeddings( + self, + texts: list[str], + settings: "PromptExecutionSettings | None" = None, + **kwargs: Any, + ) -> ndarray: + """Generate embeddings for the given texts. + + Args: + texts: List of texts to generate embeddings for (max 1,000 items). + settings: Prompt execution settings (optional). + kwargs: Additional arguments to pass to the request. + + Returns: + ndarray: Array of embeddings. + """ + if not settings: + settings = VoyageAIEmbeddingPromptExecutionSettings() + else: + settings = self.get_prompt_execution_settings_from_settings(settings) + + try: + # Call VoyageAI embeddings API + response = await self.aclient.embed( + texts=texts, + model=self.ai_model_id, + **settings.prepare_settings_dict(), + ) + + # Extract embeddings + embeddings = response.embeddings + return array(embeddings) + + except Exception as e: + raise ServiceResponseException(f"VoyageAI text embedding request failed: {e}") from e + + @override + def get_prompt_execution_settings_class(self) -> type["PromptExecutionSettings"]: + """Get the prompt execution settings class.""" + return VoyageAIEmbeddingPromptExecutionSettings diff --git a/python/semantic_kernel/connectors/ai/voyage_ai/voyage_ai_prompt_execution_settings.py b/python/semantic_kernel/connectors/ai/voyage_ai/voyage_ai_prompt_execution_settings.py new file mode 100644 index 000000000000..a30a218d4f40 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/voyage_ai/voyage_ai_prompt_execution_settings.py @@ -0,0 +1,105 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import Literal + +from pydantic import Field + +from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings + + +class VoyageAIEmbeddingPromptExecutionSettings(PromptExecutionSettings): + """VoyageAI embedding prompt execution settings. + + Args: + ai_model_id: The model ID to use for embeddings. + input_type: Type of input ('query', 'document', or None). + truncation: Whether to truncate inputs that exceed the model's context length. + output_dimension: Desired embedding dimension (256, 512, 1024, or 2048). + output_dtype: Output data type ('float', 'int8', 'uint8', 'binary', or 'ubinary'). + """ + + ai_model_id: str | None = None + input_type: Literal["query", "document"] | None = None + truncation: bool = True + output_dimension: int | None = None + output_dtype: Literal["float", "int8", "uint8", "binary", "ubinary"] | None = Field( + default=None, alias="encoding_format" + ) + + def prepare_settings_dict(self) -> dict: + """Prepare settings for API call.""" + settings = {} + if self.input_type: + settings["input_type"] = self.input_type + if self.truncation is not None: + settings["truncation"] = self.truncation + if self.output_dimension: + settings["output_dimension"] = self.output_dimension + if self.output_dtype: + settings["output_dtype"] = self.output_dtype + return settings + + +class VoyageAIContextualizedEmbeddingPromptExecutionSettings(PromptExecutionSettings): + """VoyageAI contextualized embedding prompt execution settings. + + Args: + ai_model_id: The model ID to use for contextualized embeddings. + input_type: Type of input ('query' or 'document'). + """ + + ai_model_id: str | None = None + input_type: Literal["query", "document"] | None = None + + def prepare_settings_dict(self) -> dict: + """Prepare settings for API call.""" + settings = {} + if self.input_type: + settings["input_type"] = self.input_type + return settings + + +class VoyageAIMultimodalEmbeddingPromptExecutionSettings(PromptExecutionSettings): + """VoyageAI multimodal embedding prompt execution settings. + + Args: + ai_model_id: The model ID to use for multimodal embeddings. + input_type: Type of input ('query' or 'document'). + truncation: Whether to truncate inputs that exceed the model's context length. + """ + + ai_model_id: str | None = None + input_type: Literal["query", "document"] | None = None + truncation: bool = True + + def prepare_settings_dict(self) -> dict: + """Prepare settings for API call.""" + settings = {} + if self.input_type: + settings["input_type"] = self.input_type + if self.truncation is not None: + settings["truncation"] = self.truncation + return settings + + +class VoyageAIRerankPromptExecutionSettings(PromptExecutionSettings): + """VoyageAI rerank prompt execution settings. + + Args: + ai_model_id: The model ID to use for reranking. + top_k: Number of most relevant documents to return. + truncation: Whether to truncate inputs that exceed the model's context length. + """ + + ai_model_id: str | None = None + top_k: int | None = None + truncation: bool = True + + def prepare_settings_dict(self) -> dict: + """Prepare settings for API call.""" + settings = {} + if self.top_k: + settings["top_k"] = self.top_k + if self.truncation is not None: + settings["truncation"] = self.truncation + return settings diff --git a/python/semantic_kernel/connectors/ai/voyage_ai/voyage_ai_settings.py b/python/semantic_kernel/connectors/ai/voyage_ai/voyage_ai_settings.py new file mode 100644 index 000000000000..f1c54aabc0f1 --- /dev/null +++ b/python/semantic_kernel/connectors/ai/voyage_ai/voyage_ai_settings.py @@ -0,0 +1,37 @@ +# Copyright (c) Microsoft. All rights reserved. + +from typing import ClassVar + +from pydantic import SecretStr + +from semantic_kernel.kernel_pydantic import KernelBaseSettings + + +class VoyageAISettings(KernelBaseSettings): + """VoyageAI service settings. + + Args: + api_key: VoyageAI API key (Env var VOYAGE_AI_API_KEY) + embedding_model_id: Default embedding model ID + (Env var VOYAGE_AI_EMBEDDING_MODEL_ID) + contextualized_embedding_model_id: Contextualized embedding model ID + (Env var VOYAGE_AI_CONTEXTUALIZED_EMBEDDING_MODEL_ID) + multimodal_embedding_model_id: Multimodal embedding model ID + (Env var VOYAGE_AI_MULTIMODAL_EMBEDDING_MODEL_ID) + reranker_model_id: Reranker model ID (Env var VOYAGE_AI_RERANKER_MODEL_ID) + endpoint: VoyageAI API endpoint (Env var VOYAGE_AI_ENDPOINT), + defaults to https://api.voyageai.com/v1 + max_retries: Maximum number of retries for API calls + (Env var VOYAGE_AI_MAX_RETRIES), defaults to 3 + env_file_path: Path to .env file (optional) + """ + + env_prefix: ClassVar[str] = "VOYAGE_AI_" + + api_key: SecretStr + embedding_model_id: str | None = None + contextualized_embedding_model_id: str | None = None + multimodal_embedding_model_id: str | None = None + reranker_model_id: str | None = None + endpoint: str = "https://api.voyageai.com/v1" + max_retries: int = 3 diff --git a/python/tests/integration/embeddings/test_voyage_ai_embeddings.py b/python/tests/integration/embeddings/test_voyage_ai_embeddings.py new file mode 100644 index 000000000000..771e956dcaef --- /dev/null +++ b/python/tests/integration/embeddings/test_voyage_ai_embeddings.py @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Integration tests for VoyageAI embeddings.""" + +import os + +import pytest + +from semantic_kernel.connectors.ai.voyage_ai import VoyageAITextEmbedding + +pytestmark = pytest.mark.skipif( + not os.getenv("VOYAGE_AI_API_KEY"), + reason="VoyageAI API key not set", +) + + +@pytest.mark.asyncio +async def test_voyage_ai_text_embedding(): + """Test VoyageAI text embeddings with real API.""" + api_key = os.getenv("VOYAGE_AI_API_KEY") + assert api_key, "VOYAGE_AI_API_KEY environment variable must be set" + + service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key=api_key, + ) + + texts = [ + "Semantic Kernel is an SDK", + "VoyageAI provides embeddings", + ] + + embeddings = await service.generate_embeddings(texts) + + assert embeddings is not None + assert len(embeddings) == 2 + assert len(embeddings[0]) > 0 # Should have embedding dimensions + print(f"Generated embeddings with dimension: {len(embeddings[0])}") + + +@pytest.mark.asyncio +async def test_voyage_ai_code_embeddings(): + """Test VoyageAI code embeddings.""" + api_key = os.getenv("VOYAGE_AI_API_KEY") + assert api_key, "VOYAGE_AI_API_KEY environment variable must be set" + + service = VoyageAITextEmbedding( + ai_model_id="voyage-code-3", + api_key=api_key, + ) + + code_snippets = [ + "def hello_world(): print('Hello, World!')", + "function helloWorld() { console.log('Hello, World!'); }", + ] + + embeddings = await service.generate_embeddings(code_snippets) + + assert embeddings is not None + assert len(embeddings) == 2 + print(f"Generated code embeddings with dimension: {len(embeddings[0])}") diff --git a/python/tests/unit/connectors/ai/voyage_ai/conftest.py b/python/tests/unit/connectors/ai/voyage_ai/conftest.py new file mode 100644 index 000000000000..48f49808075f --- /dev/null +++ b/python/tests/unit/connectors/ai/voyage_ai/conftest.py @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft. All rights reserved. + + +import pytest + + +@pytest.fixture(scope="session") +def voyage_ai_unit_test_env(monkeypatch_session): + """Fixture to set VoyageAI environment variables for testing.""" + monkeypatch_session.setenv("VOYAGE_AI_API_KEY", "test-api-key") + monkeypatch_session.setenv("VOYAGE_AI_EMBEDDING_MODEL_ID", "voyage-3-large") + monkeypatch_session.setenv("VOYAGE_AI_CONTEXTUALIZED_EMBEDDING_MODEL_ID", "voyage-context-3") + monkeypatch_session.setenv("VOYAGE_AI_RERANKER_MODEL_ID", "rerank-2.5") + monkeypatch_session.setenv("VOYAGE_AI_MULTIMODAL_EMBEDDING_MODEL_ID", "voyage-multimodal-3") + + return { + "VOYAGE_AI_API_KEY": "test-api-key", + "VOYAGE_AI_EMBEDDING_MODEL_ID": "voyage-3-large", + "VOYAGE_AI_CONTEXTUALIZED_EMBEDDING_MODEL_ID": "voyage-context-3", + "VOYAGE_AI_RERANKER_MODEL_ID": "rerank-2.5", + "VOYAGE_AI_MULTIMODAL_EMBEDDING_MODEL_ID": "voyage-multimodal-3", + } + + +@pytest.fixture +def voyage_ai_service(): + """Fixture for mock VoyageAI service.""" + pytest.importorskip("voyageai", reason="voyageai package not installed") + + +@pytest.fixture(scope="session") +def monkeypatch_session(): + """Session-scoped monkeypatch fixture.""" + from _pytest.monkeypatch import MonkeyPatch + + m = MonkeyPatch() + yield m + m.undo() diff --git a/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_contextualized_embedding.py b/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_contextualized_embedding.py new file mode 100644 index 000000000000..87d6eb3b0e88 --- /dev/null +++ b/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_contextualized_embedding.py @@ -0,0 +1,144 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from numpy import ndarray + +from semantic_kernel.connectors.ai.voyage_ai import ( + VoyageAIContextualizedEmbedding, + VoyageAIContextualizedEmbeddingPromptExecutionSettings, +) +from semantic_kernel.exceptions.service_exceptions import ServiceResponseException + + +def test_init(voyage_ai_unit_test_env): + """Test VoyageAI contextualized embedding initialization.""" + embedding_service = VoyageAIContextualizedEmbedding( + ai_model_id="voyage-context-3", + api_key="test-api-key", + ) + + assert embedding_service.ai_model_id == "voyage-context-3" + assert embedding_service.aclient is not None + + +def test_init_default_model(voyage_ai_unit_test_env): + """Test initialization with default model from env.""" + embedding_service = VoyageAIContextualizedEmbedding( + api_key="test-api-key", + ) + + # Should use default model + assert embedding_service.ai_model_id is not None + + +def test_prompt_execution_settings_class(): + """Test getting prompt execution settings class.""" + embedding_service = VoyageAIContextualizedEmbedding( + ai_model_id="voyage-context-3", + api_key="test-api-key", + ) + + assert ( + embedding_service.get_prompt_execution_settings_class() + == VoyageAIContextualizedEmbeddingPromptExecutionSettings + ) + + +@pytest.mark.asyncio +async def test_generate_contextualized_embeddings(voyage_ai_unit_test_env): + """Test generating contextualized embeddings.""" + # Mock response - result.embeddings is now a list of arrays directly + mock_result = MagicMock() + mock_result.embeddings = [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]] + + mock_response = MagicMock() + mock_response.results = [mock_result] + + embedding_service = VoyageAIContextualizedEmbedding( + ai_model_id="voyage-context-3", + api_key="test-api-key", + ) + + # Mock the aclient + embedding_service._aclient.contextualized_embed = AsyncMock(return_value=mock_response) + + inputs = [["chunk1", "chunk2"]] + embeddings = await embedding_service.generate_contextualized_embeddings(inputs) + + assert isinstance(embeddings, ndarray) + assert len(embeddings) == 2 + assert len(embeddings[0]) == 3 + embedding_service._aclient.contextualized_embed.assert_called_once() + + +@pytest.mark.asyncio +async def test_generate_embeddings_wrapper(voyage_ai_unit_test_env): + """Test the generate_embeddings wrapper method.""" + # Mock response - result.embeddings is now a list of arrays directly + mock_result = MagicMock() + mock_result.embeddings = [[0.1, 0.2, 0.3]] + + mock_response = MagicMock() + mock_response.results = [mock_result] + + embedding_service = VoyageAIContextualizedEmbedding( + ai_model_id="voyage-context-3", + api_key="test-api-key", + ) + + # Mock the aclient + embedding_service._aclient.contextualized_embed = AsyncMock(return_value=mock_response) + + texts = ["test text"] + embeddings = await embedding_service.generate_embeddings(texts) + + assert isinstance(embeddings, ndarray) + embedding_service._aclient.contextualized_embed.assert_called_once() + + +@pytest.mark.asyncio +async def test_generate_contextualized_embeddings_with_settings(voyage_ai_unit_test_env): + """Test generating contextualized embeddings with settings.""" + # Mock response - result.embeddings is now a list of arrays directly + mock_result = MagicMock() + mock_result.embeddings = [[0.1, 0.2]] + + mock_response = MagicMock() + mock_response.results = [mock_result] + + embedding_service = VoyageAIContextualizedEmbedding( + ai_model_id="voyage-context-3", + api_key="test-api-key", + ) + + # Mock the aclient + embedding_service._aclient.contextualized_embed = AsyncMock(return_value=mock_response) + + settings = VoyageAIContextualizedEmbeddingPromptExecutionSettings( + input_type="document", + ) + + inputs = [["chunk1"]] + embeddings = await embedding_service.generate_contextualized_embeddings(inputs, settings=settings) + + assert isinstance(embeddings, ndarray) + embedding_service._aclient.contextualized_embed.assert_called_once() + + +@pytest.mark.asyncio +async def test_generate_contextualized_embeddings_failure(voyage_ai_unit_test_env): + """Test handling of failure.""" + embedding_service = VoyageAIContextualizedEmbedding( + ai_model_id="voyage-context-3", + api_key="test-api-key", + ) + + # Mock the aclient to raise an exception + embedding_service._aclient.contextualized_embed = AsyncMock(side_effect=Exception("API error")) + + inputs = [["chunk1"]] + + with pytest.raises(ServiceResponseException): + await embedding_service.generate_contextualized_embeddings(inputs) diff --git a/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_multimodal_embedding.py b/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_multimodal_embedding.py new file mode 100644 index 000000000000..d32d34718e06 --- /dev/null +++ b/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_multimodal_embedding.py @@ -0,0 +1,169 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from numpy import ndarray + +from semantic_kernel.connectors.ai.voyage_ai import ( + VoyageAIMultimodalEmbedding, + VoyageAIMultimodalEmbeddingPromptExecutionSettings, +) +from semantic_kernel.exceptions.service_exceptions import ServiceResponseException + + +def test_init(voyage_ai_unit_test_env): + """Test VoyageAI multimodal embedding initialization.""" + embedding_service = VoyageAIMultimodalEmbedding( + ai_model_id="voyage-multimodal-3", + api_key="test-api-key", + ) + + assert embedding_service.ai_model_id == "voyage-multimodal-3" + assert embedding_service.aclient is not None + + +def test_init_default_model(voyage_ai_unit_test_env): + """Test initialization with default model from env.""" + embedding_service = VoyageAIMultimodalEmbedding( + api_key="test-api-key", + ) + + # Should use default model + assert embedding_service.ai_model_id is not None + + +def test_prompt_execution_settings_class(): + """Test getting prompt execution settings class.""" + embedding_service = VoyageAIMultimodalEmbedding( + ai_model_id="voyage-multimodal-3", + api_key="test-api-key", + ) + + assert embedding_service.get_prompt_execution_settings_class() == VoyageAIMultimodalEmbeddingPromptExecutionSettings + + +@pytest.mark.asyncio +async def test_generate_multimodal_embeddings(voyage_ai_unit_test_env): + """Test generating multimodal embeddings.""" + # Mock response + mock_response = MagicMock() + mock_response.embeddings = [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ] + + embedding_service = VoyageAIMultimodalEmbedding( + ai_model_id="voyage-multimodal-3", + api_key="test-api-key", + ) + + # Mock the aclient + embedding_service._aclient.multimodal_embed = AsyncMock(return_value=mock_response) + + # Text-only inputs + inputs = ["text1", "text2"] + embeddings = await embedding_service.generate_multimodal_embeddings(inputs) + + assert isinstance(embeddings, ndarray) + assert len(embeddings) == 2 + assert len(embeddings[0]) == 3 + embedding_service._aclient.multimodal_embed.assert_called_once() + + +@pytest.mark.asyncio +async def test_generate_embeddings_wrapper(voyage_ai_unit_test_env): + """Test the generate_embeddings wrapper method.""" + # Mock response + mock_response = MagicMock() + mock_response.embeddings = [[0.1, 0.2, 0.3]] + + embedding_service = VoyageAIMultimodalEmbedding( + ai_model_id="voyage-multimodal-3", + api_key="test-api-key", + ) + + # Mock the aclient + embedding_service._aclient.multimodal_embed = AsyncMock(return_value=mock_response) + + texts = ["test text"] + embeddings = await embedding_service.generate_embeddings(texts) + + assert isinstance(embeddings, ndarray) + embedding_service._aclient.multimodal_embed.assert_called_once() + + +@pytest.mark.asyncio +async def test_generate_multimodal_embeddings_with_settings(voyage_ai_unit_test_env): + """Test generating multimodal embeddings with settings.""" + # Mock response + mock_response = MagicMock() + mock_response.embeddings = [[0.1, 0.2]] + + embedding_service = VoyageAIMultimodalEmbedding( + ai_model_id="voyage-multimodal-3", + api_key="test-api-key", + ) + + # Mock the aclient + embedding_service._aclient.multimodal_embed = AsyncMock(return_value=mock_response) + + settings = VoyageAIMultimodalEmbeddingPromptExecutionSettings( + input_type="query", + truncation=True, + ) + + inputs = ["text input"] + embeddings = await embedding_service.generate_multimodal_embeddings(inputs, settings=settings) + + assert isinstance(embeddings, ndarray) + embedding_service._aclient.multimodal_embed.assert_called_once() + + +@pytest.mark.asyncio +async def test_generate_multimodal_embeddings_mixed_inputs(voyage_ai_unit_test_env): + """Test generating embeddings with mixed text and mock image inputs.""" + # Mock response + mock_response = MagicMock() + mock_response.embeddings = [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ] + + embedding_service = VoyageAIMultimodalEmbedding( + ai_model_id="voyage-multimodal-3", + api_key="test-api-key", + ) + + # Mock the aclient + embedding_service._aclient.multimodal_embed = AsyncMock(return_value=mock_response) + + # Mixed inputs (mock) + mock_image = MagicMock() # Mock PIL Image + inputs = [ + "text description", + mock_image, + ] + + embeddings = await embedding_service.generate_multimodal_embeddings(inputs) + + assert isinstance(embeddings, ndarray) + assert len(embeddings) == 2 + embedding_service._aclient.multimodal_embed.assert_called_once() + + +@pytest.mark.asyncio +async def test_generate_multimodal_embeddings_failure(voyage_ai_unit_test_env): + """Test handling of failure.""" + embedding_service = VoyageAIMultimodalEmbedding( + ai_model_id="voyage-multimodal-3", + api_key="test-api-key", + ) + + # Mock the aclient to raise an exception + embedding_service._aclient.multimodal_embed = AsyncMock(side_effect=Exception("API error")) + + inputs = ["text"] + + with pytest.raises(ServiceResponseException): + await embedding_service.generate_multimodal_embeddings(inputs) diff --git a/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_reranker.py b/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_reranker.py new file mode 100644 index 000000000000..dbe6128f5107 --- /dev/null +++ b/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_reranker.py @@ -0,0 +1,180 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from semantic_kernel.connectors.ai.reranker_base import RerankResult +from semantic_kernel.connectors.ai.voyage_ai import VoyageAIReranker, VoyageAIRerankPromptExecutionSettings +from semantic_kernel.exceptions.service_exceptions import ServiceResponseException + + +def test_init(voyage_ai_unit_test_env): + """Test VoyageAI reranker initialization.""" + reranker_service = VoyageAIReranker( + ai_model_id="rerank-2.5", + api_key="test-api-key", + ) + + assert reranker_service.ai_model_id == "rerank-2.5" + assert reranker_service.service_id == "rerank-2.5" + assert reranker_service.aclient is not None + + +def test_init_from_env(voyage_ai_unit_test_env): + """Test initialization from environment variables.""" + reranker_service = VoyageAIReranker() + + assert reranker_service.ai_model_id == "rerank-2.5" + + +def test_init_with_custom_endpoint(voyage_ai_unit_test_env): + """Test initialization with custom endpoint.""" + custom_endpoint = "https://custom-endpoint.com/v1" + reranker_service = VoyageAIReranker( + ai_model_id="rerank-2.5", + api_key="test-api-key", + endpoint=custom_endpoint, + ) + + assert reranker_service.endpoint == custom_endpoint + + +def test_prompt_execution_settings_class(): + """Test getting prompt execution settings class.""" + reranker_service = VoyageAIReranker( + ai_model_id="rerank-2.5", + api_key="test-api-key", + ) + + assert reranker_service.get_prompt_execution_settings_class() == VoyageAIRerankPromptExecutionSettings + + +@pytest.mark.asyncio +async def test_rerank(voyage_ai_unit_test_env): + """Test reranking documents.""" + # Mock response + mock_result_1 = MagicMock() + mock_result_1.index = 2 + mock_result_1.document = "Most relevant doc" + mock_result_1.relevance_score = 0.95 + + mock_result_2 = MagicMock() + mock_result_2.index = 0 + mock_result_2.document = "Somewhat relevant doc" + mock_result_2.relevance_score = 0.75 + + mock_response = MagicMock() + mock_response.results = [mock_result_1, mock_result_2] + + reranker_service = VoyageAIReranker( + ai_model_id="rerank-2.5", + api_key="test-api-key", + ) + + # Mock the aclient + reranker_service._aclient.rerank = AsyncMock(return_value=mock_response) + + query = "What is Semantic Kernel?" + documents = [ + "Somewhat relevant doc", + "Irrelevant doc", + "Most relevant doc", + ] + + results = await reranker_service.rerank(query, documents) + + assert isinstance(results, list) + assert len(results) == 2 + assert all(isinstance(r, RerankResult) for r in results) + assert results[0].index == 2 + assert results[0].relevance_score == 0.95 + assert results[1].index == 0 + assert results[1].relevance_score == 0.75 + reranker_service._aclient.rerank.assert_called_once() + + +@pytest.mark.asyncio +async def test_rerank_with_settings(voyage_ai_unit_test_env): + """Test reranking with execution settings.""" + # Mock response + mock_result = MagicMock() + mock_result.index = 0 + mock_result.document = "Top result" + mock_result.relevance_score = 0.95 + + mock_response = MagicMock() + mock_response.results = [mock_result] + + reranker_service = VoyageAIReranker( + ai_model_id="rerank-2.5", + api_key="test-api-key", + ) + + # Mock the aclient + reranker_service._aclient.rerank = AsyncMock(return_value=mock_response) + + settings = VoyageAIRerankPromptExecutionSettings( + top_k=1, # Only return top result + truncation=True, + ) + + query = "test query" + documents = ["doc1", "doc2", "doc3"] + + results = await reranker_service.rerank(query, documents, settings=settings) + + assert len(results) == 1 + assert results[0].relevance_score == 0.95 + reranker_service._aclient.rerank.assert_called_once() + + +@pytest.mark.asyncio +async def test_rerank_empty_results(voyage_ai_unit_test_env): + """Test reranking with empty results.""" + mock_response = MagicMock() + mock_response.results = [] + + reranker_service = VoyageAIReranker( + ai_model_id="rerank-2.5", + api_key="test-api-key", + ) + + # Mock the aclient + reranker_service._aclient.rerank = AsyncMock(return_value=mock_response) + + query = "test query" + documents = ["doc1"] + + results = await reranker_service.rerank(query, documents) + + assert isinstance(results, list) + assert len(results) == 0 + + +@pytest.mark.asyncio +async def test_rerank_failure(voyage_ai_unit_test_env): + """Test handling of reranking failure.""" + reranker_service = VoyageAIReranker( + ai_model_id="rerank-2.5", + api_key="test-api-key", + ) + + # Mock the aclient to raise an exception + reranker_service._aclient.rerank = AsyncMock(side_effect=Exception("API error")) + + query = "test query" + documents = ["doc1", "doc2"] + + with pytest.raises(ServiceResponseException): + await reranker_service.rerank(query, documents) + + +def test_service_url(): + """Test service URL retrieval.""" + reranker_service = VoyageAIReranker( + ai_model_id="rerank-2.5", + api_key="test-api-key", + ) + + assert reranker_service.service_url() == "https://api.voyageai.com/v1" diff --git a/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_text_embedding.py b/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_text_embedding.py new file mode 100644 index 000000000000..cadf1b2a5c7d --- /dev/null +++ b/python/tests/unit/connectors/ai/voyage_ai/services/test_voyage_ai_text_embedding.py @@ -0,0 +1,187 @@ +# Copyright (c) Microsoft. All rights reserved. + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from numpy import ndarray + +from semantic_kernel.connectors.ai.voyage_ai import ( + VoyageAIEmbeddingPromptExecutionSettings, + VoyageAITextEmbedding, +) +from semantic_kernel.exceptions.service_exceptions import ServiceResponseException + + +def test_init(voyage_ai_unit_test_env): + """Test VoyageAI text embedding initialization.""" + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key="test-api-key", + ) + + assert embedding_service.ai_model_id == "voyage-3-large" + assert embedding_service.service_id == "voyage-3-large" + assert embedding_service.aclient is not None + + +def test_init_with_service_id(voyage_ai_unit_test_env): + """Test initialization with custom service ID.""" + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + service_id="custom-service", + api_key="test-api-key", + ) + + assert embedding_service.ai_model_id == "voyage-3-large" + assert embedding_service.service_id == "custom-service" + + +def test_init_from_env(voyage_ai_unit_test_env): + """Test initialization from environment variables.""" + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + ) + + assert embedding_service.ai_model_id == "voyage-3-large" + + +def test_init_with_custom_endpoint(voyage_ai_unit_test_env): + """Test initialization with custom endpoint.""" + custom_endpoint = "https://custom-endpoint.com/v1" + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key="test-api-key", + endpoint=custom_endpoint, + ) + + assert embedding_service.endpoint == custom_endpoint + + +def test_prompt_execution_settings_class(): + """Test getting prompt execution settings class.""" + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key="test-api-key", + ) + + assert embedding_service.get_prompt_execution_settings_class() == VoyageAIEmbeddingPromptExecutionSettings + + +@pytest.mark.asyncio +async def test_generate_embeddings(voyage_ai_unit_test_env): + """Test generating embeddings.""" + # Mock response + mock_response = MagicMock() + mock_response.embeddings = [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ] + + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key="test-api-key", + ) + + # Mock the aclient + embedding_service._aclient.embed = AsyncMock(return_value=mock_response) + + texts = ["hello world", "goodbye world"] + embeddings = await embedding_service.generate_embeddings(texts) + + assert isinstance(embeddings, ndarray) + assert len(embeddings) == 2 + assert len(embeddings[0]) == 3 + embedding_service._aclient.embed.assert_called_once() + + +@pytest.mark.asyncio +async def test_generate_embeddings_with_settings(voyage_ai_unit_test_env): + """Test generating embeddings with execution settings.""" + # Mock response + mock_response = MagicMock() + mock_response.embeddings = [[0.1, 0.2, 0.3]] + + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key="test-api-key", + ) + + # Mock the aclient + embedding_service._aclient.embed = AsyncMock(return_value=mock_response) + + settings = VoyageAIEmbeddingPromptExecutionSettings( + input_type="query", + truncation=True, + output_dimension=1024, + ) + + texts = ["test text"] + embeddings = await embedding_service.generate_embeddings(texts, settings=settings) + + assert isinstance(embeddings, ndarray) + embedding_service._aclient.embed.assert_called_once() + + +@pytest.mark.asyncio +async def test_generate_embeddings_with_different_output_dtype(voyage_ai_unit_test_env): + """Test generating embeddings with different output dtype.""" + # Mock response + mock_response = MagicMock() + mock_response.embeddings = [[1, 2, 3]] # int8 format + + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key="test-api-key", + ) + + # Mock the aclient + embedding_service._aclient.embed = AsyncMock(return_value=mock_response) + + settings = VoyageAIEmbeddingPromptExecutionSettings( + output_dtype="int8", + ) + + texts = ["test text"] + embeddings = await embedding_service.generate_embeddings(texts, settings=settings) + + assert isinstance(embeddings, ndarray) + embedding_service._aclient.embed.assert_called_once() + + +@pytest.mark.asyncio +async def test_generate_embeddings_failure(voyage_ai_unit_test_env): + """Test handling of embedding generation failure.""" + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key="test-api-key", + ) + + # Mock the aclient to raise an exception + embedding_service._aclient.embed = AsyncMock(side_effect=Exception("API error")) + + texts = ["test text"] + + with pytest.raises(ServiceResponseException): + await embedding_service.generate_embeddings(texts) + + +def test_service_url(): + """Test service URL retrieval.""" + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key="test-api-key", + ) + + assert embedding_service.service_url() == "https://api.voyageai.com/v1" + + +def test_service_url_with_custom_endpoint(): + """Test service URL with custom endpoint.""" + custom_endpoint = "https://custom.api.com/v1" + embedding_service = VoyageAITextEmbedding( + ai_model_id="voyage-3-large", + api_key="test-api-key", + endpoint=custom_endpoint, + ) + + assert embedding_service.service_url() == custom_endpoint