diff --git a/src/AI.Tests/OpenAITests.cs b/src/AI.Tests/OpenAITests.cs index 022b6a5..700d7fb 100644 --- a/src/AI.Tests/OpenAITests.cs +++ b/src/AI.Tests/OpenAITests.cs @@ -156,7 +156,7 @@ public async Task GPT5_ThinkingTime(ReasoningEffort effort) Assert.StartsWith("gpt-5", response.ModelId); Assert.DoesNotContain("nano", response.ModelId); - // Reasoning should have been set to medium + // Reasoning should have been set to expected value Assert.All(requests, x => { var search = Assert.IsType(x["reasoning"]); @@ -166,6 +166,48 @@ public async Task GPT5_ThinkingTime(ReasoningEffort effort) output.WriteLine($"Effort: {effort}, Time: {watch.ElapsedMilliseconds}ms, Tokens: {response.Usage?.TotalTokenCount}"); } + [SecretsTheory("OPENAI_API_KEY")] + [InlineData(Verbosity.Low)] + [InlineData(Verbosity.Medium)] + [InlineData(Verbosity.High)] + public async Task GPT5_Verbosity(Verbosity verbosity) + { + var messages = new Chat() + { + { "system", "You are an intelligent AI assistant that's an expert on everything." }, + { "user", "What's the answer to the universe and everything?" }, + }; + + var requests = new List(); + + var chat = new OpenAIChatClient(Configuration["OPENAI_API_KEY"]!, "gpt-5-nano", + OpenAIClientOptions.Observable(requests.Add).WriteTo(output)); + + var options = new ChatOptions + { + ModelId = "gpt-5-mini", + Verbosity = verbosity + }; + + var watch = System.Diagnostics.Stopwatch.StartNew(); + var response = await chat.GetResponseAsync(messages, options); + watch.Stop(); + + var text = response.Text; + output.WriteLine(text); + + Assert.StartsWith("gpt-5", response.ModelId); + Assert.DoesNotContain("nano", response.ModelId); + + // Verbosity should have been set to the expected value + Assert.All(requests, x => + { + var text = Assert.IsType(x["text"]); + Assert.Equal(verbosity.ToString().ToLowerInvariant(), text["verbosity"]?.GetValue()); + }); + + output.WriteLine($"Verbosity: {verbosity}, Time: {watch.ElapsedMilliseconds}ms, Tokens: {response.Usage?.TotalTokenCount}"); + } [SecretsFact("OPENAI_API_KEY")] public async Task WebSearchCountryHighContext() diff --git a/src/AI/ChatExtensions.cs b/src/AI/ChatExtensions.cs index 8c8a015..ae2f987 100644 --- a/src/AI/ChatExtensions.cs +++ b/src/AI/ChatExtensions.cs @@ -43,5 +43,25 @@ public ReasoningEffort? ReasoningEffort } } } + + /// + /// Sets the level for a GPT-5 model when generating responses, if supported + /// + public Verbosity? Verbosity + { + get => options.AdditionalProperties?.TryGetValue("verbosity", out var value) == true && value is Verbosity verbosity ? verbosity : null; + set + { + if (value is not null) + { + options.AdditionalProperties ??= []; + options.AdditionalProperties["verbosity"] = value; + } + else + { + options.AdditionalProperties?.Remove("verbosity"); + } + } + } } } \ No newline at end of file diff --git a/src/AI/OpenAI/OpenAIChatClient.cs b/src/AI/OpenAI/OpenAIChatClient.cs index a56f853..68d596a 100644 --- a/src/AI/OpenAI/OpenAIChatClient.cs +++ b/src/AI/OpenAI/OpenAIChatClient.cs @@ -47,22 +47,18 @@ IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model if (options is null) return null; - if (options.ReasoningEffort is ReasoningEffort effort) + if (options.ReasoningEffort.HasValue || options.Verbosity.HasValue) { - options.RawRepresentationFactory = _ => new ResponseCreationOptions + options.RawRepresentationFactory = _ => { - ReasoningOptions = new ReasoningEffortOptions(effort) - //ReasoningOptions = new ResponseReasoningOptions() - //{ - // ReasoningEffortLevel = effort switch - // { - // ReasoningEffort.High => ResponseReasoningEffortLevel.High, - // ReasoningEffort.Medium => ResponseReasoningEffortLevel.Medium, - // // TODO: not exposed yet in the OpenAI package - // // ReasoningEffort.Minimal => ResponseReasoningEffortLevel.Minimal, - // _ => ResponseReasoningEffortLevel.Low - // }, - //} + var creation = new ResponseCreationOptions(); + if (options.ReasoningEffort.HasValue) + creation.ReasoningOptions = new ReasoningEffortOptions(options.ReasoningEffort!.Value); + + if (options.Verbosity.HasValue) + creation.TextOptions = new VerbosityOptions(options.Verbosity!.Value); + + return creation; }; } @@ -85,4 +81,14 @@ protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWri base.JsonModelWriteCore(writer, options); } } + + class VerbosityOptions(Verbosity verbosity) : ResponseTextOptions + { + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WritePropertyName("verbosity"u8); + writer.WriteStringValue(verbosity.ToString().ToLowerInvariant()); + base.JsonModelWriteCore(writer, options); + } + } } diff --git a/src/AI/Verbosity.cs b/src/AI/Verbosity.cs new file mode 100644 index 0000000..275e619 --- /dev/null +++ b/src/AI/Verbosity.cs @@ -0,0 +1,13 @@ +namespace Devlooped.Extensions.AI; + +/// +/// Verbosity determines how many output tokens are generated for models that support it, such as GPT-5. +/// Lowering the number of tokens reduces overall latency. +/// +/// +public enum Verbosity +{ + Low, + Medium, + High +}