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
14 changes: 14 additions & 0 deletions Extensions.AI.sln
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Weaving", "src\Weaving\Weav
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Samples", "src\Samples\Samples.csproj", "{4B78F0E3-E03B-4283-AB0B-B1D76CAEF1BC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AI.CodeAnalysis", "src\AI.CodeAnalysis\AI.CodeAnalysis.csproj", "{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -83,6 +85,18 @@ Global
{4B78F0E3-E03B-4283-AB0B-B1D76CAEF1BC}.Release|x64.Build.0 = Release|Any CPU
{4B78F0E3-E03B-4283-AB0B-B1D76CAEF1BC}.Release|x86.ActiveCfg = Release|Any CPU
{4B78F0E3-E03B-4283-AB0B-B1D76CAEF1BC}.Release|x86.Build.0 = Release|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Debug|x64.ActiveCfg = Debug|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Debug|x64.Build.0 = Debug|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Debug|x86.ActiveCfg = Debug|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Debug|x86.Build.0 = Debug|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Release|Any CPU.Build.0 = Release|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Release|x64.ActiveCfg = Release|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Release|x64.Build.0 = Release|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Release|x86.ActiveCfg = Release|Any CPU
{F6A9F74B-5C63-4C53-9745-F00BE40AF8C8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down
20 changes: 20 additions & 0 deletions src/AI.CodeAnalysis/AI.CodeAnalysis.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
<PackFolder>analyzers/dotnet/roslyn4.0/cs</PackFolder>
</PropertyGroup>

<ItemGroup>
<EmbeddedResource Include="..\AI\ChatClientExtensions.cs" Link="ChatClientExtensions.cs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="NuGetizer" Version="1.2.4" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" Pack="false" />
<PackageReference Include="PolySharp" Version="1.15.0" PrivateAssets="All" />
<PackageReference Include="ThisAssembly.Resources" Version="2.0.14" PrivateAssets="all" />
</ItemGroup>

</Project>
27 changes: 27 additions & 0 deletions src/AI.CodeAnalysis/ChatClientExtensionsGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace Devlooped.Extensions.AI;

/// <summary>
/// This generator produces the <see cref="ChatClientExtensions"/> source code so that it
/// exists in the user's target compilation and can successfully overload (and override)
/// the <c>OpenAIClientExtensions.AsIChatClient</c> that would otherwise be used. We
/// need this to ensure that the <see cref="ChatClient"/> can be used directly as an
/// <c>IChatClient</c> instead of wrapping it in the M.E.AI.OpenAI adapter.
/// </summary>
[Generator(LanguageNames.CSharp)]
public class ChatClientExtensionsGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterSourceOutput(context.CompilationProvider,
(spc, _) =>
{
spc.AddSource(
$"{nameof(ThisAssembly.Resources.ChatClientExtensions)}.g.cs",
SourceText.From(ThisAssembly.Resources.ChatClientExtensions.Text, Encoding.UTF8));
});
}
}
5 changes: 5 additions & 0 deletions src/AI.Tests/AI.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<NoWarn>OPENAI001;$(NoWarn)</NoWarn>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

<ItemGroup>
<Compile Include="..\AI\ChatClientExtensions.cs" Link="ChatClientExtensions.cs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.6.0-preview.1.25310.2" />
Expand Down
68 changes: 68 additions & 0 deletions src/AI.Tests/Extensions/PipelineTestOutput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.ClientModel.Primitives;
using System.Text.Json;
using System.Text.Json.Nodes;

namespace Devlooped.Extensions.AI;

public static class PipelineTestOutput
{
/// <summary>
/// Sets a <see cref="ClientPipelineOptions.Transport"/> that renders HTTP messages to the
/// console using Spectre.Console rich JSON formatting, but only if the console is interactive.
/// </summary>
/// <typeparam name="TOptions">The options type to configure for HTTP logging.</typeparam>
/// <param name="pipelineOptions">The options instance to configure.</param>
/// <remarks>
/// NOTE: this is the lowst-level logging after all chat pipeline processing has been done.
/// <para>
/// If the options already provide a transport, it will be wrapped with the console
/// logging transport to minimize the impact on existing configurations.
/// </para>
/// </remarks>
public static TOptions UseTestOutput<TOptions>(this TOptions pipelineOptions, ITestOutputHelper output)
where TOptions : ClientPipelineOptions
{
pipelineOptions.Transport = new TestPipelineTransport(pipelineOptions.Transport ?? HttpClientPipelineTransport.Shared, output);

return pipelineOptions;
}
}

public class TestPipelineTransport(PipelineTransport inner, ITestOutputHelper? output = null) : PipelineTransport
{
static readonly JsonSerializerOptions options = new JsonSerializerOptions(JsonSerializerDefaults.General)
{
WriteIndented = true,
};

public List<JsonNode> Requests { get; } = [];
public List<JsonNode> Responses { get; } = [];

protected override async ValueTask ProcessCoreAsync(PipelineMessage message)
{
message.BufferResponse = true;
await inner.ProcessAsync(message);

if (message.Request.Content is not null)
{
using var memory = new MemoryStream();
message.Request.Content.WriteTo(memory);
memory.Position = 0;
using var reader = new StreamReader(memory);
var content = await reader.ReadToEndAsync();
var node = JsonNode.Parse(content);
Requests.Add(node!);
output?.WriteLine(node!.ToJsonString(options));
}

if (message.Response != null)
{
var node = JsonNode.Parse(message.Response.Content.ToString());
Responses.Add(node!);
output?.WriteLine(node!.ToJsonString(options));
}
}

protected override PipelineMessage CreateMessageCore() => inner.CreateMessage();
protected override void ProcessCore(PipelineMessage message) => inner.Process(message);
}
84 changes: 70 additions & 14 deletions src/AI.Tests/GrokTests.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
namespace Devlooped.Extensions.AI;

using System.ClientModel.Primitives;
using System.Text.Json.Nodes;
using Microsoft.Extensions.AI;
using static ConfigurationExtensions;

public class GrokTests
namespace Devlooped.Extensions.AI;

public class GrokTests(ITestOutputHelper output)
{
[SecretsFact("XAI_API_KEY")]
public async Task GrokInvokesTools()
Expand All @@ -23,12 +25,18 @@ public async Task GrokInvokesTools()
Tools = [AIFunctionFactory.Create(() => DateTimeOffset.Now.ToString("O"), "get_date")]
};

var response = await grok.GetResponseAsync(messages, options);
var client = grok.GetChatClient("grok-3");
var chat = Assert.IsType<IChatClient>(client, false);

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

Assert.True(getdate);
// NOTE: the chat client was requested as grok-3 but the chat options wanted a
// different model and the grok client honors that choice.
Assert.Equal("grok-3-mini", response.ModelId);
}

