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
4 changes: 1 addition & 3 deletions .netconfig
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,7 @@
[file "Directory.Build.rsp"]
url = https://github.com/devlooped/oss/blob/main/Directory.Build.rsp
sha = 0f7f7f7e8a29de9b535676f75fe7c67e629a5e8c

etag = 0ccae83fc51f400bfd7058170bfec7aba11455e24a46a0d7e6a358da6486e255
weak
skip
[file "_config.yml"]
url = https://github.com/devlooped/oss/blob/main/_config.yml
sha = 68b409c486842062e0de0e5b11e6fdb7cd12d6e2
Expand Down
5 changes: 0 additions & 5 deletions Directory.Build.rsp

This file was deleted.

94 changes: 94 additions & 0 deletions src/AI.Tests/GrokTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
namespace Devlooped.Extensions.AI;

using Microsoft.Extensions.AI;
using static ConfigurationExtensions;

public class GrokTests
{
[SecretsFact("XAI_API_KEY")]
public async Task GrokInvokesTools()
{
var messages = new Chat()
{
{ "system", "You are a bot that invokes the tool get_date when asked for the date." },
{ "user", "What day is today?" },
};

var grok = new GrokClient(Configuration["XAI_API_KEY"]!);

var options = new GrokChatOptions
{
ModelId = "grok-3-mini",
Search = GrokSearch.Auto,
Tools = [AIFunctionFactory.Create(() => DateTimeOffset.Now.ToString("O"), "get_date")]
};

var response = await grok.GetResponseAsync(messages, options);
var getdate = response.Messages
.SelectMany(x => x.Contents.OfType<FunctionCallContent>())
.Any(x => x.Name == "get_date");

Assert.True(getdate);
}

[SecretsFact("XAI_API_KEY")]
public async Task GrokInvokesToolAndSearch()
{
var messages = new Chat()
{
{ "system", "You are a bot that invokes the tool 'get_date' before responding to anything since it's important context." },
{ "user", "What's Tesla stock worth today?" },
};

var grok = new GrokClient(Configuration["XAI_API_KEY"]!)
.AsBuilder()
.UseFunctionInvocation()
.Build();

var options = new GrokChatOptions
{
ModelId = "grok-3-mini",
Search = GrokSearch.On,
Tools = [AIFunctionFactory.Create(() => DateTimeOffset.Now.ToString("O"), "get_date")]
};

var response = await grok.GetResponseAsync(messages, options);

// The get_date result shows up as a tool role
Assert.Contains(response.Messages, x => x.Role == ChatRole.Tool);

var text = response.Text;

Assert.Contains("TSLA", text);
Assert.Contains("$", text);
Assert.Contains("Nasdaq", text, StringComparison.OrdinalIgnoreCase);
}

[SecretsFact("XAI_API_KEY")]
public async Task GrokThinksHard()
{
var messages = new Chat()
{
{ "system", "You are an intelligent AI assistant that's an expert on financial matters." },
{ "user", "If you have a debt of 100k and accumulate a compounding 5% debt on top of it every year, how long before you are a negative millonaire?" },
};

var grok = new GrokClient(Configuration["XAI_API_KEY"]!)
.AsBuilder()
.UseFunctionInvocation()
.Build();

var options = new GrokChatOptions
{
ModelId = "grok-3-mini",
Search = GrokSearch.Off,
ReasoningEffort = ReasoningEffort.High,
};

var response = await grok.GetResponseAsync(messages, options);

var text = response.Text;

Assert.Contains("48 years", text);
}
}
2 changes: 1 addition & 1 deletion src/AI/ChatExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ public static class ChatExtensions
public Task<ChatResponse> GetResponseAsync(Chat chat, ChatOptions? options = null, CancellationToken cancellation = default)
=> client.GetResponseAsync((IEnumerable<ChatMessage>)chat, options, cancellation);
}
}
}
22 changes: 22 additions & 0 deletions src/AI/Grok/GrokChatOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using Microsoft.Extensions.AI;

namespace Devlooped.Extensions.AI;

/// <summary>
/// Grok-specific chat options that extend the base <see cref="ChatOptions"/>
/// with <see cref="Search"/> and <see cref="ReasoningEffort"/> properties.
/// </summary>
public class GrokChatOptions : ChatOptions
{
/// <summary>
/// Configures Grok's live search capabilities.
/// See https://docs.x.ai/docs/guides/live-search.
/// </summary>
public GrokSearch Search { get; set; } = GrokSearch.Auto;

/// <summary>
/// Configures the reasoning effort level for Grok's responses.
/// See https://docs.x.ai/docs/guides/reasoning.
/// </summary>
public ReasoningEffort? ReasoningEffort { get; set; }
}
83 changes: 83 additions & 0 deletions src/AI/Grok/GrokClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Concurrent;
using System.Text.Json;
using Microsoft.Extensions.AI;
using OpenAI;

