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
Expand Up @@ -206,6 +206,77 @@ public void ItAcceptsValidHostnameSegments(string validLocation)
Assert.Null(exception);
}

[Fact]
public async Task ShouldUseBatchEmbedContentsEndpointForGeminiEmbeddingModelAsync()
{
// Arrange
string modelId = "gemini-embedding-2";
var client = this.CreateEmbeddingsClient(modelId: modelId);
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(
File.ReadAllText("./TestData/vertex_embed_content_response.json"));
IList<string> data = ["sample data"];

// Act
await client.GenerateEmbeddingsAsync(data);

// Assert
Assert.NotNull(this._messageHandlerStub.RequestUri);
Assert.EndsWith(":batchEmbedContents", this._messageHandlerStub.RequestUri.ToString(), StringComparison.Ordinal);
Assert.NotNull(this._messageHandlerStub.RequestContent);
string requestBody = System.Text.Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent);
using var requestJson = JsonDocument.Parse(requestBody);
Assert.Equal(JsonValueKind.Array, requestJson.RootElement.GetProperty("requests").ValueKind);
var firstRequest = requestJson.RootElement.GetProperty("requests")[0];
Assert.Equal("sample data", firstRequest.GetProperty("content").GetProperty("parts")[0].GetProperty("text").GetString());
Assert.False(requestJson.RootElement.TryGetProperty("instances", out _));
}

[Fact]
public async Task ShouldUsePredictEndpointForLegacyEmbeddingModelAsync()
{
// Arrange
string modelId = "text-embedding-004";
var client = this.CreateEmbeddingsClient(modelId: modelId);
IList<string> data = ["sample data"];

// Act
await client.GenerateEmbeddingsAsync(data);

// Assert
Assert.NotNull(this._messageHandlerStub.RequestUri);
Assert.EndsWith(":predict", this._messageHandlerStub.RequestUri.ToString(), StringComparison.Ordinal);
Assert.NotNull(this._messageHandlerStub.RequestContent);
string requestBody = System.Text.Encoding.UTF8.GetString(this._messageHandlerStub.RequestContent);
using var requestJson = JsonDocument.Parse(requestBody);
Assert.Equal(JsonValueKind.Array, requestJson.RootElement.GetProperty("instances").ValueKind);
}

[Fact]
public async Task ShouldReturnValidEmbeddingsResponseForGeminiEmbeddingModelAsync()
{
// Arrange
string modelId = "gemini-embedding-2";
var client = this.CreateEmbeddingsClient(modelId: modelId);
this._messageHandlerStub.ResponseToReturn.Content = new StringContent(
File.ReadAllText("./TestData/vertex_embed_content_response.json"));
var dataToEmbed = new List<string>()
{
"Write a story about a magic backpack.",
"Print color of backpack."
};

// Act
var embeddings = await client.GenerateEmbeddingsAsync(dataToEmbed);

// Assert
VertexAIEmbedContentResponse testDataResponse = JsonSerializer.Deserialize<VertexAIEmbedContentResponse>(
await File.ReadAllTextAsync("./TestData/vertex_embed_content_response.json"))!;
Assert.NotNull(embeddings);
Assert.Collection(embeddings,
values => Assert.Equal(testDataResponse.Embeddings[0].Values, values),
values => Assert.Equal(testDataResponse.Embeddings[1].Values, values));
}