[SecretsFact("XAI_API_KEY")]
Expand All @@ -40,7 +48,11 @@ public async Task GrokInvokesToolAndSearch()
{ "user", "What's Tesla stock worth today?" },
};

var grok = new GrokClient(Configuration["XAI_API_KEY"]!)
var transport = new TestPipelineTransport(HttpClientPipelineTransport.Shared, output);

var grok = new GrokClient(Configuration["XAI_API_KEY"]!, new OpenAI.OpenAIClientOptions() { Transport = transport })
.GetChatClient("grok-3")
.AsIChatClient()
.AsBuilder()
.UseFunctionInvocation()
.Build();
Expand All @@ -54,14 +66,30 @@ public async Task GrokInvokesToolAndSearch()

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

// assert that the request contains the following node
// "search_parameters": {
// "mode": "on"
//}
Assert.All(transport.Requests, x =>
{
var search = Assert.IsType<JsonObject>(x["search_parameters"]);
Assert.Equal("on", search["mode"]?.GetValue<string>());
});

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

var text = response.Text;
// Citations include nasdaq.com at least as a web search source
var node = transport.Responses.LastOrDefault();
Assert.NotNull(node);
var citations = Assert.IsType<JsonArray>(node["citations"], false);
var yahoo = citations.Where(x => x != null).Any(x => x!.ToString().Contains("https://finance.yahoo.com/quote/TSLA/", StringComparison.Ordinal));

Assert.Contains("TSLA", text);
Assert.Contains("$", text);
Assert.Contains("Nasdaq", text, StringComparison.OrdinalIgnoreCase);
Assert.True(yahoo, "Expected at least one citation to nasdaq.com");

// NOTE: the chat client was requested as grok-3 but the chat options wanted a
// different model and the grok client honors that choice.
Assert.Equal("grok-3-mini", response.ModelId);
}

[SecretsFact("XAI_API_KEY")]
Expand All @@ -73,20 +101,43 @@ public async Task GrokInvokesHostedSearchTool()
{ "user", "What's Tesla stock worth today? Search X and the news for latest info." },
};

