Skip to content

Commit 35ad864

Browse files
committed
Simplify approach to Grok chat client and dynamic clients
We want to provide the intuitive behavior of honoring the ChatOptions.ModelId, which the OpenAI client doesn't do. In order to achieve this, we implement the switching in the new GrokChatClient instead, and keep the GrokClient as a fallback for existing code or other non-chat scenarios where the same endpoints might work (but it's not guaranteed at all).
1 parent 76bdc92 commit 35ad864

File tree

7 files changed

+194
-103
lines changed

7 files changed

+194
-103
lines changed

src/AI.Tests/AI.Tests.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<Project Sdk="Microsoft.NET.Sdk">
22

33
<PropertyGroup>
4-
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
4+
<TargetFramework>net10.0</TargetFramework>
55
<NoWarn>OPENAI001;$(NoWarn)</NoWarn>
66
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
77
</PropertyGroup>

src/AI.Tests/GrokTests.cs

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,19 @@ public async Task GrokInvokesTools()
1616
{ "user", "What day is today?" },
1717
};
1818

19-
var grok = new GrokClient(Configuration["XAI_API_KEY"]!);
19+
var chat = new GrokChatClient(Configuration["XAI_API_KEY"]!);
2020

2121
var options = new GrokChatOptions
2222
{
2323
ModelId = "grok-3-mini",
2424
Search = GrokSearch.Auto,
25-
Tools = [AIFunctionFactory.Create(() => DateTimeOffset.Now.ToString("O"), "get_date")]
25+
Tools = [AIFunctionFactory.Create(() => DateTimeOffset.Now.ToString("O"), "get_date")],
26+
AdditionalProperties = new()
27+
{
28+
{ "foo", "bar" }
29+
}
2630
};
2731

28-
var client = grok.GetChatClient("grok-3");
29-
var chat = Assert.IsType<IChatClient>(client, false);
30-
3132
var response = await chat.GetResponseAsync(messages, options);
3233
var getdate = response.Messages
3334
.SelectMany(x => x.Contents.OfType<FunctionCallContent>())
@@ -50,9 +51,7 @@ public async Task GrokInvokesToolAndSearch()
5051

5152
var transport = new TestPipelineTransport(HttpClientPipelineTransport.Shared, output);
5253

53-
var grok = new GrokClient(Configuration["XAI_API_KEY"]!, new OpenAI.OpenAIClientOptions() { Transport = transport })
54-
.GetChatClient("grok-3")
55-
.AsIChatClient()
54+
var grok = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", new OpenAI.OpenAIClientOptions() { Transport = transport })
5655
.AsBuilder()
5756
.UseFunctionInvocation()
5857
.Build();
@@ -103,9 +102,7 @@ public async Task GrokInvokesHostedSearchTool()
103102

104103
var transport = new TestPipelineTransport(HttpClientPipelineTransport.Shared, output);
105104

106-
var grok = new GrokClient(Configuration["XAI_API_KEY"]!, new OpenAI.OpenAIClientOptions() { Transport = transport });
107-
var client = grok.GetChatClient("grok-3");
108-
var chat = Assert.IsType<IChatClient>(client, false);
105+
var chat = new GrokChatClient(Configuration["XAI_API_KEY"]!, "grok-3", new OpenAI.OpenAIClientOptions() { Transport = transport });
109106

110107
var options = new ChatOptions
111108
{

src/AI/ChatExtensions.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,28 @@ public static class ChatExtensions
2020
public Task<ChatResponse> GetResponseAsync(Chat chat, ChatOptions? options = null, CancellationToken cancellation = default)
2121
=> client.GetResponseAsync((IEnumerable<ChatMessage>)chat, options, cancellation);
2222
}
23+
24+
extension(ChatOptions options)
25+
{
26+
/// <summary>
27+
/// Sets the effort level for a reasoning AI model when generating responses, if supported
28+
/// by the model.
29+
/// </summary>
30+
public ReasoningEffort? ReasoningEffort
31+
{
32+
get => options.AdditionalProperties?.TryGetValue("reasoning_effort", out var value) == true && value is ReasoningEffort effort ? effort : null;
33+
set
34+
{
35+
if (value is not null)
36+
{
37+
options.AdditionalProperties ??= [];
38+
options.AdditionalProperties["reasoning_effort"] = value;
39+
}
40+
else
41+
{
42+
options.AdditionalProperties?.Remove("reasoning_effort");
43+
}
44+
}
45+
}
46+
}
2347
}