[Fact]
public async Task ShouldUseGlobalEndpointWhenLocationIsGlobalAsync()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"embeddings": [
{
"values": [
0.1,
0.2,
0.3
]
},
{
"values": [
0.4,
0.5,
0.6
]
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) Microsoft. All rights reserved.

using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;

namespace Microsoft.SemanticKernel.Connectors.Google.Core;

internal sealed class VertexAIEmbedContentRequest
{
[JsonPropertyName("requests")]
public IList<EmbedContentRequestItem> Requests { get; set; } = null!;

public static VertexAIEmbedContentRequest FromData(IEnumerable<string> data, int? dimensions = null) => new()
{
Requests = data.Select(text => new EmbedContentRequestItem

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each item in the :batchEmbedContents request body is built with only content (and optional outputDimensionality) and never sets a per-request model. The sibling GoogleAI implementation for this same endpoint sets it explicitly (GoogleAIEmbeddingRequest.cs:39, Model = $"models/{modelId}"), because the batchEmbedContents schema treats each requests[] entry as a full embed request whose model is required. If Vertex enforces the same requirement, every gemini-embedding-* call will fail at runtime with an HTTP 400 — the same class of error this PR is meant to fix — and the stubbed test handler cannot detect it since it never validates the body against the live API.

Populate a model field on each EmbedContentRequestItem with the fully-qualified Vertex resource name (projects/{projectId}/locations/{location}/publishers/google/models/{modelId}), or confirm against the Vertex batchEmbedContents REST reference that omission is accepted and document that decision. Please verify against a live endpoint before merge.

{
Content = new RequestContent
{
Parts =
[
new RequestPart
{
Text = text
}
]
},
OutputDimensionality = dimensions
}).ToList()
};

internal sealed class EmbedContentRequestItem
{
[JsonPropertyName("content")]
public RequestContent Content { get; set; } = null!;

[JsonPropertyName("outputDimensionality")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? OutputDimensionality { get; set; }
}

internal sealed class RequestContent
{
[JsonPropertyName("parts")]
public IList<RequestPart> Parts { get; set; } = null!;
}

internal sealed class RequestPart
{
[JsonPropertyName("text")]
public string Text { get; set; } = null!;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;

namespace Microsoft.SemanticKernel.Connectors.Google.Core;

internal sealed class VertexAIEmbedContentResponse
{
[JsonPropertyName("embeddings")]
[JsonRequired]
public IList<ResponseEmbedding> Embeddings { 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
Expand Up @@ -19,6 +19,7 @@ internal sealed class VertexAIEmbeddingClient : ClientBase
private readonly string _embeddingModelId;
private readonly Uri _embeddingEndpoint;
private readonly int? _dimensions;
private readonly bool _useEmbedContentMethod;

/// <summary>
/// Represents a client for interacting with the embeddings models by Vertex AI.
Expand Down Expand Up @@ -54,10 +55,15 @@ 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._useEmbedContentMethod = UsesEmbedContentMethod(modelId);
string embeddingMethod = this._useEmbedContentMethod ? "batchEmbedContents" : "predict";
this._embeddingEndpoint = new Uri($"{baseUri}/{versionSubLink}/projects/{projectId}/locations/{location}/publishers/google/models/{this._embeddingModelId}:{embeddingMethod}");
this._dimensions = dimensions;
}

private static bool UsesEmbedContentMethod(string modelId)
=> modelId.StartsWith("gemini-embedding", StringComparison.Ordinal);

/// <summary>
/// Generates embeddings for the given data asynchronously.
/// </summary>
Expand All @@ -72,18 +78,26 @@ 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);
object request = this._useEmbedContentMethod
? VertexAIEmbedContentRequest.FromData(data, options?.Dimensions ?? this._dimensions)
: this.GetEmbeddingRequest(data, options);

using var httpRequestMessage = await this.CreateHttpRequestAsync(request, this._embeddingEndpoint).ConfigureAwait(false);

string body = await this.SendRequestAndGetStringBodyAsync(httpRequestMessage, cancellationToken)
.ConfigureAwait(false);

return DeserializeAndProcessEmbeddingsResponse(body);
return this._useEmbedContentMethod
? ProcessEmbedContentResponse(body)
: DeserializeAndProcessEmbeddingsResponse(body);
}

private VertexAIEmbeddingRequest GetEmbeddingRequest(IEnumerable<string> data, EmbeddingGenerationOptions? options = null)
=> VertexAIEmbeddingRequest.FromData(data, options?.Dimensions ?? this._dimensions);

private static List<ReadOnlyMemory<float>> ProcessEmbedContentResponse(string body)
=> DeserializeResponse<VertexAIEmbedContentResponse>(body).Embeddings.Select(embedding => embedding.Values).ToList();

private static List<ReadOnlyMemory<float>> DeserializeAndProcessEmbeddingsResponse(string body)
=> ProcessEmbeddingsResponse(DeserializeResponse<VertexAIEmbeddingResponse>(body));

Expand Down
Loading