Conversation
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
…configuration Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
… and telemetry integration Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
…ate empty embeddings Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
…oint/health sub-objects Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
…orization Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
…lthCheckConfig in converter
There was a problem hiding this comment.
Pull request overview
Adds a new internal embeddings/vectorization subsystem to DAB, including runtime configuration (runtime.embeddings), an HTTP-backed embedding service with caching/telemetry, an optional REST endpoint, and health-check reporting integration.
Changes:
- Introduces
EmbeddingsOptionsconfig models + JSON schema + custom converter and runtime validation. - Adds
IEmbeddingService/EmbeddingService(OpenAI/Azure OpenAI) with FusionCache L1 caching and OpenTelemetry instrumentation. - Integrates embeddings status into health reporting and adds a new
/embed-style endpoint controller plus CLI configuration flags.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Service/Startup.cs | Registers embeddings services/options and wires embeddings into service startup logging. |
| src/Service/HealthCheck/Model/ConfigurationDetails.cs | Extends health check configuration details to include embeddings flags. |
| src/Service/HealthCheck/HealthCheckHelper.cs | Adds embeddings health check execution + reporting. |
| src/Service/Controllers/EmbeddingController.cs | New REST endpoint controller for embedding generation. |
| src/Service.Tests/UnitTests/EmbeddingsOptionsTests.cs | Unit tests for embeddings config deserialization/serialization and env var replacement. |
| src/Service.Tests/UnitTests/EmbeddingServiceTests.cs | Unit tests for EmbeddingService option behaviors and disabled-path failures. |
| src/Service.Tests/UnitTests/ConfigValidationUnitTests.cs | Adds validation test coverage for embeddings config constraints. |
| src/Core/Services/Embeddings/IEmbeddingService.cs | Defines embeddings service contract + result types. |
| src/Core/Services/Embeddings/EmbeddingTelemetryHelper.cs | Adds embeddings-specific metrics and tracing helpers. |
| src/Core/Services/Embeddings/EmbeddingService.cs | Implements embedding calls, caching, and telemetry hooks. |
| src/Core/Configurations/RuntimeConfigValidator.cs | Adds validation for runtime.embeddings settings (URLs, required fields, endpoint conflicts, etc.). |
| src/Config/RuntimeConfigLoader.cs | Registers the embeddings JSON converter in runtime config serialization options. |
| src/Config/ObjectModel/RuntimeOptions.cs | Adds Embeddings to runtime options and exposes IsEmbeddingsConfigured. |
| src/Config/ObjectModel/Embeddings/EmbeddingsOptions.cs | New embeddings config model with defaults and “effective” helper properties. |
| src/Config/ObjectModel/Embeddings/EmbeddingsHealthCheckConfig.cs | New embeddings health-check config model. |
| src/Config/ObjectModel/Embeddings/EmbeddingsEndpointOptions.cs | New embeddings endpoint config + role checks. |
| src/Config/ObjectModel/Embeddings/EmbeddingProviderType.cs | Defines supported providers (azure-openai, openai). |
| src/Config/HotReloadEventHandler.cs | Registers a new embeddings-related hot-reload event slot. |
| src/Config/HealthCheck/HealthCheckConstants.cs | Adds an “embedding” health-check tag constant. |
| src/Config/DabConfigEvents.cs | Adds an embeddings service config-changed event constant. |
| src/Config/Converters/EmbeddingsOptionsConverterFactory.cs | Custom JSON converter for embeddings config. |
| src/Cli/ConfigGenerator.cs | Adds config generation/update flow for embeddings options. |
| src/Cli/Commands/ConfigureOptions.cs | Adds CLI flags for runtime.embeddings.* configuration. |
| schemas/dab.draft.schema.json | Adds JSON schema definition for runtime.embeddings. |
| switch (propertyName) | ||
| { | ||
| case "enabled": | ||
| enabled = reader.GetBoolean(); | ||
| break; |
There was a problem hiding this comment.
The converter reads optional fields with reader.GetBoolean() / GetInt32() directly. If a config specifies these as JSON null, deserialization will throw instead of treating them as unspecified.
Handle JsonTokenType.Null explicitly (leave the nullable as null) or use JsonSerializer.Deserialize<bool?> / Deserialize<int?> for these fields.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
src/Service/Startup.cs
Outdated
| services.AddSingleton<HealthCheckHelper>(sp => | ||
| { | ||
| ILogger<HealthCheckHelper> logger = sp.GetRequiredService<ILogger<HealthCheckHelper>>(); | ||
| HttpUtilities httpUtility = sp.GetRequiredService<HttpUtilities>(); | ||
| IEmbeddingService? embeddingService = sp.GetService<IEmbeddingService>(); |
There was a problem hiding this comment.
HealthCheckHelper is registered via a factory that resolves IEmbeddingService immediately (sp.GetService<IEmbeddingService>()). This can instantiate EmbeddingService even when embeddings are configured-but-disabled, and can cause failures if required strings are empty.
Prefer letting DI inject the optional parameter (no factory), and/or only register IEmbeddingService when embeddings are enabled and valid.
There was a problem hiding this comment.
@copilot open a new pull request to apply changes based on this feedback
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
| [TestClass] | ||
| public class EmbeddingServiceTests | ||
| { | ||
| private Mock<ILogger<EmbeddingService>> _mockLogger = null!; | ||
| private Mock<IFusionCache> _mockCache = null!; | ||
|
|
||
| [TestInitialize] | ||
| public void Setup() | ||
| { | ||
| _mockLogger = new Mock<ILogger<EmbeddingService>>(); | ||
| _mockCache = new Mock<IFusionCache>(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Tests that IsEnabled returns true when embeddings are enabled. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void IsEnabled_ReturnsTrue_WhenEnabled() | ||
| { | ||
| // Arrange | ||
| EmbeddingsOptions options = CreateAzureOpenAIOptions(); | ||
| HttpClient httpClient = new(); | ||
| EmbeddingService service = new(httpClient, options, _mockLogger.Object, _mockCache.Object); | ||
|
|
||
| // Assert | ||
| Assert.IsTrue(service.IsEnabled); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Tests that IsEnabled returns false when embeddings are disabled. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void IsEnabled_ReturnsFalse_WhenDisabled() | ||
| { | ||
| // Arrange | ||
| EmbeddingsOptions options = new( | ||
| Provider: EmbeddingProviderType.AzureOpenAI, | ||
| BaseUrl: "https://test.openai.azure.com", | ||
| ApiKey: "test-api-key", | ||
| Enabled: false, | ||
| Model: "text-embedding-ada-002"); | ||
| HttpClient httpClient = new(); | ||
| EmbeddingService service = new(httpClient, options, _mockLogger.Object, _mockCache.Object); | ||
|
|
||
| // Assert | ||
| Assert.IsFalse(service.IsEnabled); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Tests that TryEmbedAsync returns failure when service is disabled. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public async Task TryEmbedAsync_ReturnsFailure_WhenDisabled() | ||
| { | ||
| // Arrange | ||
| EmbeddingsOptions options = new( | ||
| Provider: EmbeddingProviderType.AzureOpenAI, | ||
| BaseUrl: "https://test.openai.azure.com", | ||
| ApiKey: "test-api-key", | ||
| Enabled: false, | ||
| Model: "text-embedding-ada-002"); | ||
| HttpClient httpClient = new(); | ||
| EmbeddingService service = new(httpClient, options, _mockLogger.Object, _mockCache.Object); | ||
|
|
||
| // Act | ||
| EmbeddingResult result = await service.TryEmbedAsync("test"); | ||
|
|
||
| // Assert | ||
| Assert.IsFalse(result.Success); | ||
| Assert.IsNull(result.Embedding); | ||
| Assert.IsNotNull(result.ErrorMessage); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Tests that TryEmbedAsync returns failure for null or empty text. | ||
| /// </summary> | ||
| [DataTestMethod] | ||
| [DataRow(null, DisplayName = "Null text returns failure")] | ||
| [DataRow("", DisplayName = "Empty text returns failure")] | ||
| public async Task TryEmbedAsync_ReturnsFailure_ForNullOrEmptyText(string text) | ||
| { | ||
| // Arrange | ||
| EmbeddingsOptions options = CreateAzureOpenAIOptions(); | ||
| HttpClient httpClient = new(); | ||
| EmbeddingService service = new(httpClient, options, _mockLogger.Object, _mockCache.Object); | ||
|
|
||
| // Act | ||
| EmbeddingResult result = await service.TryEmbedAsync(text!); | ||
|
|
||
| // Assert | ||
| Assert.IsFalse(result.Success); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Tests that EffectiveModel returns the default model for OpenAI when not specified. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void EmbeddingsOptions_OpenAI_DefaultModel() | ||
| { | ||
| // Arrange | ||
| EmbeddingsOptions options = new( | ||
| Provider: EmbeddingProviderType.OpenAI, | ||
| BaseUrl: "https://api.openai.com", | ||
| ApiKey: "test-key"); | ||
|
|
||
| // Assert | ||
| Assert.IsNull(options.Model); | ||
| Assert.AreEqual(EmbeddingsOptions.DEFAULT_OPENAI_MODEL, options.EffectiveModel); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Tests that EffectiveModel returns null for Azure OpenAI when model not specified. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void EmbeddingsOptions_AzureOpenAI_NoDefaultModel() | ||
| { | ||
| // Arrange | ||
| EmbeddingsOptions options = new( | ||
| Provider: EmbeddingProviderType.AzureOpenAI, | ||
| BaseUrl: "https://my.openai.azure.com", | ||
| ApiKey: "test-key"); | ||
|
|
||
| // Assert | ||
| Assert.IsNull(options.Model); | ||
| Assert.IsNull(options.EffectiveModel); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Tests that EffectiveTimeoutMs returns the default timeout when not specified. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void EmbeddingsOptions_DefaultTimeout() | ||
| { | ||
| // Arrange | ||
| EmbeddingsOptions options = new( | ||
| Provider: EmbeddingProviderType.OpenAI, | ||
| BaseUrl: "https://api.openai.com", | ||
| ApiKey: "test-key"); | ||
|
|
||
| // Assert | ||
| Assert.IsNull(options.TimeoutMs); | ||
| Assert.AreEqual(EmbeddingsOptions.DEFAULT_TIMEOUT_MS, options.EffectiveTimeoutMs); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Tests that custom timeout is used when specified. | ||
| /// </summary> | ||
| [TestMethod] | ||
| public void EmbeddingsOptions_CustomTimeout() | ||
| { | ||
| // Arrange | ||
| int customTimeout = 60000; | ||
| EmbeddingsOptions options = new( | ||
| Provider: EmbeddingProviderType.OpenAI, | ||
| BaseUrl: "https://api.openai.com", | ||
| ApiKey: "test-key", | ||
| TimeoutMs: customTimeout); | ||
|
|
||
| // Assert | ||
| Assert.AreEqual(customTimeout, options.TimeoutMs); | ||
| Assert.AreEqual(customTimeout, options.EffectiveTimeoutMs); | ||
| Assert.IsTrue(options.UserProvidedTimeoutMs); | ||
| } | ||
|
|
||
| #region Helper Methods | ||
|
|
||
| private static EmbeddingsOptions CreateAzureOpenAIOptions() | ||
| { | ||
| return new EmbeddingsOptions( | ||
| Provider: EmbeddingProviderType.AzureOpenAI, | ||
| BaseUrl: "https://test.openai.azure.com", | ||
| ApiKey: "test-api-key", | ||
| Model: "text-embedding-ada-002"); | ||
| } | ||
|
|
||
| private static EmbeddingsOptions CreateOpenAIOptions() | ||
| { | ||
| return new EmbeddingsOptions( | ||
| Provider: EmbeddingProviderType.OpenAI, | ||
| BaseUrl: "https://api.openai.com", | ||
| ApiKey: "test-api-key"); | ||
| } | ||
|
|
||
| #endregion | ||
| } |
There was a problem hiding this comment.
The unit tests for EmbeddingService do not include tests for the actual embedding API call logic. The tests only cover:
- IsEnabled property checks
- Disabled service behavior
- Null/empty text validation
- Configuration defaults
Missing test coverage for:
- Successful API calls (mocking HttpClient responses)
- Error handling for HTTP failures
- Cache hit/miss scenarios
- Batch embedding operations
- Response parsing and validation
- Timeout handling
- Provider-specific URL construction
- Request body building
Consider adding tests that mock HttpMessageHandler to verify the service correctly handles API responses, errors, and edge cases.
| public class EmbeddingController : ControllerBase | ||
| { | ||
| private readonly IEmbeddingService? _embeddingService; | ||
| private readonly RuntimeConfigProvider _runtimeConfigProvider; | ||
| private readonly ILogger<EmbeddingController> _logger; | ||
|
|
||
| /// <summary> | ||
| /// Constructor. | ||
| /// </summary> | ||
| public EmbeddingController( | ||
| RuntimeConfigProvider runtimeConfigProvider, | ||
| ILogger<EmbeddingController> logger, | ||
| IEmbeddingService? embeddingService = null) | ||
| { | ||
| _runtimeConfigProvider = runtimeConfigProvider; | ||
| _logger = logger; | ||
| _embeddingService = embeddingService; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// POST endpoint for generating embeddings. | ||
| /// Accepts plain text body and returns embedding vector as comma-separated floats. | ||
| /// </summary> | ||
| /// <param name="route">The route path after the "embed" prefix.</param> | ||
| /// <returns>Plain text embedding vector or error response.</returns> | ||
| [HttpPost] | ||
| [Route("embed/{*route}")] | ||
| [Consumes("text/plain", "application/json")] | ||
| [Produces("text/plain")] | ||
| public async Task<IActionResult> PostAsync(string? route) | ||
| { | ||
| // Get embeddings configuration | ||
| EmbeddingsOptions? embeddingsOptions = _runtimeConfigProvider.GetConfig()?.Runtime?.Embeddings; | ||
| EmbeddingsEndpointOptions? endpointOptions = embeddingsOptions?.Endpoint; | ||
|
|
||
| // Check if embeddings and endpoint are enabled | ||
| if (embeddingsOptions is null || !embeddingsOptions.Enabled) | ||
| { | ||
| return NotFound(); | ||
| } | ||
|
|
||
| if (endpointOptions is null || !endpointOptions.Enabled) | ||
| { | ||
| return NotFound(); | ||
| } | ||
|
|
||
| // Check if the full request path matches the configured endpoint path. | ||
| // Use Request.Path for comparison since the route prefix "embed" is already | ||
| // consumed by the route template and not included in the route parameter. | ||
| string expectedPath = endpointOptions.EffectivePath; | ||
| if (!expectedPath.StartsWith('/')) | ||
| { | ||
| expectedPath = "/" + expectedPath; | ||
| } | ||
|
|
||
| if (!string.Equals(Request.Path.Value, expectedPath, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| return NotFound(); | ||
| } | ||
|
|
||
| // Check if embedding service is available | ||
| if (_embeddingService is null || !_embeddingService.IsEnabled) | ||
| { | ||
| _logger.LogWarning("Embedding endpoint called but embedding service is not available or disabled."); | ||
| return StatusCode((int)HttpStatusCode.ServiceUnavailable, "Embedding service is not available."); | ||
| } | ||
|
|
||
| // Check authorization | ||
| bool isDevelopmentMode = _runtimeConfigProvider.GetConfig()?.Runtime?.Host?.Mode == HostMode.Development; | ||
| string clientRole = GetClientRole(); | ||
|
|
||
| if (!endpointOptions.IsRoleAllowed(clientRole, isDevelopmentMode)) | ||
| { | ||
| _logger.LogWarning("Embedding endpoint access denied for role: {Role}", clientRole); | ||
| return StatusCode((int)HttpStatusCode.Forbidden, "Access denied. Role not authorized."); | ||
| } | ||
|
|
||
| // Read request body as plain text | ||
| string text; | ||
| try | ||
| { | ||
| using StreamReader reader = new(Request.Body); | ||
| text = await reader.ReadToEndAsync(); | ||
|
|
||
| // Handle JSON-wrapped string | ||
| if (Request.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true) | ||
| { | ||
| try | ||
| { | ||
| text = JsonSerializer.Deserialize<string>(text) ?? text; | ||
| } | ||
| catch (JsonException) | ||
| { | ||
| // Not valid JSON string, use as-is | ||
| _logger.LogDebug("Request body is not a valid JSON string, using as plain text."); | ||
| } | ||
| } | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Failed to read request body for embedding."); | ||
| return BadRequest("Failed to read request body."); | ||
| } | ||
|
|
||
| if (string.IsNullOrWhiteSpace(text)) | ||
| { | ||
| return BadRequest("Request body cannot be empty."); | ||
| } | ||
|
|
||
| // Generate embedding | ||
| EmbeddingResult result = await _embeddingService.TryEmbedAsync(text); | ||
|
|
||
| if (!result.Success) | ||
| { | ||
| _logger.LogError("Embedding request failed: {Error}", result.ErrorMessage); | ||
| return StatusCode((int)HttpStatusCode.InternalServerError, result.ErrorMessage ?? "Failed to generate embedding."); | ||
| } | ||
|
|
||
| if (result.Embedding is null || result.Embedding.Length == 0) | ||
| { | ||
| _logger.LogError("Embedding request returned empty result."); | ||
| return StatusCode((int)HttpStatusCode.InternalServerError, "Failed to generate embedding."); | ||
| } | ||
|
|
||
| // Return embedding as comma-separated float values (plain text) | ||
| string embeddingText = string.Join(",", result.Embedding.Select(f => f.ToString("G", CultureInfo.InvariantCulture))); | ||
| return Content(embeddingText, MediaTypeNames.Text.Plain); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Gets the client role from request headers. | ||
| /// </summary> | ||
| private string GetClientRole() | ||
| { | ||
| StringValues roleHeader = Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]; | ||
| string? firstRole = roleHeader.Count == 1 ? roleHeader[0] : null; | ||
|
|
||
| if (!string.IsNullOrEmpty(firstRole)) | ||
| { | ||
| return firstRole.ToLowerInvariant(); | ||
| } | ||
|
|
||
| return EmbeddingsEndpointOptions.ANONYMOUS_ROLE; | ||
| } | ||
| } |
There was a problem hiding this comment.
No tests found for EmbeddingController. The controller includes significant logic that should be tested:
- Route matching and path validation
- Authorization checks (role-based access control)
- Development vs production mode behavior
- Request body parsing (plain text and JSON)
- Empty request body validation
- Service availability checks
- Error response handling
- Integration with IEmbeddingService
Add unit tests or integration tests for the EmbeddingController to ensure the endpoint behaves correctly under various scenarios.
| private async Task UpdateEmbeddingsHealthCheckResultsAsync(ComprehensiveHealthCheckReport comprehensiveHealthCheckReport, RuntimeConfig runtimeConfig) | ||
| { | ||
| EmbeddingsOptions? embeddingsOptions = runtimeConfig?.Runtime?.Embeddings; | ||
| EmbeddingsHealthCheckConfig? healthConfig = embeddingsOptions?.Health; | ||
|
|
||
| // Only run health check if embeddings is enabled, health check is enabled, and embedding service is available | ||
| if (embeddingsOptions is null || !embeddingsOptions.Enabled || healthConfig is null || !healthConfig.Enabled || _embeddingService is null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| if (comprehensiveHealthCheckReport.Checks is null) | ||
| { | ||
| comprehensiveHealthCheckReport.Checks = new List<HealthCheckResultEntry>(); | ||
| } | ||
|
|
||
| string testText = healthConfig.TestText; | ||
| int thresholdMs = healthConfig.ThresholdMs; | ||
| int? expectedDimensions = healthConfig.ExpectedDimensions; | ||
|
|
||
| try | ||
| { | ||
| Stopwatch stopwatch = new(); | ||
| stopwatch.Start(); | ||
| EmbeddingResult result = await _embeddingService.TryEmbedAsync(testText); | ||
| stopwatch.Stop(); | ||
|
|
||
| int responseTimeMs = (int)stopwatch.ElapsedMilliseconds; | ||
| bool isResponseTimeWithinThreshold = responseTimeMs <= thresholdMs; | ||
| bool isDimensionsValid = true; | ||
| string? errorMessage = null; | ||
|
|
||
| if (!result.Success) | ||
| { | ||
| errorMessage = result.ErrorMessage ?? "Embedding request failed."; | ||
| comprehensiveHealthCheckReport.Checks.Add(new HealthCheckResultEntry | ||
| { | ||
| Name = "embeddings", | ||
| ResponseTimeData = new ResponseTimeData | ||
| { | ||
| ResponseTimeMs = HealthCheckConstants.ERROR_RESPONSE_TIME_MS, | ||
| ThresholdMs = thresholdMs | ||
| }, | ||
| Exception = errorMessage, | ||
| Tags = new List<string> { HealthCheckConstants.EMBEDDING }, | ||
| Status = HealthStatus.Unhealthy | ||
| }); | ||
| return; | ||
| } | ||
|
|
||
| // Validate dimensions if expected dimensions is specified | ||
| if (expectedDimensions.HasValue && result.Embedding is not null) | ||
| { | ||
| isDimensionsValid = result.Embedding.Length == expectedDimensions.Value; | ||
| if (!isDimensionsValid) | ||
| { | ||
| errorMessage = $"{DIMENSIONS_MISMATCH_ERROR_MESSAGE} Expected: {expectedDimensions.Value}, Actual: {result.Embedding.Length}"; | ||
| } | ||
| } | ||
|
|
||
| // Check response time threshold | ||
| if (!isResponseTimeWithinThreshold) | ||
| { | ||
| errorMessage = errorMessage is null ? TIME_EXCEEDED_ERROR_MESSAGE : $"{errorMessage} {TIME_EXCEEDED_ERROR_MESSAGE}"; | ||
| } | ||
|
|
||
| bool isHealthy = isResponseTimeWithinThreshold && isDimensionsValid; | ||
|
|
||
| comprehensiveHealthCheckReport.Checks.Add(new HealthCheckResultEntry | ||
| { | ||
| Name = "embeddings", | ||
| ResponseTimeData = new ResponseTimeData | ||
| { | ||
| ResponseTimeMs = responseTimeMs, | ||
| ThresholdMs = thresholdMs | ||
| }, | ||
| Exception = errorMessage, | ||
| Tags = new List<string> { HealthCheckConstants.EMBEDDING }, | ||
| Status = isHealthy ? HealthStatus.Healthy : HealthStatus.Unhealthy | ||
| }); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _logger.LogError(ex, "Error executing embeddings health check."); | ||
| comprehensiveHealthCheckReport.Checks.Add(new HealthCheckResultEntry | ||
| { | ||
| Name = "embeddings", | ||
| ResponseTimeData = new ResponseTimeData | ||
| { | ||
| ResponseTimeMs = HealthCheckConstants.ERROR_RESPONSE_TIME_MS, | ||
| ThresholdMs = thresholdMs | ||
| }, | ||
| Exception = ex.Message, | ||
| Tags = new List<string> { HealthCheckConstants.EMBEDDING }, | ||
| Status = HealthStatus.Unhealthy | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
No tests found for UpdateEmbeddingsHealthCheckResultsAsync method in HealthCheckHelper. This method contains complex logic including:
- Conditional execution based on multiple configuration checks
- Embedding API calls with error handling
- Response time measurement and validation
- Dimensions validation
- Health status determination
- Error message construction
Add tests to verify the health check correctly:
- Reports healthy status when embedding succeeds within threshold
- Reports unhealthy status when response time exceeds threshold
- Reports unhealthy status when dimensions don't match expected
- Reports unhealthy status when embedding service fails
- Handles exceptions gracefully
- Skips execution when embeddings or health checks are disabled
| private string CreateCacheKey(string text) | ||
| { | ||
| // Include provider and model in hash to avoid cross-provider/model collisions | ||
| string keyInput = $"{_options.Provider}:{_options.EffectiveModel}:{text}"; | ||
| byte[] textBytes = Encoding.UTF8.GetBytes(keyInput); | ||
| byte[] hashBytes = SHA256.HashData(textBytes); | ||
| string hashHex = Convert.ToHexString(hashBytes); | ||
|
|
||
| StringBuilder cacheKeyBuilder = new(); | ||
| cacheKeyBuilder.Append(CACHE_KEY_PREFIX); | ||
| cacheKeyBuilder.Append(KEY_DELIMITER); | ||
| cacheKeyBuilder.Append(hashHex); | ||
|
|
||
| return cacheKeyBuilder.ToString(); | ||
| } |
There was a problem hiding this comment.
The cache key generation does not include the dimensions parameter, which can affect the embedding output. If a user changes the dimensions configuration for the same provider/model/text combination, they will get the cached embedding with the old dimensions instead of generating a new one with the updated dimensions.
Consider including the dimensions value (if user-provided) in the cache key to ensure cache keys are unique per configuration: $"{_options.Provider}:{_options.EffectiveModel}:{_options.Dimensions}:{text}"
| [Route("embed/{*route}")] | ||
| [Consumes("text/plain", "application/json")] | ||
| [Produces("text/plain")] | ||
| public async Task<IActionResult> PostAsync(string? route) | ||
| { | ||
| // Get embeddings configuration | ||
| EmbeddingsOptions? embeddingsOptions = _runtimeConfigProvider.GetConfig()?.Runtime?.Embeddings; | ||
| EmbeddingsEndpointOptions? endpointOptions = embeddingsOptions?.Endpoint; | ||
|
|
||
| // Check if embeddings and endpoint are enabled | ||
| if (embeddingsOptions is null || !embeddingsOptions.Enabled) | ||
| { | ||
| return NotFound(); | ||
| } | ||
|
|
||
| if (endpointOptions is null || !endpointOptions.Enabled) | ||
| { | ||
| return NotFound(); | ||
| } | ||
|
|
||
| // Check if the full request path matches the configured endpoint path. | ||
| // Use Request.Path for comparison since the route prefix "embed" is already | ||
| // consumed by the route template and not included in the route parameter. | ||
| string expectedPath = endpointOptions.EffectivePath; | ||
| if (!expectedPath.StartsWith('/')) | ||
| { | ||
| expectedPath = "/" + expectedPath; | ||
| } | ||
|
|
||
| if (!string.Equals(Request.Path.Value, expectedPath, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| return NotFound(); | ||
| } |
There was a problem hiding this comment.
The controller uses a catch-all route pattern [Route("embed/{*route}")] but then manually validates the full request path matches the configured endpoint path. This approach has a potential issue:
If the configured path is NOT /embed (e.g., /vectorize), the route won't match at all because the route template is hardcoded to embed/{*route}. The controller will never be invoked for paths like /vectorize.
The comment on line 26 mentions using "embed" prefix to avoid conflicts, but this creates a mismatch between the route template and the configurable endpoint path. Either:
- Make the route template dynamic based on configuration, or
- Document that the endpoint path MUST start with
/embedregardless of configuration, or - Use a different routing approach that can handle arbitrary paths
This is a fundamental design issue that prevents the endpoint.path configuration from working as intended.
| using StreamReader reader = new(Request.Body); | ||
| text = await reader.ReadToEndAsync(); |
There was a problem hiding this comment.
The controller reads the entire request body into memory without any size limit using ReadToEndAsync(). This creates a potential denial-of-service (DoS) vulnerability where an attacker could send extremely large text payloads to consume server memory.
Add a maximum request body size limit check before reading the body. Consider using Request.ContentLength to validate the size, or use a streaming approach with a limited buffer size. A reasonable limit might be a few MB, depending on the expected use case for embedding requests.
| private string GetClientRole() | ||
| { | ||
| StringValues roleHeader = Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER]; | ||
| string? firstRole = roleHeader.Count == 1 ? roleHeader[0] : null; | ||
|
|
||
| if (!string.IsNullOrEmpty(firstRole)) | ||
| { | ||
| return firstRole.ToLowerInvariant(); | ||
| } | ||
|
|
||
| return EmbeddingsEndpointOptions.ANONYMOUS_ROLE; | ||
| } |
There was a problem hiding this comment.
The GetClientRole method only returns the first role when exactly one role header is present (roleHeader.Count == 1). If a client sends multiple X-MS-API-ROLE headers, the method will return "anonymous" instead of checking any of the provided roles.
This behavior differs from typical role-based authorization where any of the user's roles should be checked against the allowed roles. Consider either:
- Checking all provided roles against the allowed roles (any match grants access), or
- Document that only single-role headers are supported and reject requests with multiple role headers
The current implementation silently treats multi-role headers as anonymous, which may be unexpected.
| RuntimeCacheOptions? Cache = null, | ||
| PaginationOptions? Pagination = null, | ||
| RuntimeHealthCheckConfig? Health = null, | ||
| EmbeddingsOptions? Embeddings = null) |
There was a problem hiding this comment.
Syntax error: Missing comma at the end of line 35 after the Embeddings parameter. This will cause a compilation error. The Embeddings parameter should end with a comma before the Compression parameter.
| EmbeddingsOptions? Embeddings = null) | |
| EmbeddingsOptions? Embeddings = null, |
JerryNixon
left a comment
There was a problem hiding this comment.
A lot of good work here!
| /// <param name="route">The route path after the "embed" prefix.</param> | ||
| /// <returns>Plain text embedding vector or error response.</returns> | ||
| [HttpPost] | ||
| [Route("embed/{*route}")] |
There was a problem hiding this comment.
The route embed/{*route} tells ASP.NET “send any request that starts with /embed/ here, and capture everything after it,” but the code then does a strict check that Request.Path must exactly equal endpointOptions.EffectivePath, which means most /embed/... requests will be rejected unless they match one exact configured path; this makes the {*route} catch-all effectively useless and makes configuration easy to get wrong because the real routing rules are split between the attribute and a manual runtime check, pick one approach: either make routing match the configured path using endpoint routing (and remove the manual path equality check), or keep a fixed /embed/... route and use the {*route} value consistently instead of comparing full paths.
|
|
||
| // Return embedding as comma-separated float values (plain text) | ||
| string embeddingText = string.Join(",", result.Embedding.Select(f => f.ToString("G", CultureInfo.InvariantCulture))); | ||
| return Content(embeddingText, MediaTypeNames.Text.Plain); |
There was a problem hiding this comment.
Returning the embedding as a comma-separated text/plain string forces every client to implement brittle parsing (split + float parsing + edge cases) and makes it hard to extend the response later (dimensions, model, cache hit, etc.) without inventing a new format, so it would be more robust to return JSON like { "embedding": [0.1, 0.2, ...], "dimensions": 1536 } (and only keep text/plain as an optional format if you truly need it).
| // Dependencies | ||
| private ILogger<HealthCheckHelper> _logger; | ||
| private HttpUtilities _httpUtility; | ||
| private IEmbeddingService? _embeddingService; |
There was a problem hiding this comment.
HealthCheckHelper only runs embeddings-specific health logic if it actually receives an IEmbeddingService, but because that dependency is optional (= null) it’s easy for the helper to be constructed in a way that never passes it (e.g., created manually rather than by DI), which would silently skip the embeddings health check; to avoid “health check code that never runs,” make sure HealthCheckHelper is created by the DI container so IEmbeddingService gets injected when available (or explicitly resolve it from IServiceProvider).
| "enabled": { | ||
| "type": "boolean", | ||
| "description": "Whether the embedding service is enabled. Defaults to true.", | ||
| "default": true |
There was a problem hiding this comment.
This may be a change in what we originally discussed but embedding enabled needs to default to false to minimize the surface area exposed. With constraints that if this is true then the other settings are required will make this default necessary. Anyway, default should be false.
Why make this change?
Internal DAB system for text embedding/vectorization to support future parameter substitution and Redis semantic search features.
What is this change?
Configuration (runtime.embeddings)
enabled: Master toggle (default: true)
provider: azure-openai | openai
base-url, api-key, model: Provider connection (supports @env())
api-version, dimensions, timeout-ms: Optional tuning
endpoint.enabled/path/roles: Optional REST endpoint at configured path (default: /embed)
health.enabled/threshold-ms/test-text/expected-dimensions: Health check config
Core Components
IEmbeddingService with TryEmbedAsync() pattern - returns result objects, no exceptions
EmbeddingService - HTTP client with FusionCache L1 (24h TTL, SHA256 hash keys with provider/model included)
EmbeddingsOptionsConverterFactory - Custom JSON deserializer with env var replacement
EmbeddingTelemetryHelper - OpenTelemetry metrics/spans for latency, cache hits, dimensions
EmbeddingController - REST endpoint for /embed with role-based authorization
Health Check Implementation ✅
HealthCheckHelper.UpdateEmbeddingsHealthCheckResultsAsync() - Executes test embedding with threshold validation
Validates response time against health.threshold-ms
Validates dimensions against health.expected-dimensions if specified
Reports Healthy/Unhealthy status in comprehensive health check report
ConfigurationDetails includes Embeddings and EmbeddingsEndpoint status
REST Endpoint Implementation ✅
EmbeddingController serves POST requests at configured path (default: /embed)
Accepts plain text input and returns comma-separated float values (plain text)
Role-based authorization using X-MS-API-ROLE header
In development mode, defaults to anonymous access
In production mode, requires explicit role configuration
Validation & Safety
Constructor validates Azure OpenAI requires model/deployment name
Constructor validates required fields (BaseUrl, ApiKey)
Cache keys include provider and model to prevent collisions
Validates non-empty embedding arrays from API responses
Telemetry Integration
TryEmbedAsync and TryEmbedBatchAsync instrumented with activity spans and metrics
Cache hit/miss tracking in batch operations
API call duration and error tracking
Integration Points
Health check report includes embeddings status in comprehensive checks
Hot reload via EMBEDDINGS_CONFIG_CHANGED event
Startup logging when embeddings configured
CLI: dab configure --runtime.embeddings.*
Code Organization
Azure.DataApiBuilder.Config.ObjectModel.Embeddings - Config models
Azure.DataApiBuilder.Core.Services.Embeddings - Service, telemetry, interface
Azure.DataApiBuilder.Service.Controllers - EmbeddingController
How was this tested?
Sample Request(s)
{
"runtime": {
"embeddings": {
"enabled": true,
"provider": "azure-openai",
"base-url": "@env('EMBEDDINGS_ENDPOINT')",
"api-key": "@env('EMBEDDINGS_API_KEY')",
"model": "text-embedding-ada-002",
"endpoint": {
"enabled": true,
"path": "/embed",
"roles": ["authenticated"]
},
"health": {
"enabled": true,
"threshold-ms": 5000,
"test-text": "health check"
}
}
}
}
Embed Endpoint:
curl -X POST http://localhost:5000/embed
-H "Content-Type: text/plain"
-H "X-MS-API-ROLE: authenticated"
-d "Hello, world!"
Response:
0.123456,0.234567,0.345678,...