src/AI/Grok/GrokChatClient.cs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
using System.ClientModel;
2+
using System.ClientModel.Primitives;
3+
using System.Collections.Concurrent;
4+
using System.Text.Json;
5+
using Microsoft.Extensions.AI;
6+
using OpenAI;
7+
8+
namespace Devlooped.Extensions.AI;
9+
10+
/// <summary>
11+
/// An <see cref="IChatClient"/> implementation for Grok.
12+
/// </summary>
13+
public class GrokChatClient : IChatClient
14+
{
15+
readonly ConcurrentDictionary<string, IChatClient> clients = new();
16+
readonly string apiKey;
17+
readonly string modelId;
18+
readonly ClientPipeline pipeline;
19+
readonly OpenAIClientOptions options;
20+
21+
/// <summary>
22+
/// Initializes the client with the specified API key and the default model ID "grok-3-mini".
23+
/// </summary>
24+
public GrokChatClient(string apiKey) : this(apiKey, "grok-3-mini", null) { }
25+
26+
/// <summary>
27+
/// Initializes the client with the specified API key, model ID, and optional OpenAI client options.
28+
/// </summary>
29+
public GrokChatClient(string apiKey, string modelId, OpenAIClientOptions? options = default)
30+
{
31+
this.apiKey = apiKey;
32+
this.modelId = modelId;
33+
this.options = options ?? new();
34+
this.options.Endpoint ??= new Uri("https://api.x.ai/v1");
35+
36+
// NOTE: by caching the pipeline, we speed up creation of new chat clients per model,
37+
// since the pipeline will be the same for all of them.
38+
pipeline = new OpenAIClient(new ApiKeyCredential(apiKey), options).Pipeline;
39+
}
40+
41+
/// <inheritdoc/>
42+
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellation = default)
43+
=> GetChatClient(options?.ModelId ?? modelId).GetResponseAsync(messages, SetOptions(options), cancellation);
44+
45+
/// <inheritdoc/>
46+
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellation = default)
47+
=> GetChatClient(options?.ModelId ?? modelId).GetStreamingResponseAsync(messages, SetOptions(options), cancellation);
48+
49+
IChatClient GetChatClient(string modelId) => clients.GetOrAdd(modelId, model
50+
=> new PipelineClient(pipeline, options).GetChatClient(modelId).AsIChatClient());
51+
52+
static ChatOptions? SetOptions(ChatOptions? options)
53+
{
54+
if (options is null)
55+
return null;
56+
57+
options.RawRepresentationFactory = _ =>
58+
{
59+
var result = new GrokCompletionOptions();
60+
var grok = options as GrokChatOptions;
61+
var search = grok?.Search;
62+
63+
if (options.Tools != null)
64+
{
65+
if (options.Tools.OfType<GrokSearchTool>().FirstOrDefault() is GrokSearchTool grokSearch)
66+
search = grokSearch.Mode;
67+
else if (options.Tools.OfType<HostedWebSearchTool>().FirstOrDefault() is HostedWebSearchTool webSearch)
68+
search = GrokSearch.Auto;
69+
70+
// Grok doesn't support any other hosted search tools, so remove remaining ones
71+
// so they don't get copied over by the OpenAI client.
72+
//options.Tools = [.. options.Tools.Where(tool => tool is not HostedWebSearchTool)];
73+
}
74+
75+
if (search != null)
76+
result.Search = search.Value;
77+
78+
if (grok?.ReasoningEffort != null)
79+
{
80+
result.ReasoningEffortLevel = grok.ReasoningEffort switch
81+
{
82+
ReasoningEffort.Low => OpenAI.Chat.ChatReasoningEffortLevel.Low,
83+
ReasoningEffort.High => OpenAI.Chat.ChatReasoningEffortLevel.High,
84+
_ => throw new ArgumentException($"Unsupported reasoning effort {grok.ReasoningEffort}")
85+
};
86+
}
87+
88+
return result;
89+
};
90+
91+
return options;
92+
}
93+
94+
void IDisposable.Dispose() { }
95+
96+
public object? GetService(Type serviceType, object? serviceKey = null) => null;
97+
98+
// Allows creating the base OpenAIClient with a pre-created pipeline.
99+
class PipelineClient(ClientPipeline pipeline, OpenAIClientOptions options) : OpenAIClient(pipeline, options) { }
100+
101+
class GrokCompletionOptions : OpenAI.Chat.ChatCompletionOptions
102+
{
103+
public GrokSearch Search { get; set; } = GrokSearch.Auto;
104+
105+
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions? options)
106+
{
107+
base.JsonModelWriteCore(writer, options);
108+
109+
// "search_parameters": { "mode": "auto" }
110+
writer.WritePropertyName("search_parameters");
111+
writer.WriteStartObject();
112+
writer.WriteString("mode", Search.ToString().ToLowerInvariant());
113+
writer.WriteEndObject();
114+
}
115+
}
116+
}