namespace Devlooped.Extensions.AI;

public class GrokClient(string apiKey, GrokClientOptions options)
: OpenAIClient(new ApiKeyCredential(apiKey), options), IChatClient
{
readonly GrokClientOptions clientOptions = options;
readonly ConcurrentDictionary<string, IChatClient> clients = new();

public GrokClient(string apiKey)
: this(apiKey, new())
{
}

void IDisposable.Dispose() { }
object? IChatClient.GetService(Type serviceType, object? serviceKey) => default;

Task<ChatResponse> IChatClient.GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken cancellation)
=> GetClient(options).GetResponseAsync(messages, SetOptions(options), cancellation);

IAsyncEnumerable<ChatResponseUpdate> IChatClient.GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options, CancellationToken cancellation)
=> GetClient(options).GetStreamingResponseAsync(messages, SetOptions(options), cancellation);

IChatClient GetClient(ChatOptions? options) => clients.GetOrAdd(
options?.ModelId ?? clientOptions.Model,
model => base.GetChatClient(model).AsIChatClient());

ChatOptions? SetOptions(ChatOptions? options)
{
if (options is null || options is not GrokChatOptions grok)
return null;

options.RawRepresentationFactory = _ =>
{
var result = new GrokCompletionOptions
{
Search = grok.Search
};

if (grok.ReasoningEffort != null)
{
result.ReasoningEffortLevel = grok.ReasoningEffort switch
{
ReasoningEffort.Low => OpenAI.Chat.ChatReasoningEffortLevel.Low,
ReasoningEffort.High => OpenAI.Chat.ChatReasoningEffortLevel.High,
_ => throw new ArgumentException($"Unsupported reasoning effort {grok.ReasoningEffort}")
};
}

return result;
};

return options;
}

class SearchParameters
{
public GrokSearch Mode { get; set; } = GrokSearch.Auto;
}

class GrokCompletionOptions : OpenAI.Chat.ChatCompletionOptions
{
public GrokSearch Search { get; set; } = GrokSearch.Auto;

protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options)
{
base.JsonModelWriteCore(writer, options);

// "search_parameters": { "mode": "auto" }
writer.WritePropertyName("search_parameters");
writer.WriteStartObject();
writer.WriteString("mode", Search.ToString().ToLowerInvariant());
writer.WriteEndObject();
}
}
}

16 changes: 16 additions & 0 deletions src/AI/Grok/GrokClientOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using OpenAI;

namespace Devlooped.Extensions.AI;

public class GrokClientOptions : OpenAIClientOptions
{
public GrokClientOptions() : this("grok-3") { }

public GrokClientOptions(string model)
{
Endpoint = new Uri("https://api.x.ai/v1");
Model = Throw.IfNullOrEmpty(model);
}

public string Model { get; }
}
3 changes: 3 additions & 0 deletions src/AI/Grok/GrokSearch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Devlooped.Extensions.AI;

public enum GrokSearch { Auto, On, Off }
27 changes: 27 additions & 0 deletions src/AI/Grok/GrokServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.ComponentModel;
using Devlooped.Extensions.AI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace Devlooped.Extensions.AI;

/// <summary>
/// Extensions for registering the <see cref="GrokClient"/> as a chat client in the service collection.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class GrokServiceCollectionExtensions
{
extension(IServiceCollection services)
{
/// <summary>
/// Registers the <see cref="GrokClient"/> as a chat client in the service collection.
/// </summary>
/// <param name="factory">The factory to create the Grok client.</param>
/// <param name="lifetime">The optional service lifetime.</param>
/// <returns>The <see cref="ChatClientBuilder"/> to further build the pipeline.</returns>
public ChatClientBuilder AddGrok(Func<IConfiguration, GrokClient> factory, ServiceLifetime lifetime = ServiceLifetime.Singleton)
=> services.AddChatClient(services
=> factory(services.GetRequiredService<IConfiguration>()), lifetime);
}
}
6 changes: 6 additions & 0 deletions src/AI/ReasoningEffort.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Devlooped.Extensions.AI;

/// <summary>
/// Reasoning effort an AI should apply when generating a response.
/// </summary>
public enum ReasoningEffort { Low, High }
4 changes: 4 additions & 0 deletions src/Directory.targets
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<Project InitialTargets="SetLocalVersion">

<PropertyGroup>
<PackOnBuild>true</PackOnBuild>
</PropertyGroup>

<Target Name="SetLocalVersion" Condition="!$(CI)">
<GetVersion>
<Output TaskParameter="Version" PropertyName="Version" />
Expand Down
Loading