Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Text.Json.Serialization;

namespace Microsoft.SemanticKernel.Connectors.Google.Core;

/// <summary>
/// Request body for Vertex AI <c>:embedContent</c> (Gemini Embedding 2 models).
/// </summary>
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
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Text.Json.Serialization;

namespace Microsoft.SemanticKernel.Connectors.Google.Core;

/// <summary>
/// Response body for Vertex AI <c>:embedContent</c> (Gemini Embedding 2 models).
/// </summary>
internal sealed class VertexAIEmbedContentResponse
{
[JsonPropertyName("embedding")]
[JsonRequired]
public ResponseEmbedding Embedding { get; set; } = null!;

internal sealed class ResponseEmbedding
{
[JsonPropertyName("values")]
[JsonRequired]
public ReadOnlyMemory<float> Values { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
Expand All @@ -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;

/// <summary>
/// Represents a client for interacting with the embeddings models by Vertex AI.
Expand Down Expand Up @@ -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;
}

/// <summary>
/// Newer multimodal Gemini Embedding 2 models use the <c>:embedContent</c> endpoint.
/// Legacy text embedding models continue to use <c>:predict</c>.
/// </summary>
internal static bool UsesEmbedContentEndpoint(string modelId)
=> modelId.StartsWith("gemini-embedding-2", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Generates embeddings for the given data asynchronously.
/// </summary>
Expand All @@ -72,15 +82,46 @@ public async Task<IList<ReadOnlyMemory<float>>> 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);

return DeserializeAndProcessEmbeddingsResponse(body);
}

/// <summary>
/// The Vertex AI <c>:embedContent</c> API accepts a single content object per request.
/// Issue one call per input string and preserve order.
/// </summary>
private async Task<IList<ReadOnlyMemory<float>>> GenerateEmbedContentEmbeddingsAsync(
IList<string> data,
EmbeddingGenerationOptions? options,
CancellationToken cancellationToken)
{
var results = new List<ReadOnlyMemory<float>>(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<string> data, EmbeddingGenerationOptions? options = null)
=> VertexAIEmbeddingRequest.FromData(data, options?.Dimensions ?? this._dimensions);

Expand All @@ -89,4 +130,7 @@ private static List<ReadOnlyMemory<float>> DeserializeAndProcessEmbeddingsRespon

private static List<ReadOnlyMemory<float>> ProcessEmbeddingsResponse(VertexAIEmbeddingResponse embeddingsResponse)
=> embeddingsResponse.Predictions.Select(prediction => prediction.Embeddings.Values).ToList();

private static ReadOnlyMemory<float> DeserializeAndProcessEmbedContentResponse(string body)
=> DeserializeResponse<VertexAIEmbedContentResponse>(body).Embedding.Values;
}
Loading