src/AI/Grok/GrokClient.cs

Lines changed: 21 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1,113 +1,45 @@
11
using System.ClientModel;
22
using System.ClientModel.Primitives;
33
using System.Collections.Concurrent;
4-
using System.Text.Json;
54
using Microsoft.Extensions.AI;
65
using OpenAI;
76

87
namespace Devlooped.Extensions.AI;
98

10-
public class GrokClient(string apiKey, OpenAIClientOptions options)
9+
/// <summary>
10+
/// Provides an OpenAI compability client for Grok. It's recommended you
11+
/// use <see cref="GrokChatClient"/> directly for chat-only scenarios.
12+
/// </summary>
13+
public class GrokClient(string apiKey, OpenAIClientOptions? options = null)
1114
: OpenAIClient(new ApiKeyCredential(apiKey), EnsureEndpoint(options))
1215
{
13-
// This allows ChatOptions to request a different model than the one configured
14-
// in the chat pipeline when GetChatClient(model).AsIChatClient() is called at registration time.
15-
readonly ConcurrentDictionary<string, GrokChatClientAdapter> adapters = new();
1616
readonly ConcurrentDictionary<string, IChatClient> clients = new();
1717

18-
public GrokClient(string apiKey)
19-
: this(apiKey, new())
20-
{
21-
}
18+
/// <summary>
19+
/// Initializes a new instance of the <see cref="GrokClient"/> with the specified API key.
20+
/// </summary>
21+
public GrokClient(string apiKey) : this(apiKey, new()) { }
2222

23-
IChatClient GetChatClientImpl(string model)
24-
// Gets the real chat client by prefixing so the overload invokes the base.
25-
=> clients.GetOrAdd(model, key => GetChatClient("__" + model).AsIChatClient());
23+
IChatClient GetChatClientImpl(string model) => clients.GetOrAdd(model, key => new GrokChatClient(apiKey, key, options));
2624

2725
/// <summary>
2826
/// Returns an adapter that surfaces an <see cref="IChatClient"/> interface that
2927
/// can be used directly in the <see cref="ChatClientBuilder"/> pipeline builder.
3028
/// </summary>
31-
public override OpenAI.Chat.ChatClient GetChatClient(string model)
32-
// We need to differentiate getting a real chat client vs an adapter for pipeline setup.
33-
// The former is invoked by the adapter when it needs to invoke the actual chat client,
34-
// which goes through the GetChatClientImpl. Since the method override is necessary to
35-
// satisfy the usage pattern when configuring OpenAIClient with M.E.AI, we differentiate
36-
// the internal call by adding a prefix we remove before calling downstream.
37-
=> model.StartsWith("__") ? base.GetChatClient(model[2..]) : new GrokChatClientAdapter(this, model);
38-
39-
static OpenAIClientOptions EnsureEndpoint(OpenAIClientOptions options)
40-
{
41-
if (options.Endpoint is null)
42-
options.Endpoint = new Uri("https://api.x.ai/v1");
43-
44-
return options;
45-
}
29+
public override OpenAI.Chat.ChatClient GetChatClient(string model) => new GrokChatClientAdapter(this, model);
4630

47-
static ChatOptions? SetOptions(ChatOptions? options)
31+
static OpenAIClientOptions EnsureEndpoint(OpenAIClientOptions? options)
4832
{
49-
if (options is null)
50-
return null;
51-
52-
options.RawRepresentationFactory = _ =>
53-
{
54-
var result = new GrokCompletionOptions();
55-
var grok = options as GrokChatOptions;
56-
var search = grok?.Search;
57-
58-
if (options.Tools != null)
59-
{
60-
if (options.Tools.OfType<GrokSearchTool>().FirstOrDefault() is GrokSearchTool grokSearch)
61-
search = grokSearch.Mode;
62-
else if (options.Tools.OfType<HostedWebSearchTool>().FirstOrDefault() is HostedWebSearchTool webSearch)
63-
search = GrokSearch.Auto;
64-
65-
// Grok doesn't support any other hosted search tools, so remove remaining ones
66-
// so they don't get copied over by the OpenAI client.
67-
//options.Tools = [.. options.Tools.Where(tool => tool is not HostedWebSearchTool)];
68-
}
69-
70-
if (search != null)
71-
result.Search = search.Value;
72-
73-
if (grok?.ReasoningEffort != null)
74-
{
75-
result.ReasoningEffortLevel = grok.ReasoningEffort switch
76-
{
77-
ReasoningEffort.Low => OpenAI.Chat.ChatReasoningEffortLevel.Low,
78-
ReasoningEffort.High => OpenAI.Chat.ChatReasoningEffortLevel.High,
79-
_ => throw new ArgumentException($"Unsupported reasoning effort {grok.ReasoningEffort}")
80-
};
81-
}
82-
83-
return result;
84-
};
85-
33+
options ??= new();
34+
options.Endpoint ??= new Uri("https://api.x.ai/v1");
8635
return options;
8736
}
8837

89-
class SearchParameters
90-
{
91-
public GrokSearch Mode { get; set; } = GrokSearch.Auto;
92-
}
93-
94-
class GrokCompletionOptions : OpenAI.Chat.ChatCompletionOptions
95-
{
96-
public GrokSearch Search { get; set; } = GrokSearch.Auto;
97-
98-
protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions? options)
99-
{
100-
base.JsonModelWriteCore(writer, options);
101-
102-
// "search_parameters": { "mode": "auto" }
103-
writer.WritePropertyName("search_parameters");
104-
writer.WriteStartObject();
105-
writer.WriteString("mode", Search.ToString().ToLowerInvariant());
106-
writer.WriteEndObject();
107-
}
108-
}
109-
110-
public class GrokChatClientAdapter(GrokClient client, string model) : OpenAI.Chat.ChatClient, IChatClient
38+
// This adapter is provided for compatibility with the documented usage for
39+
// OpenAI in MEAI docs. Most typical case would be to just create an <see cref="GrokChatClient"/> directly.
40+
// This throws on any non-IChatClient invoked methods in the AsIChatClient adapter, and
41+
// forwards the IChatClient methods to the GrokChatClient implementation which is cached per client.
42+
class GrokChatClientAdapter(GrokClient client, string model) : OpenAI.Chat.ChatClient, IChatClient
11143
{
11244
void IDisposable.Dispose() { }
11345

@@ -118,14 +50,14 @@ void IDisposable.Dispose() { }
11850
/// the default model when the adapter was created.
11951
/// </summary>
12052
Task<ChatResponse> IChatClient.GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken cancellation)
121-
=> client.GetChatClientImpl(options?.ModelId ?? model).GetResponseAsync(messages, SetOptions(options), cancellation);
53+
=> client.GetChatClientImpl(options?.ModelId ?? model).GetResponseAsync(messages, options, cancellation);
12254

