From 4a51ae2b20d667ef3a56e2306b15be03bebd3bf3 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Mon, 7 Jul 2025 12:11:47 -0300 Subject: [PATCH] Add support for OpenAI web search options and Grok compat * Add OpenAI/Grok subnamespace: helps organize functionality areas * Adds OpenAI WebSearchTool-specific extensions under that namespace: region, city, timezone, context size. * Cross-provider compatibility by interpreting WebSearchTool as GrokSearch(Auto) with a GrokWebSource(country). --- readme.md | 44 ++++++++++++ src/AI.Tests/GrokTests.cs | 8 ++- src/AI.Tests/OpenAITests.cs | 54 ++++++++++++++ src/AI.Tests/RetrievalTests.cs | 4 +- src/AI.Tests/ToolsTests.cs | 7 +- src/AI/Grok/GrokChatClient.cs | 18 +++-- src/AI/Grok/GrokChatOptions.cs | 2 +- src/AI/Grok/GrokClient.cs | 22 +++--- src/AI/Grok/GrokSearchTool.cs | 2 +- src/AI/OpenAI/OpenAIChatClient.cs | 2 +- src/AI/OpenAI/WebSearchToolExtensions.cs | 92 ++++++++++++++++++++++++ src/AI/WebSearchTool.cs | 46 ++++++++++++ 12 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 src/AI/OpenAI/WebSearchToolExtensions.cs create mode 100644 src/AI/WebSearchTool.cs diff --git a/readme.md b/readme.md index aaa3332..a6c26df 100644 --- a/readme.md +++ b/readme.md @@ -62,6 +62,25 @@ var options = new ChatOptions var response = await grok.GetResponseAsync(messages, options); ``` +We also provide an OpenAI-compatible `WebSearchTool` that can be used to restrict +the search to a specific country in a way that works with both Grok and OpenAI: + +```csharp +var options = new ChatOptions +{ + Tools = [new WebSearchTool("AR")] // 👈 search in Argentina +}; +``` + +This is equivalent to the following when used with a Grok client: +```csharp +var options = new ChatOptions +{ + // 👇 search in Argentina + Tools = [new GrokSearchTool(GrokSearch.On) { Country = "AR" }] +}; +``` + ### Advanced Live Search To configure advanced live search options, beyond the `On|Auto|Off` settings @@ -127,9 +146,34 @@ var options = new ChatOptions }; var response = await chat.GetResponseAsync(messages, options); +``` + +### Web Search +Similar to the Grok client, we provide the `WebSearchTool` to enable search customization +in OpenAI too: + +```csharp +var options = new ChatOptions +{ + // 👇 search in Argentina, Bariloche region + Tools = [new WebSearchTool("AR") + { + Region = "Bariloche", // 👈 Bariloche region + TimeZone = "America/Argentina/Buenos_Aires", // 👈 IANA timezone + ContextSize = WebSearchContextSize.High // 👈 high search context size + }] +}; ``` +> [!NOTE] +> This enables all features supported by the [Web search](https://platform.openai.com/docs/guides/tools-web-search) +> feature in OpenAI. + +If advanced search settings are not needed, you can use the built-in M.E.AI `HostedWebSearchTool` +instead, which is a more generic tool and provides the basics out of the box. + + ## Observing Request/Response The underlying HTTP pipeline provided by the Azure SDK allows setting up diff --git a/src/AI.Tests/GrokTests.cs b/src/AI.Tests/GrokTests.cs index 4e31061..9927099 100644 --- a/src/AI.Tests/GrokTests.cs +++ b/src/AI.Tests/GrokTests.cs @@ -1,6 +1,8 @@ using System.Text.Json.Nodes; +using Devlooped.Extensions.AI.Grok; using Microsoft.Extensions.AI; using static ConfigurationExtensions; +using OpenAIClientOptions = OpenAI.OpenAIClientOptions; namespace Devlooped.Extensions.AI; @@ -51,7 +53,7 @@ public async Task GrokInvokesToolAndSearch() var requests = new List(); var responses = new List(); - var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", OpenAI.OpenAIClientOptions + var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", OpenAIClientOptions .Observable(requests.Add, responses.Add) .WriteTo(output)) .AsBuilder() @@ -105,7 +107,7 @@ public async Task GrokInvokesHostedSearchTool() var requests = new List(); var responses = new List(); - var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", OpenAI.OpenAIClientOptions + var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", OpenAIClientOptions .Observable(requests.Add, responses.Add) .WriteTo(output)); @@ -185,7 +187,7 @@ public async Task GrokInvokesSpecificSearchUrl() var requests = new List(); var responses = new List(); - var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", OpenAI.OpenAIClientOptions + var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", OpenAIClientOptions .Observable(requests.Add, responses.Add) .WriteTo(output)); diff --git a/src/AI.Tests/OpenAITests.cs b/src/AI.Tests/OpenAITests.cs index 5b77edf..10bbc83 100644 --- a/src/AI.Tests/OpenAITests.cs +++ b/src/AI.Tests/OpenAITests.cs @@ -1,6 +1,8 @@ using System.Text.Json.Nodes; +using Devlooped.Extensions.AI.OpenAI; using Microsoft.Extensions.AI; using OpenAI; +using OpenAI.Responses; using static ConfigurationExtensions; namespace Devlooped.Extensions.AI; @@ -66,4 +68,56 @@ public async Task OpenAIThinks() Assert.Equal("medium", search["effort"]?.GetValue()); }); } + + [SecretsFact("OPENAI_API_KEY")] + public async Task WebSearchCountryHighContext() + { + var messages = new Chat() + { + { "system", "Sos un asistente del Cerro Catedral, usas la funcionalidad de Live Search en el sitio oficial." }, + { "system", $"Hoy es {DateTime.Now.ToString("o")}." }, + { "system", + """ + Web search sources: + https://catedralaltapatagonia.com/parte-de-nieve/ + https://catedralaltapatagonia.com/tarifas/ + https://catedralaltapatagonia.com/ + + DO NOT USE https://partediario.catedralaltapatagonia.com/partediario for web search, it's **OBSOLETE**. + """}, + { "user", "Cuanto cuesta el pase diario en el Catedral hoy?" }, + }; + + var requests = new List(); + var responses = new List(); + + var chat = new OpenAIChatClient(Configuration["OPENAI_API_KEY"]!, "gpt-4.1", + OpenAIClientOptions.Observable(requests.Add, responses.Add).WriteTo(output)); + + var options = new ChatOptions + { + Tools = [new WebSearchTool("AR") + { + Region = "Bariloche", + TimeZone = "America/Argentina/Buenos_Aires", + ContextSize = WebSearchContextSize.High + }] + }; + + var response = await chat.GetResponseAsync(messages, options); + var text = response.Text; + + var raw = Assert.IsType(response.RawRepresentation); + Assert.NotEmpty(raw.OutputItems.OfType()); + + var assistant = raw.OutputItems.OfType().Where(x => x.Role == MessageRole.Assistant).FirstOrDefault(); + Assert.NotNull(assistant); + + var content = Assert.Single(assistant.Content); + Assert.NotEmpty(content.OutputTextAnnotations); + Assert.Contains(content.OutputTextAnnotations, + x => x.Kind == ResponseMessageAnnotationKind.UriCitation && + x.UriCitationUri.StartsWith("https://catedralaltapatagonia.com/tarifas/")); + + } } diff --git a/src/AI.Tests/RetrievalTests.cs b/src/AI.Tests/RetrievalTests.cs index ab1537c..d5692d6 100644 --- a/src/AI.Tests/RetrievalTests.cs +++ b/src/AI.Tests/RetrievalTests.cs @@ -11,11 +11,11 @@ public class RetrievalTests(ITestOutputHelper output) [InlineData("gpt-4.1-nano", "What's the battery life in an iPhone 15?", true)] public async Task CanRetrieveContent(string model, string question, bool empty = false) { - var client = new OpenAI.OpenAIClient(Configuration["OPENAI_API_KEY"]); + var client = new global::OpenAI.OpenAIClient(Configuration["OPENAI_API_KEY"]); var store = client.GetVectorStoreClient().CreateVectorStore(true); try { - var file = client.GetOpenAIFileClient().UploadFile("Content/LNS0004592.md", OpenAI.Files.FileUploadPurpose.Assistants); + var file = client.GetOpenAIFileClient().UploadFile("Content/LNS0004592.md", global::OpenAI.Files.FileUploadPurpose.Assistants); try { client.GetVectorStoreClient().AddFileToVectorStore(store.VectorStoreId, file.Value.Id, true); diff --git a/src/AI.Tests/ToolsTests.cs b/src/AI.Tests/ToolsTests.cs index 135e6f3..c59ac08 100644 --- a/src/AI.Tests/ToolsTests.cs +++ b/src/AI.Tests/ToolsTests.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using Devlooped.Extensions.AI.OpenAI; using Microsoft.Extensions.AI; using static ConfigurationExtensions; @@ -18,7 +19,7 @@ public async Task RunToolResult() }; var client = new OpenAIChatClient(Configuration["OPENAI_API_KEY"]!, "gpt-4.1", - OpenAI.OpenAIClientOptions.WriteTo(output)) + global::OpenAI.OpenAIClientOptions.WriteTo(output)) .AsBuilder() .UseFunctionInvocation() .Build(); @@ -50,7 +51,7 @@ public async Task RunToolTerminateResult() }; var client = new OpenAIChatClient(Configuration["OPENAI_API_KEY"]!, "gpt-4.1", - OpenAI.OpenAIClientOptions.WriteTo(output)) + global::OpenAI.OpenAIClientOptions.WriteTo(output)) .AsBuilder() .UseFunctionInvocation() .Build(); @@ -82,7 +83,7 @@ public async Task RunToolExceptionOutcome() }; var client = new OpenAIChatClient(Configuration["OPENAI_API_KEY"]!, "gpt-4.1", - OpenAI.OpenAIClientOptions.WriteTo(output)) + global::OpenAI.OpenAIClientOptions.WriteTo(output)) .AsBuilder() .UseFunctionInvocation() .Build(); diff --git a/src/AI/Grok/GrokChatClient.cs b/src/AI/Grok/GrokChatClient.cs index 9f80a44..fa264b7 100644 --- a/src/AI/Grok/GrokChatClient.cs +++ b/src/AI/Grok/GrokChatClient.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.AI; using OpenAI; -namespace Devlooped.Extensions.AI; +namespace Devlooped.Extensions.AI.Grok; /// /// An implementation for Grok. @@ -64,6 +64,14 @@ IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model Mode = search.Value }; } + else if (tool is null && options.Tools?.OfType().FirstOrDefault() is { } web) + { + searchOptions = new GrokChatWebSearchOptions + { + Mode = GrokSearch.Auto, + Sources = [new GrokWebSource { Country = web.Country }] + }; + } else if (tool is null && options.Tools?.OfType().FirstOrDefault() is not null) { searchOptions = new GrokChatWebSearchOptions @@ -92,9 +100,9 @@ IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model { result.ReasoningEffortLevel = grok.ReasoningEffort switch { - ReasoningEffort.High => OpenAI.Chat.ChatReasoningEffortLevel.High, + ReasoningEffort.High => global::OpenAI.Chat.ChatReasoningEffortLevel.High, // Grok does not support Medium, so we map it to Low too - _ => OpenAI.Chat.ChatReasoningEffortLevel.Low, + _ => global::OpenAI.Chat.ChatReasoningEffortLevel.Low, }; } @@ -111,7 +119,7 @@ void IDisposable.Dispose() { } // Allows creating the base OpenAIClient with a pre-created pipeline. class PipelineClient(ClientPipeline pipeline, OpenAIClientOptions options) : OpenAIClient(pipeline, options) { } - class GrokChatWebSearchOptions : OpenAI.Chat.ChatWebSearchOptions + class GrokChatWebSearchOptions : global::OpenAI.Chat.ChatWebSearchOptions { public GrokSearch Mode { get; set; } = GrokSearch.Auto; public DateOnly? FromDate { get; set; } @@ -166,7 +174,7 @@ class LowercaseNamingPolicy : JsonNamingPolicy } } - class GrokCompletionOptions : OpenAI.Chat.ChatCompletionOptions + class GrokCompletionOptions : global::OpenAI.Chat.ChatCompletionOptions { protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions? options) { diff --git a/src/AI/Grok/GrokChatOptions.cs b/src/AI/Grok/GrokChatOptions.cs index 4d16f9d..b608abd 100644 --- a/src/AI/Grok/GrokChatOptions.cs +++ b/src/AI/Grok/GrokChatOptions.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.AI; -namespace Devlooped.Extensions.AI; +namespace Devlooped.Extensions.AI.Grok; /// /// Grok-specific chat options that extend the base diff --git a/src/AI/Grok/GrokClient.cs b/src/AI/Grok/GrokClient.cs index 7f53ed2..036147b 100644 --- a/src/AI/Grok/GrokClient.cs +++ b/src/AI/Grok/GrokClient.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.AI; using OpenAI; -namespace Devlooped.Extensions.AI; +namespace Devlooped.Extensions.AI.Grok; /// /// Provides an OpenAI compability client for Grok. It's recommended you @@ -26,7 +26,7 @@ public class GrokClient(string apiKey, OpenAIClientOptions? options = null) /// Returns an adapter that surfaces an interface that /// can be used directly in the pipeline builder. /// - public override OpenAI.Chat.ChatClient GetChatClient(string model) => new GrokChatClientAdapter(this, model); + public override global::OpenAI.Chat.ChatClient GetChatClient(string model) => new GrokChatClientAdapter(this, model); static OpenAIClientOptions EnsureEndpoint(OpenAIClientOptions? options) { @@ -39,7 +39,7 @@ static OpenAIClientOptions EnsureEndpoint(OpenAIClientOptions? options) // OpenAI in MEAI docs. Most typical case would be to just create an directly. // This throws on any non-IChatClient invoked methods in the AsIChatClient adapter, and // forwards the IChatClient methods to the GrokChatClient implementation which is cached per client. - class GrokChatClientAdapter(GrokClient client, string model) : OpenAI.Chat.ChatClient, IChatClient + class GrokChatClientAdapter(GrokClient client, string model) : global::OpenAI.Chat.ChatClient, IChatClient { void IDisposable.Dispose() { } @@ -60,10 +60,10 @@ IAsyncEnumerable IChatClient.GetStreamingResponseAsync(IEnum => client.GetChatClientImpl(options?.ModelId ?? model).GetStreamingResponseAsync(messages, options, cancellation); // These are the only two methods actually invoked by the AsIChatClient adapter from M.E.AI.OpenAI - public override Task> CompleteChatAsync(IEnumerable? messages, OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) + public override Task> CompleteChatAsync(IEnumerable? messages, global::OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)} instead of invoking {nameof(OpenAIClientExtensions.AsIChatClient)} on this instance."); - public override AsyncCollectionResult CompleteChatStreamingAsync(IEnumerable? messages, OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) + public override AsyncCollectionResult CompleteChatStreamingAsync(IEnumerable? messages, global::OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)} instead of invoking {nameof(OpenAIClientExtensions.AsIChatClient)} on this instance."); #region Unsupported @@ -71,25 +71,25 @@ IAsyncEnumerable IChatClient.GetStreamingResponseAsync(IEnum public override ClientResult CompleteChat(BinaryContent? content, RequestOptions? options = null) => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - public override ClientResult CompleteChat(IEnumerable? messages, OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) + public override ClientResult CompleteChat(IEnumerable? messages, global::OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - public override ClientResult CompleteChat(params OpenAI.Chat.ChatMessage[] messages) + public override ClientResult CompleteChat(params global::OpenAI.Chat.ChatMessage[] messages) => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); public override Task CompleteChatAsync(BinaryContent? content, RequestOptions? options = null) => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - public override Task> CompleteChatAsync(params OpenAI.Chat.ChatMessage[] messages) + public override Task> CompleteChatAsync(params global::OpenAI.Chat.ChatMessage[] messages) => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - public override CollectionResult CompleteChatStreaming(IEnumerable? messages, OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) + public override CollectionResult CompleteChatStreaming(IEnumerable? messages, global::OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default) => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - public override CollectionResult CompleteChatStreaming(params OpenAI.Chat.ChatMessage[] messages) + public override CollectionResult CompleteChatStreaming(params global::OpenAI.Chat.ChatMessage[] messages) => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); - public override AsyncCollectionResult CompleteChatStreamingAsync(params OpenAI.Chat.ChatMessage[] messages) + public override AsyncCollectionResult CompleteChatStreamingAsync(params global::OpenAI.Chat.ChatMessage[] messages) => throw new NotSupportedException($"Consume directly as an {nameof(IChatClient)}."); #endregion diff --git a/src/AI/Grok/GrokSearchTool.cs b/src/AI/Grok/GrokSearchTool.cs index b053254..7bad648 100644 --- a/src/AI/Grok/GrokSearchTool.cs +++ b/src/AI/Grok/GrokSearchTool.cs @@ -1,7 +1,7 @@ using System.Text.Json.Serialization; using Microsoft.Extensions.AI; -namespace Devlooped.Extensions.AI; +namespace Devlooped.Extensions.AI.Grok; /// /// Enables or disables Grok's live search capabilities. diff --git a/src/AI/OpenAI/OpenAIChatClient.cs b/src/AI/OpenAI/OpenAIChatClient.cs index e27b8e4..ad5a672 100644 --- a/src/AI/OpenAI/OpenAIChatClient.cs +++ b/src/AI/OpenAI/OpenAIChatClient.cs @@ -5,7 +5,7 @@ using OpenAI; using OpenAI.Responses; -namespace Devlooped.Extensions.AI; +namespace Devlooped.Extensions.AI.OpenAI; /// /// An implementation for OpenAI. diff --git a/src/AI/OpenAI/WebSearchToolExtensions.cs b/src/AI/OpenAI/WebSearchToolExtensions.cs new file mode 100644 index 0000000..eb5eb5e --- /dev/null +++ b/src/AI/OpenAI/WebSearchToolExtensions.cs @@ -0,0 +1,92 @@ +using OpenAI.Responses; + +namespace Devlooped.Extensions.AI.OpenAI; + +/// +/// Controls how much context is retrieved from the web to help the tool formulate a response. +/// +public enum WebSearchContextSize +{ + /// + /// Least context, lowest cost, fastest response, but potentially lower answer quality. + /// + Low, + /// + /// (default): Balanced context, cost, and latency. + /// + Medium, + /// + /// Most comprehensive context, highest cost, slower response. + /// + High +} + +public static class OpenAIWebSearchToolExtensions +{ + extension(WebSearchTool web) + { + /// + /// Optional free text additional information about the region to be used in the search. + /// + /// + public string? Region + { + get => web.Properties.TryGetValue("Region", out var region) ? (string?)region : null; + set + { + web.Properties["Region"] = value; + web.Location = WebSearchToolLocation.CreateApproximateLocation(web.Country, value, web.City, web.TimeZone); + } + } + + /// + /// Optional free text additional information about the city to be used in the search. + /// + /// + public string? City + { + get => web.Properties.TryGetValue("City", out var city) ? (string?)city : null; + set + { + web.Properties["City"] = value; + web.Location = WebSearchToolLocation.CreateApproximateLocation(web.Country, web.Region, value, web.TimeZone); + } + } + + /// + /// Optional IANA timezone name to be used in the search. + /// + /// + public string? TimeZone + { + get => web.Properties.TryGetValue("TimeZone", out var timeZone) ? (string?)timeZone : null; + set + { + web.Properties["TimeZone"] = value; + web.Location = WebSearchToolLocation.CreateApproximateLocation(web.Country, web.Region, web.City, value); + } + } + + /// + /// Controls how much context is retrieved from the web to help the tool formulate a response. + /// + public WebSearchContextSize? ContextSize + { + get => web.Properties.TryGetValue("ContextSize", out var size) && size is WebSearchContextSize contextSize + ? contextSize : null; + set + { + web.Properties["ContextSize"] = value; + if (value != null) + { + web.ContextSize = value.Value switch + { + WebSearchContextSize.Low => WebSearchToolContextSize.Low, + WebSearchContextSize.High => WebSearchToolContextSize.High, + _ => WebSearchToolContextSize.Medium + }; + } + } + } + } +} diff --git a/src/AI/WebSearchTool.cs b/src/AI/WebSearchTool.cs new file mode 100644 index 0000000..a84282f --- /dev/null +++ b/src/AI/WebSearchTool.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.AI; +using OpenAI.Responses; + +namespace Devlooped.Extensions.AI; + +/// +/// Basic web search tool that can limit the search to a specific country. +/// +public class WebSearchTool : HostedWebSearchTool +{ + Dictionary additionalProperties; + string? region; + + /// + /// Initializes a new instance of the class with the specified country. + /// + /// ISO alpha-2 country code. + public WebSearchTool(string country) + { + Country = country; + additionalProperties = new Dictionary + { + { nameof(WebSearchToolLocation), WebSearchToolLocation.CreateApproximateLocation(country) } + }; + } + + /// + /// Sets the user's country for web search results, using the ISO alpha-2 code. + /// + public string Country { get; } + + internal WebSearchToolLocation Location + { + set => additionalProperties[nameof(WebSearchToolLocation)] = value; + } + + internal WebSearchToolContextSize ContextSize + { + set => additionalProperties[nameof(WebSearchToolContextSize)] = value; + } + + internal IDictionary Properties => additionalProperties; + + /// + public override IReadOnlyDictionary AdditionalProperties => additionalProperties; +}