Skip to content
Merged
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 @@ -160,6 +160,76 @@ await client.GetResponseAsync(new AIChatRequest
record.Succeeded.Should().BeTrue();
}

[Fact]
public void ProductionOptionValidators_WhenOptionsAreInvalid_ShouldReturnExpectedFailures()
{
var invalidLogSanitizer = new AILogSanitizerOptions
{
RedactionText = string.Empty,
};
invalidLogSanitizer.SensitiveFields.Clear();
invalidLogSanitizer.SensitiveFields.Add(string.Empty);

var rateResult = new AIRateLimitingOptionsValidator().Validate(null, new AIRateLimitingOptions
{
RequestsPerWindow = 0,
WindowSeconds = 0,
});
var circuitResult = new AICircuitBreakerOptionsValidator().Validate(null, new AICircuitBreakerOptions
{
FailureThreshold = 0,
BreakDurationSeconds = 0,
});
var logResult = new AILogSanitizerOptionsValidator().Validate(null, invalidLogSanitizer);
var secretResult = new AISecretProviderOptionsValidator().Validate(null, new AISecretProviderOptions
{
EnvironmentVariablePrefix = string.Empty,
});
var payloadResult = new AIPayloadLimitOptionsValidator().Validate(null, new AIPayloadLimitOptions
{
MaxChatCharacters = 0,
MaxStreamingChatCharacters = 0,
MaxTextCharacters = 0,
MaxAudioBytes = 0,
MaxImagePromptCharacters = 0,
MaxImageBytes = 0,
MaxImageMaskBytes = 0,
});

rateResult.Failures.Should().HaveCount(2);
circuitResult.Failures.Should().HaveCount(2);
logResult.Failures.Should().HaveCount(2);
secretResult.Failures.Should().ContainSingle("AI:SecretProvider:EnvironmentVariablePrefix is required.");
payloadResult.Failures.Should().HaveCount(7);
}

[Fact]
public void ProductionOptionValidators_WhenOptionsAreValid_ShouldSucceed()
{
var rateResult = new AIRateLimitingOptionsValidator().Validate(null, new AIRateLimitingOptions
{
RequestsPerWindow = 1,
WindowSeconds = 30,
});
var circuitResult = new AICircuitBreakerOptionsValidator().Validate(null, new AICircuitBreakerOptions
{
FailureThreshold = 1,
BreakDurationSeconds = 30,
});
var logResult = new AILogSanitizerOptionsValidator().Validate(null, new AILogSanitizerOptions());
var secretResult = new AISecretProviderOptionsValidator().Validate(null, new AISecretProviderOptions
{
EnvironmentVariablePrefix = "AI__PROVIDERS__",
});
var payloadResult = new AIPayloadLimitOptionsValidator().Validate(null, new AIPayloadLimitOptions());

rateResult.Succeeded.Should().BeTrue();
circuitResult.Succeeded.Should().BeTrue();
logResult.Succeeded.Should().BeTrue();
secretResult.Succeeded.Should().BeTrue();
payloadResult.Succeeded.Should().BeTrue();
}

private static IConfigurationRoot CreateConfiguration(IDictionary<string, string?>? overrides = null)
{
var values = new Dictionary<string, string?>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,144 @@ public void ResolveImageGenerationModel_ShouldPreferExplicitCount_AndRetainConfi
resolved.MaxRetryAttempts.Should().Be(4);
}

[Fact]
public void ResolveTtsModel_WhenOnlyExplicitModelIsProvided_ShouldFallbackToDefaultProviderSettings()
{
var resolver = new DefaultAIModelResolver(Options.Create(CreateOptions()));

var resolved = resolver.ResolveTtsModel(new AITtsRequest
{
Input = "hello",
Model = "gpt-4o-mini-tts",
});

resolved.Provider.Should().Be("OpenAI");
resolved.Model.Should().Be("gpt-4o-mini-tts");
resolved.Scenario.Should().Be("Default");
resolved.Timeout.Should().Be(TimeSpan.FromSeconds(100));
resolved.MaxRetryAttempts.Should().Be(2);
}

[Fact]
public void ResolveTtsModel_WhenProviderOverrideHasNoModel_ShouldThrowConfigurationException()
{
var resolver = new DefaultAIModelResolver(Options.Create(CreateOptions()));

Action action = () => resolver.ResolveTtsModel(new AITtsRequest
{
Input = "hello",
Provider = "OpenAI",
});

action.Should().Throw<AIConfigurationException>()
.WithMessage("*must specify a model when provider override is used*");
}

[Fact]
public void ResolveAsrModel_WhenNoScenarioAndNoDefaultConfigured_ShouldThrowConfigurationException()
{
var options = CreateOptions();
options.Models.Asr.Clear();
var resolver = new DefaultAIModelResolver(Options.Create(options));

Action action = () => resolver.ResolveAsrModel(new AIAsrRequest
{
Audio = new AIBinaryContent
{
Content = [1, 2, 3],
FileName = "sample.wav",
ContentType = "audio/wav",
},
});

action.Should().Throw<AIConfigurationException>()
.WithMessage("*no scenario-specific or default model is configured*");
}