var grok = new GrokClient(Configuration["XAI_API_KEY"]!);
var transport = new TestPipelineTransport(HttpClientPipelineTransport.Shared, output);

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

var options = new ChatOptions
{
ModelId = "grok-3",
Tools = [new HostedWebSearchTool()]
};

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

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

// assert that the request contains the following node
// "search_parameters": {
// "mode": "auto"
//}
Assert.All(transport.Requests, x =>
{
var search = Assert.IsType<JsonObject>(x["search_parameters"]);
Assert.Equal("auto", search["mode"]?.GetValue<string>());
});

// Citations include nasdaq.com at least as a web search source
Assert.Single(transport.Responses);
var node = transport.Responses[0];
Assert.NotNull(node);
var citations = Assert.IsType<JsonArray>(node["citations"], false);
var yahoo = citations.Where(x => x != null).Any(x => x!.ToString().Contains("https://finance.yahoo.com/quote/TSLA/", StringComparison.Ordinal));

Assert.True(yahoo, "Expected at least one citation to nasdaq.com");

// Uses the default model set by the client when we asked for it
Assert.Equal("grok-3", response.ModelId);
}

[SecretsFact("XAI_API_KEY")]
Expand All @@ -99,6 +150,8 @@ public async Task GrokThinksHard()
};

var grok = new GrokClient(Configuration["XAI_API_KEY"]!)
.GetChatClient("grok-3")
.AsIChatClient()
.AsBuilder()
.UseFunctionInvocation()
.Build();
Expand All @@ -115,5 +168,8 @@ public async Task GrokThinksHard()
var text = response.Text;

Assert.Contains("48 years", text);
// NOTE: the chat client was requested as grok-3 but the chat options wanted a
// different model and the grok client honors that choice.
Assert.StartsWith("grok-3-mini", response.ModelId);
}
}
7 changes: 6 additions & 1 deletion src/AI/AI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,21 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.6" />
<PackageReference Include="NuGetizer" Version="1.2.4" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.AI" Version="9.6.0" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.6.0-preview.1.25310.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.6" />
<PackageReference Include="OpenAI" Version="2.2.0-beta.4" />
<PackageReference Include="Spectre.Console" Version="0.50.0" />
<PackageReference Include="Spectre.Console.Json" Version="0.50.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AI.CodeAnalysis\AI.CodeAnalysis.csproj" ReferenceOutputAssembly="false" />
</ItemGroup>

<ItemGroup>
<None Update="Devlooped.Extensions.AI.targets" PackFolder="build" />
<None Update="Devlooped.Extensions.AI.props" PackFolder="build" />
</ItemGroup>

Expand Down
13 changes: 13 additions & 0 deletions src/AI/ChatClientExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using Microsoft.Extensions.AI;
using OpenAI.Chat;

/// <summary>
/// Smarter casting to <see cref="IChatClient"/> when the target <see cref="ChatClient"/>
/// already implements the interface.
/// </summary>
static class ChatClientExtensions
{
/// <summary>Gets an <see cref="IChatClient"/> for use with this <see cref="ChatClient"/>.</summary>
public static IChatClient AsIChatClient(this ChatClient client) =>
client as IChatClient ?? OpenAIClientExtensions.AsIChatClient(client);
}
2 changes: 2 additions & 0 deletions src/AI/Console/JsonConsoleOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ internal Panel CreatePanel(object value)
return panel;
}

#pragma warning disable CS9113 // Parameter is unread. BOGUS
sealed class WrappedJsonText(string json, int maxWidth) : Renderable
#pragma warning restore CS9113 // Parameter is unread. BOGUS
{
readonly JsonText jsonText = new(json);

Expand Down
10 changes: 3 additions & 7 deletions src/AI/Devlooped.Extensions.AI.props
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
<Project>
<PropertyGroup>
<Nullable>enable</Nullable>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>

<ItemGroup>
<Compile Update="@(Compile -> WithMetadataValue('NuGetPackageId', 'Devlooped.Extensions.AI'))" Visible="false" />
</ItemGroup>

<ItemGroup>
<Using Include="Microsoft.Extensions.AI"/>
<Using Include="Devlooped.Extensions.AI"/>
<!--<Using Include="Devlooped.Extensions.AI"/>-->
</ItemGroup>

</Project>
3 changes: 3 additions & 0 deletions src/AI/Devlooped.Extensions.AI.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<Project>

</Project>
Loading
Loading