12355
/// <summary>
12456
/// Routes the request to a client that matches the options' ModelId (if set), or
12557
/// the default model when the adapter was created.
12658
/// </summary>
12759
IAsyncEnumerable<ChatResponseUpdate> IChatClient.GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken cancellation)
128-
=> client.GetChatClientImpl(options?.ModelId ?? model).GetStreamingResponseAsync(messages, SetOptions(options), cancellation);
60+
=> client.GetChatClientImpl(options?.ModelId ?? model).GetStreamingResponseAsync(messages, options, cancellation);
12961

13062
// These are the only two methods actually invoked by the AsIChatClient adapter from M.E.AI.OpenAI
13163
public override Task<ClientResult<OpenAI.Chat.ChatCompletion>> CompleteChatAsync(IEnumerable<OpenAI.Chat.ChatMessage>? messages, OpenAI.Chat.ChatCompletionOptions? options = null, CancellationToken cancellationToken = default)

src/AI/GrokExtensions.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Devlooped.Extensions.AI;
2+
3+
/// <summary>
4+
///
5+
/// </summary>
6+
public static class GrokExtensions
7+
{
8+
}

src/AI/ReasoningEffort.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,20 @@
11
namespace Devlooped.Extensions.AI;
22

33
/// <summary>
4-
/// Reasoning effort an AI should apply when generating a response.
4+
/// Effort a reasoning model should apply when generating a response.
55
/// </summary>
6-
public enum ReasoningEffort { Low, High }
6+
public enum ReasoningEffort
7+
{
8+
/// <summary>
9+
/// Low effort reasoning, which may result in faster responses but less detailed or accurate answers.
10+
/// </summary>
11+
Low,
12+
/// <summary>
13+
/// Grok in particular does not support this mode, so it will default to <see cref="Low"/>.
14+
/// </summary>
15+
Medium,
16+
/// <summary>
17+
/// High effort reasoning, which may take longer but provides more detailed and accurate responses.
18+
/// </summary>
19+
High
20+
}

0 commit comments

Comments
 (0)