[Fact]
public void ResolveImageEditModel_WhenScenarioMatchesCaseInsensitive_ShouldUseScenarioConfiguration()
{
var options = CreateOptions();
options.Models.ImageEdit.Add("Marketing", new AIImageModelOptions
{
Provider = "Qwen",
Model = "qwen-image-edit",
Count = 2,
Size = "512x512",
Quality = "hd",
ResponseFormat = "url",
TimeoutSeconds = 55,
MaxRetryAttempts = 4,
});

var resolver = new DefaultAIModelResolver(Options.Create(options));

var resolved = resolver.ResolveImageEditModel(new AIImageEditRequest
{
Scenario = "marketing",
Prompt = "clean up background",
Image = new AIBinaryContent
{
Content = [1, 2, 3],
FileName = "image.png",
ContentType = "image/png",
},
});

resolved.Provider.Should().Be("Qwen");
resolved.Model.Should().Be("qwen-image-edit");
resolved.Scenario.Should().Be("Marketing");
resolved.Count.Should().Be(2);
resolved.Size.Should().Be("512x512");
resolved.Quality.Should().Be("hd");
resolved.ResponseFormat.Should().Be("url");
resolved.Timeout.Should().Be(TimeSpan.FromSeconds(55));
resolved.MaxRetryAttempts.Should().Be(4);
}

[Fact]
public void ResolveImageGenerationModel_WhenProviderCannotBeDetermined_ShouldThrowConfigurationException()
{
var options = CreateOptions();
options.DefaultProvider = null;
options.Models.ImageGeneration.Add("Default", new AIImageModelOptions
{
Provider = null!,
Model = "gpt-image-1",
});

var resolver = new DefaultAIModelResolver(Options.Create(options));

Action action = () => resolver.ResolveImageGenerationModel(new AIImageGenerationRequest
{
Prompt = "draw a cat",
});

action.Should().Throw<AIConfigurationException>()
.WithMessage("*no provider could be determined*");
}

[Fact]
public void ResolveImageGenerationModel_WhenResolvedProviderIsNotConfigured_ShouldThrowConfigurationException()
{
var options = CreateOptions();
options.Models.ImageGeneration.Add("Default", new AIImageModelOptions
{
Provider = "Missing",
Model = "gpt-image-1",
});

var resolver = new DefaultAIModelResolver(Options.Create(options));

Action action = () => resolver.ResolveImageGenerationModel(new AIImageGenerationRequest
{
Prompt = "draw a cat",
});

action.Should().Throw<AIConfigurationException>()
.WithMessage("*provider 'Missing' is not configured*");
}

private static AIOptions CreateOptions()
{
var options = new AIOptions
Expand Down Expand Up @@ -221,4 +359,4 @@ private static AIOptions CreateOptions()

return options;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,56 @@ public async Task StreamAsync_ShouldParseSseDeltaEvents_AndFinalUsageChunk()
handler.Requests[0].Headers.Accept.Should().Contain(header => header.MediaType == "text/event-stream");
}

[Fact]
public void Validate_WhenOpenAIIsNotReferenced_ShouldSucceed()
{
var result = new OpenAIOptionsValidator().Validate(null, new AIOptions
{
DefaultProvider = "Qwen",
});

result.Succeeded.Should().BeTrue();
}

[Fact]
public void Validate_WhenOpenAIUsesFallbackBaseAddress_ShouldSucceed()
{
var options = new AIOptions
{
DefaultProvider = OpenAIProviderDefaults.ProviderName,
};
options.Providers.Add(OpenAIProviderDefaults.ProviderName, new AIProviderRegistrationOptions
{
ApiKey = "openai-key",
});

var result = new OpenAIOptionsValidator().Validate(null, options);

result.Succeeded.Should().BeTrue();
}

[Fact]
public void Validate_WhenOpenAIIsReferencedButInvalid_ShouldReturnFailures()
{
var options = new AIOptions
{
DefaultProvider = OpenAIProviderDefaults.ProviderName,
};
options.Providers.Add(OpenAIProviderDefaults.ProviderName, new AIProviderRegistrationOptions
{
Enabled = false,
ApiKey = string.Empty,
BaseAddress = "not-a-uri",
});

var result = new OpenAIOptionsValidator().Validate(null, options);

result.Failed.Should().BeTrue();
result.Failures.Should().Contain($"AI:Providers:{OpenAIProviderDefaults.ProviderName}:Enabled must be true when the provider is in use.");
result.Failures.Should().Contain($"AI:Providers:{OpenAIProviderDefaults.ProviderName}:ApiKey is required.");
result.Failures.Should().Contain($"AI:Providers:{OpenAIProviderDefaults.ProviderName}:BaseAddress must be a valid absolute URI.");
}

private static IAIChatProvider CreateProvider(SequenceHttpMessageHandler handler)
{
var options = new AIOptions();
Expand Down Expand Up @@ -264,4 +314,4 @@ private static HttpRequestMessage CloneRequest(HttpRequestMessage request)
return clone;
}
}
}
}
Loading