diff --git a/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIEmbeddingEndpointTests.cs b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIEmbeddingEndpointTests.cs new file mode 100644 index 000000000000..c3a9702810f1 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Google.UnitTests/Core/VertexAI/VertexAIEmbeddingEndpointTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.SemanticKernel.Connectors.Google; +using Microsoft.SemanticKernel.Connectors.Google.Core; +using Xunit; + +namespace SemanticKernel.Connectors.Google.UnitTests.Core.VertexAI; + +public sealed class VertexAIEmbeddingEndpointTests : IDisposable +{ + private readonly HttpMessageHandlerStub _messageHandlerStub; + private readonly HttpClient _httpClient; + + public VertexAIEmbeddingEndpointTests() + { + this._messageHandlerStub = new HttpMessageHandlerStub(); + this._messageHandlerStub.ResponseToReturn.Content = new StringContent( + """ + { + "embedding": { + "values": [0.1, 0.2, 0.3] + } + } + """); + this._httpClient = new HttpClient(this._messageHandlerStub, false); + } + + [Theory] + [InlineData("gemini-embedding-2", true)] + [InlineData("gemini-embedding-2-preview", true)] + [InlineData("gemini-embedding-2-0", true)] + [InlineData("GEMINI-EMBEDDING-2", true)] + [InlineData("gemini-embedding-001", false)] + [InlineData("textembedding-gecko", false)] + [InlineData("textembedding-gecko@003", false)] + [InlineData("text-embedding-004", false)] + [InlineData("custom-model", false)] + public void UsesEmbedContentEndpoint_ReturnsExpectedValue(string modelId, bool expected) + { + Assert.Equal(expected, VertexAIEmbeddingClient.UsesEmbedContentEndpoint(modelId)); + } + + [Fact] + public async Task Constructor_UsesEmbedContentSuffix_ForGeminiEmbedding2Async() + { + // Arrange + var client = this.CreateClient("gemini-embedding-2"); + + // Act + await client.GenerateEmbeddingsAsync(["hello"]); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + Assert.Contains(":embedContent", this._messageHandlerStub.RequestUri.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain(":predict", this._messageHandlerStub.RequestUri.ToString(), StringComparison.Ordinal); + } + + [Fact] + public async Task Constructor_UsesPredictSuffix_ForLegacyModelsAsync() + { + // Arrange – predict path expects the legacy response shape + this._messageHandlerStub.ResponseToReturn.Content = new StringContent( + """ + { + "predictions": [ + { + "embeddings": { + "values": [0.1, 0.2, 0.3] + } + } + ] + } + """); + var client = this.CreateClient("gemini-embedding-001"); + + // Act + await client.GenerateEmbeddingsAsync(["hello"]); + + // Assert + Assert.NotNull(this._messageHandlerStub.RequestUri); + Assert.Contains(":predict", this._messageHandlerStub.RequestUri.ToString(), StringComparison.Ordinal); + Assert.DoesNotContain(":embedContent", this._messageHandlerStub.RequestUri.ToString(), StringComparison.Ordinal); + } + + public void Dispose() + { + this._httpClient.Dispose(); + this._messageHandlerStub.Dispose(); + } + + private VertexAIEmbeddingClient CreateClient(string modelId) + { + return new VertexAIEmbeddingClient( + httpClient: this._httpClient, + modelId: modelId, + bearerTokenProvider: () => ValueTask.FromResult("fake-key"), + apiVersion: VertexAIVersion.V1, + location: "us-central1", + projectId: "fake-project-id"); + } +} diff --git a/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbedContentRequest.cs b/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbedContentRequest.cs new file mode 100644 index 000000000000..ceae25b8e104 --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbedContentRequest.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Google.Core; + +/// +/// Request body for Vertex AI :embedContent (Gemini Embedding 2 models). +/// +internal sealed class VertexAIEmbedContentRequest +{ + [JsonPropertyName("content")] + public GeminiContent Content { get; set; } = null!; + + [JsonPropertyName("outputDimensionality")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? OutputDimensionality { get; set; } + + [JsonPropertyName("taskType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TaskType { get; set; } + + [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Title { get; set; } + + public static VertexAIEmbedContentRequest FromText(string text, int? dimensions = null) => new() + { + Content = new GeminiContent + { + Parts = + [ + new GeminiPart { Text = text } + ] + }, + OutputDimensionality = dimensions + }; +} diff --git a/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbedContentResponse.cs b/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbedContentResponse.cs new file mode 100644 index 000000000000..24180be971cd --- /dev/null +++ b/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbedContentResponse.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Connectors.Google.Core; + +/// +/// Response body for Vertex AI :embedContent (Gemini Embedding 2 models). +/// +internal sealed class VertexAIEmbedContentResponse +{ + [JsonPropertyName("embedding")] + [JsonRequired] + public ResponseEmbedding Embedding { get; set; } = null!; + + internal sealed class ResponseEmbedding + { + [JsonPropertyName("values")] + [JsonRequired] + public ReadOnlyMemory Values { get; set; } + } +} diff --git a/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs b/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs index cb59e0087481..4d21aabde548 100644 --- a/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs +++ b/dotnet/src/Connectors/Connectors.Google/Core/VertexAI/VertexAIEmbeddingClient.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; @@ -19,6 +19,7 @@ internal sealed class VertexAIEmbeddingClient : ClientBase private readonly string _embeddingModelId; private readonly Uri _embeddingEndpoint; private readonly int? _dimensions; + private readonly bool _usesEmbedContent; /// /// Represents a client for interacting with the embeddings models by Vertex AI. @@ -54,10 +55,19 @@ public VertexAIEmbeddingClient( string baseUri = GetVertexAIBaseUri(location); this._embeddingModelId = modelId; - this._embeddingEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._embeddingModelId}:predict"); + this._usesEmbedContent = UsesEmbedContentEndpoint(modelId); + string endpointSuffix = this._usesEmbedContent ? "embedContent" : "predict"; + this._embeddingEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._embeddingModelId}:{endpointSuffix}"); this._dimensions = dimensions; } + /// + /// Newer multimodal Gemini Embedding 2 models use the :embedContent endpoint. + /// Legacy text embedding models continue to use :predict. + /// + internal static bool UsesEmbedContentEndpoint(string modelId) + => modelId.StartsWith("gemini-embedding-2", StringComparison.OrdinalIgnoreCase); + /// /// Generates embeddings for the given data asynchronously. /// @@ -72,8 +82,13 @@ public async Task>> GenerateEmbeddingsAsync( { Verify.NotNullOrEmpty(data); - var geminiRequest = this.GetEmbeddingRequest(data, options); - using var httpRequestMessage = await this.CreateHttpRequestAsync(geminiRequest, this._embeddingEndpoint).ConfigureAwait(false); + if (this._usesEmbedContent) + { + return await this.GenerateEmbedContentEmbeddingsAsync(data, options, cancellationToken).ConfigureAwait(false); + } + + var predictRequest = this.GetEmbeddingRequest(data, options); + using var httpRequestMessage = await this.CreateHttpRequestAsync(predictRequest, this._embeddingEndpoint).ConfigureAwait(false); string body = await this.SendRequestAndGetStringBodyAsync(httpRequestMessage, cancellationToken) .ConfigureAwait(false); @@ -81,6 +96,32 @@ public async Task>> GenerateEmbeddingsAsync( return DeserializeAndProcessEmbeddingsResponse(body); } + /// + /// The Vertex AI :embedContent API accepts a single content object per request. + /// Issue one call per input string and preserve order. + /// + private async Task>> GenerateEmbedContentEmbeddingsAsync( + IList data, + EmbeddingGenerationOptions? options, + CancellationToken cancellationToken) + { + var results = new List>(data.Count); + int? dimensions = options?.Dimensions ?? this._dimensions; + + foreach (string text in data) + { + var request = VertexAIEmbedContentRequest.FromText(text, dimensions); + using var httpRequestMessage = await this.CreateHttpRequestAsync(request, this._embeddingEndpoint).ConfigureAwait(false); + + string body = await this.SendRequestAndGetStringBodyAsync(httpRequestMessage, cancellationToken) + .ConfigureAwait(false); + + results.Add(DeserializeAndProcessEmbedContentResponse(body)); + } + + return results; + } + private VertexAIEmbeddingRequest GetEmbeddingRequest(IEnumerable data, EmbeddingGenerationOptions? options = null) => VertexAIEmbeddingRequest.FromData(data, options?.Dimensions ?? this._dimensions); @@ -89,4 +130,7 @@ private static List> DeserializeAndProcessEmbeddingsRespon private static List> ProcessEmbeddingsResponse(VertexAIEmbeddingResponse embeddingsResponse) => embeddingsResponse.Predictions.Select(prediction => prediction.Embeddings.Values).ToList(); + + private static ReadOnlyMemory DeserializeAndProcessEmbedContentResponse(string body) + => DeserializeResponse(body).Embedding.Values; }