diff --git a/CodexSharpSDK.Extensions.AI.Tests/ChatMessageMapperTests.cs b/CodexSharpSDK.Extensions.AI.Tests/ChatMessageMapperTests.cs new file mode 100644 index 0000000..fdbdf50 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI.Tests/ChatMessageMapperTests.cs @@ -0,0 +1,87 @@ +using ManagedCode.CodexSharpSDK.Extensions.AI.Internal; +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Tests; + +public class ChatMessageMapperTests +{ + [Test] + public async Task ToCodexInput_TextOnly_ReturnsPrompt() + { + var messages = new[] { new ChatMessage(ChatRole.User, "Hello world") }; + var (prompt, images) = ChatMessageMapper.ToCodexInput(messages); + await Assert.That(prompt).IsEqualTo("Hello world"); + await Assert.That(images).Count().IsEqualTo(0); + } + + [Test] + public async Task ToCodexInput_SystemAndUser_PrependsSystemPrefix() + { + var messages = new[] + { + new ChatMessage(ChatRole.System, "You are helpful"), + new ChatMessage(ChatRole.User, "Help me"), + }; + var (prompt, _) = ChatMessageMapper.ToCodexInput(messages); + await Assert.That(prompt).Contains("[System] You are helpful"); + await Assert.That(prompt).Contains("Help me"); + } + + [Test] + public async Task ToCodexInput_AssistantMessage_AppendsAssistantPrefix() + { + var messages = new[] + { + new ChatMessage(ChatRole.User, "Question"), + new ChatMessage(ChatRole.Assistant, "Answer"), + new ChatMessage(ChatRole.User, "Follow up"), + }; + var (prompt, _) = ChatMessageMapper.ToCodexInput(messages); + await Assert.That(prompt).Contains("[Assistant] Answer"); + await Assert.That(prompt).Contains("Follow up"); + } + + [Test] + public async Task ToCodexInput_ImageContent_ExtractedSeparately() + { + var imageData = new byte[] { 0x89, 0x50, 0x4E, 0x47 }; // PNG header + var messages = new[] + { + new ChatMessage(ChatRole.User, + [ + new TextContent("Describe this"), + new DataContent(imageData, "image/png"), + ]), + }; + var (prompt, images) = ChatMessageMapper.ToCodexInput(messages); + await Assert.That(prompt).Contains("Describe this"); + await Assert.That(images).Count().IsEqualTo(1); + } + + [Test] + public async Task ToCodexInput_EmptyMessages_ReturnsEmpty() + { + var (prompt, images) = ChatMessageMapper.ToCodexInput([]); + await Assert.That(prompt).IsEqualTo(string.Empty); + await Assert.That(images).Count().IsEqualTo(0); + } + + [Test] + public async Task BuildUserInput_NoImages_ReturnsSingleTextInput() + { + var result = ChatMessageMapper.BuildUserInput("Hello", []); + await Assert.That(result).Count().IsEqualTo(1); + await Assert.That(result[0]).IsTypeOf(); + } + + [Test] + public async Task BuildUserInput_WithImages_ReturnsTextAndImageInputs() + { + var imageData = new byte[] { 0xFF, 0xD8, 0xFF }; // JPEG header + var images = new List { new(imageData, "image/jpeg") }; + var result = ChatMessageMapper.BuildUserInput("Look at this", images); + await Assert.That(result.Count).IsGreaterThanOrEqualTo(2); + await Assert.That(result[0]).IsTypeOf(); + } +} diff --git a/CodexSharpSDK.Extensions.AI.Tests/ChatOptionsMapperTests.cs b/CodexSharpSDK.Extensions.AI.Tests/ChatOptionsMapperTests.cs new file mode 100644 index 0000000..17e0b71 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI.Tests/ChatOptionsMapperTests.cs @@ -0,0 +1,53 @@ +using ManagedCode.CodexSharpSDK.Client; +using ManagedCode.CodexSharpSDK.Extensions.AI.Internal; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Tests; + +public class ChatOptionsMapperTests +{ + [Test] + public async Task ToThreadOptions_NullOptions_UsesDefaults() + { + var clientOptions = new CodexChatClientOptions { DefaultModel = "test-model" }; + var result = ChatOptionsMapper.ToThreadOptions(null, clientOptions); + await Assert.That(result.Model).IsEqualTo("test-model"); + } + + [Test] + public async Task ToThreadOptions_ModelId_MapsToModel() + { + var chatOptions = new ChatOptions { ModelId = "gpt-5" }; + var clientOptions = new CodexChatClientOptions { DefaultModel = "default" }; + var result = ChatOptionsMapper.ToThreadOptions(chatOptions, clientOptions); + await Assert.That(result.Model).IsEqualTo("gpt-5"); + } + + [Test] + public async Task ToThreadOptions_AdditionalProperties_MapsCodexKeys() + { + var chatOptions = new ChatOptions + { + AdditionalProperties = new AdditionalPropertiesDictionary + { + [ChatOptionsMapper.SandboxModeKey] = SandboxMode.WorkspaceWrite, + [ChatOptionsMapper.FullAutoKey] = true, + [ChatOptionsMapper.ProfileKey] = "strict", + [ChatOptionsMapper.ReasoningEffortKey] = ModelReasoningEffort.High, + }, + }; + var result = ChatOptionsMapper.ToThreadOptions(chatOptions, new CodexChatClientOptions()); + await Assert.That(result.SandboxMode).IsEqualTo(SandboxMode.WorkspaceWrite); + await Assert.That(result.FullAuto).IsTrue(); + await Assert.That(result.Profile).IsEqualTo("strict"); + await Assert.That(result.ModelReasoningEffort).IsEqualTo(ModelReasoningEffort.High); + } + + [Test] + public async Task ToTurnOptions_SetsCancellationToken() + { + using var cts = new CancellationTokenSource(); + var result = ChatOptionsMapper.ToTurnOptions(null, cts.Token); + await Assert.That(result.CancellationToken).IsEqualTo(cts.Token); + } +} diff --git a/CodexSharpSDK.Extensions.AI.Tests/ChatResponseMapperTests.cs b/CodexSharpSDK.Extensions.AI.Tests/ChatResponseMapperTests.cs new file mode 100644 index 0000000..4629b4a --- /dev/null +++ b/CodexSharpSDK.Extensions.AI.Tests/ChatResponseMapperTests.cs @@ -0,0 +1,83 @@ +using ManagedCode.CodexSharpSDK.Extensions.AI.Content; +using ManagedCode.CodexSharpSDK.Extensions.AI.Internal; +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Tests; + +public class ChatResponseMapperTests +{ + [Test] + public async Task ToChatResponse_BasicResult_MapsCorrectly() + { + var result = new RunResult([], "Hello from Codex", new Usage(100, 10, 50)); + var response = ChatResponseMapper.ToChatResponse(result, "thread-123"); + + await Assert.That(response.Text).Contains("Hello from Codex"); + await Assert.That(response.ConversationId).IsEqualTo("thread-123"); + await Assert.That(response.Usage).IsNotNull(); + await Assert.That(response.Usage!.InputTokenCount).IsEqualTo(100); + await Assert.That(response.Usage!.OutputTokenCount).IsEqualTo(50); + await Assert.That(response.Usage!.TotalTokenCount).IsEqualTo(150); + } + + [Test] + public async Task ToChatResponse_NullUsage_NoUsageSet() + { + var result = new RunResult([], "Response", null); + var response = ChatResponseMapper.ToChatResponse(result, null); + await Assert.That(response.Usage).IsNull(); + await Assert.That(response.ConversationId).IsNull(); + } + + [Test] + public async Task ToChatResponse_WithReasoningItem_MapsToTextReasoningContent() + { + var items = new List + { + new ReasoningItem("r1", "thinking about this..."), + }; + var result = new RunResult(items, "Final answer", null); + var response = ChatResponseMapper.ToChatResponse(result, null); + + var contents = response.Messages[0].Contents; + await Assert.That(contents.OfType().Count()).IsEqualTo(1); + } + + [Test] + public async Task ToChatResponse_WithCommandExecution_MapsToCustomContent() + { + var items = new List + { + new CommandExecutionItem("c1", "npm test", "all passed", 0, CommandExecutionStatus.Completed), + }; + var result = new RunResult(items, "Done", null); + var response = ChatResponseMapper.ToChatResponse(result, null); + + var cmdContent = response.Messages[0].Contents.OfType().Single(); + await Assert.That(cmdContent.Command).IsEqualTo("npm test"); + await Assert.That(cmdContent.ExitCode).IsEqualTo(0); + } + + [Test] + public async Task ToChatResponse_WithFileChange_MapsToCustomContent() + { + var items = new List + { + new FileChangeItem("f1", [new FileUpdateChange("src/app.cs", PatchChangeKind.Update)], PatchApplyStatus.Completed), + }; + var result = new RunResult(items, "Fixed", null); + var response = ChatResponseMapper.ToChatResponse(result, null); + + var fileContent = response.Messages[0].Contents.OfType().Single(); + await Assert.That(fileContent.Changes).Count().IsEqualTo(1); + } + + [Test] + public async Task ToChatResponse_CachedTokens_IncludedInUsage() + { + var result = new RunResult([], "Response", new Usage(100, 50, 25)); + var response = ChatResponseMapper.ToChatResponse(result, null); + await Assert.That(response.Usage!.CachedInputTokenCount).IsEqualTo(50); + } +} diff --git a/CodexSharpSDK.Extensions.AI.Tests/CodexServiceCollectionExtensionsTests.cs b/CodexSharpSDK.Extensions.AI.Tests/CodexServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..5ce74df --- /dev/null +++ b/CodexSharpSDK.Extensions.AI.Tests/CodexServiceCollectionExtensionsTests.cs @@ -0,0 +1,39 @@ +using ManagedCode.CodexSharpSDK.Extensions.AI.Extensions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Tests; + +public class CodexServiceCollectionExtensionsTests +{ + [Test] + public async Task AddCodexChatClient_RegistersIChatClient() + { + var services = new ServiceCollection(); + services.AddCodexChatClient(); + var provider = services.BuildServiceProvider(); + var client = provider.GetService(); + await Assert.That(client).IsNotNull(); + await Assert.That(client).IsTypeOf(); + } + + [Test] + public async Task AddCodexChatClient_WithConfiguration_RegistersIChatClient() + { + var services = new ServiceCollection(); + services.AddCodexChatClient(_ => { }); + var provider = services.BuildServiceProvider(); + var client = provider.GetService(); + await Assert.That(client).IsNotNull(); + } + + [Test] + public async Task AddKeyedCodexChatClient_RegistersWithKey() + { + var services = new ServiceCollection(); + services.AddKeyedCodexChatClient("codex"); + var provider = services.BuildServiceProvider(); + var client = provider.GetKeyedService("codex"); + await Assert.That(client).IsNotNull(); + } +} diff --git a/CodexSharpSDK.Extensions.AI.Tests/CodexSharpSDK.Extensions.AI.Tests.csproj b/CodexSharpSDK.Extensions.AI.Tests/CodexSharpSDK.Extensions.AI.Tests.csproj new file mode 100644 index 0000000..286a92c --- /dev/null +++ b/CodexSharpSDK.Extensions.AI.Tests/CodexSharpSDK.Extensions.AI.Tests.csproj @@ -0,0 +1,22 @@ + + + Exe + false + true + ManagedCode.CodexSharpSDK.Extensions.AI.Tests + ManagedCode.CodexSharpSDK.Extensions.AI.Tests + true + true + $(NoWarn);CA1707;CS1591 + + + + + + + + + + + + diff --git a/CodexSharpSDK.Extensions.AI.Tests/StreamingEventMapperTests.cs b/CodexSharpSDK.Extensions.AI.Tests/StreamingEventMapperTests.cs new file mode 100644 index 0000000..5ef50a7 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI.Tests/StreamingEventMapperTests.cs @@ -0,0 +1,92 @@ +using ManagedCode.CodexSharpSDK.Extensions.AI.Content; +using ManagedCode.CodexSharpSDK.Extensions.AI.Internal; +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Tests; + +public class StreamingEventMapperTests +{ + [Test] + public async Task ToUpdates_ThreadStarted_YieldsConversationId() + { + var events = ToAsyncEnumerable(new ThreadStartedEvent("thread-1")); + var updates = await CollectUpdates(events); + await Assert.That(updates[0].ConversationId).IsEqualTo("thread-1"); + } + + [Test] + public async Task ToUpdates_AgentMessage_YieldsTextContent() + { + var events = ToAsyncEnumerable( + new ItemCompletedEvent(new AgentMessageItem("m1", "Hello"))); + var updates = await CollectUpdates(events); + await Assert.That(updates[0].Text).IsEqualTo("Hello"); + await Assert.That(updates[0].Role).IsEqualTo(ChatRole.Assistant); + } + + [Test] + public async Task ToUpdates_TurnCompleted_YieldsFinishReason() + { + var events = ToAsyncEnumerable( + new TurnCompletedEvent(new Usage(10, 0, 5))); + var updates = await CollectUpdates(events); + await Assert.That(updates[0].FinishReason).IsEqualTo(ChatFinishReason.Stop); + } + + [Test] + public async Task ToUpdates_TurnFailed_ThrowsException() + { + var events = ToAsyncEnumerable( + new TurnFailedEvent(new ThreadError("something broke"))); + + await Assert.That(async () => await CollectUpdates(events)) + .ThrowsExactly(); + } + + [Test] + public async Task ToUpdates_CommandExecution_YieldsCustomContent() + { + var events = ToAsyncEnumerable( + new ItemCompletedEvent( + new CommandExecutionItem("c1", "ls", "file.txt", 0, CommandExecutionStatus.Completed))); + var updates = await CollectUpdates(events); + var content = updates[0].Contents.OfType().Single(); + await Assert.That(content.Command).IsEqualTo("ls"); + } + + [Test] + public async Task ToUpdates_FullSequence_MapsAllEvents() + { + var events = ToAsyncEnumerable( + new ThreadStartedEvent("t1"), + new TurnStartedEvent(), + new ItemCompletedEvent(new ReasoningItem("r1", "thinking")), + new ItemCompletedEvent(new AgentMessageItem("m1", "answer")), + new TurnCompletedEvent(new Usage(10, 0, 5))); + + var updates = await CollectUpdates(events); + // TurnStartedEvent is not matched in the switch, so 4 updates expected + await Assert.That(updates.Count).IsGreaterThanOrEqualTo(4); + } + + private static async IAsyncEnumerable ToAsyncEnumerable(params ThreadEvent[] events) + { + foreach (var evt in events) + { + yield return evt; + await Task.CompletedTask; + } + } + + private static async Task> CollectUpdates(IAsyncEnumerable events) + { + var updates = new List(); + await foreach (var update in StreamingEventMapper.ToUpdates(events)) + { + updates.Add(update); + } + + return updates; + } +} diff --git a/CodexSharpSDK.Extensions.AI/CodexChatClient.cs b/CodexSharpSDK.Extensions.AI/CodexChatClient.cs new file mode 100644 index 0000000..60c1ddd --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/CodexChatClient.cs @@ -0,0 +1,100 @@ +using System.Runtime.CompilerServices; +using ManagedCode.CodexSharpSDK.Client; +using ManagedCode.CodexSharpSDK.Extensions.AI.Internal; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI; + +public sealed class CodexChatClient : IChatClient +{ + private readonly CodexClient _client; + private readonly CodexChatClientOptions _options; + + public CodexChatClient(CodexChatClientOptions? options = null) + { + _options = options ?? new CodexChatClientOptions(); + _client = new CodexClient(new CodexClientOptions + { + CodexOptions = _options.CodexOptions, + AutoStart = true, + }); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + + var (prompt, imageContents) = ChatMessageMapper.ToCodexInput(messages); + var threadOptions = ChatOptionsMapper.ToThreadOptions(options, _options); + var turnOptions = ChatOptionsMapper.ToTurnOptions(options, cancellationToken); + + var thread = options?.ConversationId is { } threadId + ? _client.ResumeThread(threadId, threadOptions) + : _client.StartThread(threadOptions); + + using (thread) + { + var userInput = ChatMessageMapper.BuildUserInput(prompt, imageContents); + var result = await thread.RunAsync(userInput, turnOptions).ConfigureAwait(false); + return ChatResponseMapper.ToChatResponse(result, thread.Id); + } + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(messages); + + var (prompt, imageContents) = ChatMessageMapper.ToCodexInput(messages); + var threadOptions = ChatOptionsMapper.ToThreadOptions(options, _options); + var turnOptions = ChatOptionsMapper.ToTurnOptions(options, cancellationToken); + + var thread = options?.ConversationId is { } threadId + ? _client.ResumeThread(threadId, threadOptions) + : _client.StartThread(threadOptions); + + using (thread) + { + var userInput = ChatMessageMapper.BuildUserInput(prompt, imageContents); + var streamed = await thread.RunStreamedAsync(userInput, turnOptions) + .ConfigureAwait(false); + + await foreach (var update in StreamingEventMapper.ToUpdates(streamed.Events, cancellationToken) + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + yield return update; + } + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + if (serviceKey is not null) + { + return null; + } + + if (serviceType == typeof(ChatClientMetadata)) + { + return new ChatClientMetadata( + providerName: "CodexCLI", + providerUri: null, + defaultModelId: _options.DefaultModel); + } + + if (serviceType.IsInstanceOfType(this)) + { + return this; + } + + return null; + } + + public void Dispose() => _client.Dispose(); +} diff --git a/CodexSharpSDK.Extensions.AI/CodexChatClientOptions.cs b/CodexSharpSDK.Extensions.AI/CodexChatClientOptions.cs new file mode 100644 index 0000000..1a94b67 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/CodexChatClientOptions.cs @@ -0,0 +1,11 @@ +using ManagedCode.CodexSharpSDK.Client; +using ManagedCode.CodexSharpSDK.Configuration; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI; + +public sealed record CodexChatClientOptions +{ + public CodexOptions? CodexOptions { get; init; } + public string? DefaultModel { get; init; } + public ThreadOptions? DefaultThreadOptions { get; init; } +} diff --git a/CodexSharpSDK.Extensions.AI/CodexSharpSDK.Extensions.AI.csproj b/CodexSharpSDK.Extensions.AI/CodexSharpSDK.Extensions.AI.csproj new file mode 100644 index 0000000..d0132cc --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/CodexSharpSDK.Extensions.AI.csproj @@ -0,0 +1,25 @@ + + + .NET Microsoft.Extensions.AI adapter for CodexSharpSDK, providing IChatClient integration. + ManagedCode.CodexSharpSDK.Extensions.AI + ManagedCode.CodexSharpSDK.Extensions.AI + ManagedCode.CodexSharpSDK.Extensions.AI + codex;openai;sdk;ai;agent;cli;microsoft-extensions-ai;ichatclient + true + $(NoWarn);CS1591 + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/CodexSharpSDK.Extensions.AI/Content/CollabToolCallContent.cs b/CodexSharpSDK.Extensions.AI/Content/CollabToolCallContent.cs new file mode 100644 index 0000000..df750c4 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Content/CollabToolCallContent.cs @@ -0,0 +1,13 @@ +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Content; + +public sealed class CollabToolCallContent : AIContent +{ + public required CollabTool Tool { get; init; } + public required string SenderThreadId { get; init; } + public required IReadOnlyList ReceiverThreadIds { get; init; } + public required IReadOnlyDictionary AgentsStates { get; init; } + public required CollabToolCallStatus Status { get; init; } +} diff --git a/CodexSharpSDK.Extensions.AI/Content/CommandExecutionContent.cs b/CodexSharpSDK.Extensions.AI/Content/CommandExecutionContent.cs new file mode 100644 index 0000000..54a43db --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Content/CommandExecutionContent.cs @@ -0,0 +1,12 @@ +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Content; + +public sealed class CommandExecutionContent : AIContent +{ + public required string Command { get; init; } + public required string AggregatedOutput { get; init; } + public int? ExitCode { get; init; } + public required CommandExecutionStatus Status { get; init; } +} diff --git a/CodexSharpSDK.Extensions.AI/Content/FileChangeContent.cs b/CodexSharpSDK.Extensions.AI/Content/FileChangeContent.cs new file mode 100644 index 0000000..54ee07a --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Content/FileChangeContent.cs @@ -0,0 +1,10 @@ +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Content; + +public sealed class FileChangeContent : AIContent +{ + public required IReadOnlyList Changes { get; init; } + public required PatchApplyStatus Status { get; init; } +} diff --git a/CodexSharpSDK.Extensions.AI/Content/McpToolCallContent.cs b/CodexSharpSDK.Extensions.AI/Content/McpToolCallContent.cs new file mode 100644 index 0000000..3c55243 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Content/McpToolCallContent.cs @@ -0,0 +1,15 @@ +using System.Text.Json.Nodes; +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Content; + +public sealed class McpToolCallContent : AIContent +{ + public required string Server { get; init; } + public required string Tool { get; init; } + public JsonNode? Arguments { get; init; } + public McpToolCallResult? Result { get; init; } + public McpToolCallError? Error { get; init; } + public required McpToolCallStatus Status { get; init; } +} diff --git a/CodexSharpSDK.Extensions.AI/Content/WebSearchContent.cs b/CodexSharpSDK.Extensions.AI/Content/WebSearchContent.cs new file mode 100644 index 0000000..a9ada41 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Content/WebSearchContent.cs @@ -0,0 +1,8 @@ +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Content; + +public sealed class WebSearchContent : AIContent +{ + public required string Query { get; init; } +} diff --git a/CodexSharpSDK.Extensions.AI/Extensions/CodexServiceCollectionExtensions.cs b/CodexSharpSDK.Extensions.AI/Extensions/CodexServiceCollectionExtensions.cs new file mode 100644 index 0000000..227f7d4 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Extensions/CodexServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Extensions; + +public static class CodexServiceCollectionExtensions +{ + public static IServiceCollection AddCodexChatClient( + this IServiceCollection services, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var options = new CodexChatClientOptions(); + configure?.Invoke(options); + services.AddSingleton(new CodexChatClient(options)); + return services; + } + + public static IServiceCollection AddKeyedCodexChatClient( + this IServiceCollection services, + object serviceKey, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(serviceKey); + + var options = new CodexChatClientOptions(); + configure?.Invoke(options); + services.AddKeyedSingleton(serviceKey, new CodexChatClient(options)); + return services; + } +} diff --git a/CodexSharpSDK.Extensions.AI/Internal/ChatMessageMapper.cs b/CodexSharpSDK.Extensions.AI/Internal/ChatMessageMapper.cs new file mode 100644 index 0000000..84306d4 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Internal/ChatMessageMapper.cs @@ -0,0 +1,91 @@ +using System.Text; +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Internal; + +internal static class ChatMessageMapper +{ + internal static (string Prompt, List ImageContents) ToCodexInput(IEnumerable messages) + { + var prompt = new StringBuilder(); + var userTextParts = new List(); + var imageContents = new List(); + + foreach (var message in messages) + { + if (message.Role == ChatRole.System) + { + if (message.Text is { } systemText) + { + prompt.Append("[System] ").Append(systemText).Append("\n\n"); + } + } + else if (message.Role == ChatRole.User) + { + foreach (var content in message.Contents) + { + if (content is TextContent tc && tc.Text is not null) + { + userTextParts.Add(tc.Text); + } + else if (content is DataContent dc && dc.MediaType is not null && dc.MediaType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + imageContents.Add(dc); + } + } + } + else if (message.Role == ChatRole.Assistant) + { + if (message.Text is { } assistantText) + { + prompt.Append("[Assistant] ").Append(assistantText).Append("\n\n"); + } + } + } + + if (userTextParts.Count > 0) + { + prompt.Append(string.Join("\n\n", userTextParts)); + } + + return (prompt.ToString(), imageContents); + } + + internal static IReadOnlyList BuildUserInput(string prompt, IReadOnlyList imageContents) + { + if (imageContents.Count == 0) + { + return [new TextInput(prompt)]; + } + + var inputs = new List { new TextInput(prompt) }; + + foreach (var dc in imageContents) + { + var fileName = dc.Name ?? GenerateFileName(dc.MediaType); + if (dc.Data.Length > 0) + { + var stream = new MemoryStream(dc.Data.ToArray()); + inputs.Add(new LocalImageInput(stream, fileName, leaveOpen: false)); + } + } + + return inputs; + } + + private static string GenerateFileName(string? mediaType) + { + var extension = mediaType switch + { + "image/png" => ".png", + "image/jpeg" => ".jpg", + "image/gif" => ".gif", + "image/webp" => ".webp", + "image/bmp" => ".bmp", + _ => ".bin", + }; + + return $"image_{Guid.NewGuid():N}{extension}"; + } +} diff --git a/CodexSharpSDK.Extensions.AI/Internal/ChatOptionsMapper.cs b/CodexSharpSDK.Extensions.AI/Internal/ChatOptionsMapper.cs new file mode 100644 index 0000000..a2a6636 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Internal/ChatOptionsMapper.cs @@ -0,0 +1,112 @@ +using ManagedCode.CodexSharpSDK.Client; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Internal; + +internal static class ChatOptionsMapper +{ + internal const string SandboxModeKey = "codex:sandbox_mode"; + internal const string WorkingDirectoryKey = "codex:working_directory"; + internal const string ReasoningEffortKey = "codex:reasoning_effort"; + internal const string NetworkAccessKey = "codex:network_access"; + internal const string WebSearchKey = "codex:web_search"; + internal const string ApprovalPolicyKey = "codex:approval_policy"; + internal const string FullAutoKey = "codex:full_auto"; + internal const string EphemeralKey = "codex:ephemeral"; + internal const string ProfileKey = "codex:profile"; + internal const string SkipGitRepoCheckKey = "codex:skip_git_repo_check"; + + internal static ThreadOptions ToThreadOptions(ChatOptions? chatOptions, CodexChatClientOptions clientOptions) + { + var defaults = clientOptions.DefaultThreadOptions ?? new ThreadOptions(); + + var model = chatOptions?.ModelId ?? clientOptions.DefaultModel ?? defaults.Model; + var sandboxMode = defaults.SandboxMode; + var workingDirectory = defaults.WorkingDirectory; + var reasoningEffort = defaults.ModelReasoningEffort; + var networkAccess = defaults.NetworkAccessEnabled; + var webSearch = defaults.WebSearchMode; + var approvalPolicy = defaults.ApprovalPolicy; + var fullAuto = defaults.FullAuto; + var ephemeral = defaults.Ephemeral; + var profile = defaults.Profile; + var skipGitRepoCheck = defaults.SkipGitRepoCheck; + + if (chatOptions?.AdditionalProperties is { } props) + { + if (props.TryGetValue(SandboxModeKey, out var val) && val is SandboxMode sm) + { + sandboxMode = sm; + } + + if (props.TryGetValue(WorkingDirectoryKey, out val) && val is string wd) + { + workingDirectory = wd; + } + + if (props.TryGetValue(ReasoningEffortKey, out val) && val is ModelReasoningEffort mre) + { + reasoningEffort = mre; + } + + if (props.TryGetValue(NetworkAccessKey, out val) && val is bool na) + { + networkAccess = na; + } + + if (props.TryGetValue(WebSearchKey, out val) && val is WebSearchMode wsm) + { + webSearch = wsm; + } + + if (props.TryGetValue(ApprovalPolicyKey, out val) && val is ApprovalMode am) + { + approvalPolicy = am; + } + + if (props.TryGetValue(FullAutoKey, out val) && val is bool fa) + { + fullAuto = fa; + } + + if (props.TryGetValue(EphemeralKey, out val) && val is bool eph) + { + ephemeral = eph; + } + + if (props.TryGetValue(ProfileKey, out val) && val is string prof) + { + profile = prof; + } + + if (props.TryGetValue(SkipGitRepoCheckKey, out val) && val is bool sgrc) + { + skipGitRepoCheck = sgrc; + } + } + + return defaults with + { + Model = model, + SandboxMode = sandboxMode, + WorkingDirectory = workingDirectory, + ModelReasoningEffort = reasoningEffort, + NetworkAccessEnabled = networkAccess, + WebSearchMode = webSearch, + ApprovalPolicy = approvalPolicy, + FullAuto = fullAuto, + Ephemeral = ephemeral, + Profile = profile, + SkipGitRepoCheck = skipGitRepoCheck, + }; + } + + internal static TurnOptions ToTurnOptions(ChatOptions? chatOptions, CancellationToken cancellationToken) + { + _ = chatOptions; // reserved for future ResponseFormat→OutputSchema mapping + return new TurnOptions + { + CancellationToken = cancellationToken, + }; + } +} diff --git a/CodexSharpSDK.Extensions.AI/Internal/ChatResponseMapper.cs b/CodexSharpSDK.Extensions.AI/Internal/ChatResponseMapper.cs new file mode 100644 index 0000000..b4e18ee --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Internal/ChatResponseMapper.cs @@ -0,0 +1,93 @@ +using ManagedCode.CodexSharpSDK.Extensions.AI.Content; +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Internal; + +internal static class ChatResponseMapper +{ + internal static ChatResponse ToChatResponse(RunResult result, string? threadId) + { + var contents = new List + { + new TextContent(result.FinalResponse), + }; + + foreach (var item in result.Items) + { + switch (item) + { + case ReasoningItem r: + contents.Add(new TextReasoningContent(r.Text)); + break; + + case CommandExecutionItem c: + contents.Add(new CommandExecutionContent + { + Command = c.Command, + AggregatedOutput = c.AggregatedOutput, + ExitCode = c.ExitCode, + Status = c.Status, + }); + break; + + case FileChangeItem f: + contents.Add(new FileChangeContent + { + Changes = f.Changes, + Status = f.Status, + }); + break; + + case McpToolCallItem m: + contents.Add(new McpToolCallContent + { + Server = m.Server, + Tool = m.Tool, + Arguments = m.Arguments, + Result = m.Result, + Error = m.Error, + Status = m.Status, + }); + break; + + case WebSearchItem w: + contents.Add(new WebSearchContent + { + Query = w.Query, + }); + break; + + case CollabToolCallItem col: + contents.Add(new CollabToolCallContent + { + Tool = col.Tool, + SenderThreadId = col.SenderThreadId, + ReceiverThreadIds = col.ReceiverThreadIds, + AgentsStates = col.AgentsStates, + Status = col.Status, + }); + break; + } + } + + var assistantMessage = new ChatMessage(ChatRole.Assistant, contents); + var response = new ChatResponse(assistantMessage) + { + ConversationId = threadId, + }; + + if (result.Usage is { } usage) + { + response.Usage = new UsageDetails + { + InputTokenCount = usage.InputTokens, + OutputTokenCount = usage.OutputTokens, + TotalTokenCount = usage.InputTokens + usage.OutputTokens, + CachedInputTokenCount = usage.CachedInputTokens > 0 ? usage.CachedInputTokens : null, + }; + } + + return response; + } +} diff --git a/CodexSharpSDK.Extensions.AI/Internal/StreamingEventMapper.cs b/CodexSharpSDK.Extensions.AI/Internal/StreamingEventMapper.cs new file mode 100644 index 0000000..8a21894 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Internal/StreamingEventMapper.cs @@ -0,0 +1,146 @@ +using System.Runtime.CompilerServices; +using ManagedCode.CodexSharpSDK.Extensions.AI.Content; +using ManagedCode.CodexSharpSDK.Models; +using Microsoft.Extensions.AI; + +namespace ManagedCode.CodexSharpSDK.Extensions.AI.Internal; + +internal static class StreamingEventMapper +{ + internal static async IAsyncEnumerable ToUpdates( + IAsyncEnumerable events, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await foreach (var evt in events.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + switch (evt) + { + case ThreadStartedEvent started: + yield return new ChatResponseUpdate { ConversationId = started.ThreadId }; + break; + + case ItemCompletedEvent { Item: AgentMessageItem msg }: + yield return new ChatResponseUpdate + { + Role = ChatRole.Assistant, + Contents = [new TextContent(msg.Text)], + }; + break; + + case ItemCompletedEvent { Item: ReasoningItem r }: + yield return new ChatResponseUpdate + { + Contents = [new TextReasoningContent(r.Text)], + }; + break; + + case ItemCompletedEvent { Item: CommandExecutionItem c }: + yield return new ChatResponseUpdate + { + Contents = + [ + new CommandExecutionContent + { + Command = c.Command, + AggregatedOutput = c.AggregatedOutput, + ExitCode = c.ExitCode, + Status = c.Status, + }, + ], + }; + break; + + case ItemCompletedEvent { Item: FileChangeItem f }: + yield return new ChatResponseUpdate + { + Contents = + [ + new FileChangeContent + { + Changes = f.Changes, + Status = f.Status, + }, + ], + }; + break; + + case ItemCompletedEvent { Item: McpToolCallItem m }: + yield return new ChatResponseUpdate + { + Contents = + [ + new McpToolCallContent + { + Server = m.Server, + Tool = m.Tool, + Arguments = m.Arguments, + Result = m.Result, + Error = m.Error, + Status = m.Status, + }, + ], + }; + break; + + case ItemCompletedEvent { Item: WebSearchItem w }: + yield return new ChatResponseUpdate + { + Contents = + [ + new WebSearchContent + { + Query = w.Query, + }, + ], + }; + break; + + case ItemCompletedEvent { Item: CollabToolCallItem col }: + yield return new ChatResponseUpdate + { + Contents = + [ + new CollabToolCallContent + { + Tool = col.Tool, + SenderThreadId = col.SenderThreadId, + ReceiverThreadIds = col.ReceiverThreadIds, + AgentsStates = col.AgentsStates, + Status = col.Status, + }, + ], + }; + break; + + case ItemUpdatedEvent { Item: AgentMessageItem msg }: + yield return new ChatResponseUpdate + { + Contents = [new TextContent(msg.Text)], + }; + break; + + case TurnCompletedEvent tc: + yield return new ChatResponseUpdate + { + FinishReason = ChatFinishReason.Stop, + Contents = + [ + new UsageContent(new UsageDetails + { + InputTokenCount = tc.Usage.InputTokens, + OutputTokenCount = tc.Usage.OutputTokens, + TotalTokenCount = tc.Usage.InputTokens + tc.Usage.OutputTokens, + }), + ], + }; + break; + + case TurnFailedEvent tf: + throw new InvalidOperationException(tf.Error.Message); + + case ThreadErrorEvent te: + throw new InvalidOperationException(te.Message); + } + } + } +} diff --git a/CodexSharpSDK.Extensions.AI/Properties/AssemblyInfo.cs b/CodexSharpSDK.Extensions.AI/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..6b0f064 --- /dev/null +++ b/CodexSharpSDK.Extensions.AI/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("ManagedCode.CodexSharpSDK.Extensions.AI.Tests")] diff --git a/Directory.Build.props b/Directory.Build.props index fb5f39e..252025d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -28,7 +28,7 @@ true true true - 0.1.0 + 0.1.1 $(Version) diff --git a/Directory.Packages.props b/Directory.Packages.props index d8d04e4..b6d4860 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,9 @@ + + + diff --git a/ManagedCode.CodexSharpSDK.slnx b/ManagedCode.CodexSharpSDK.slnx index deb146f..cfece02 100644 --- a/ManagedCode.CodexSharpSDK.slnx +++ b/ManagedCode.CodexSharpSDK.slnx @@ -1,4 +1,6 @@ + + diff --git a/README.md b/README.md index a299b1e..9906413 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,93 @@ var resumed = client.ResumeThread("thread_123"); await resumed.RunAsync("Continue from previous plan"); ``` +## Microsoft.Extensions.AI Integration + +An optional adapter package lets you use CodexSharpSDK through the standard `IChatClient` interface from `Microsoft.Extensions.AI`. + +```bash +dotnet add package ManagedCode.CodexSharpSDK.Extensions.AI +``` + +### Basic usage + +```csharp +using Microsoft.Extensions.AI; +using ManagedCode.CodexSharpSDK.Extensions.AI; + +IChatClient client = new CodexChatClient(new CodexChatClientOptions +{ + DefaultModel = CodexModels.Gpt53Codex, +}); + +var response = await client.GetResponseAsync("Diagnose failing tests and propose a fix"); +Console.WriteLine(response.Text); +``` + +### DI registration + +```csharp +using ManagedCode.CodexSharpSDK.Extensions.AI.Extensions; + +builder.Services.AddCodexChatClient(options => +{ + options.DefaultModel = CodexModels.Gpt53Codex; +}); + +// Then inject IChatClient anywhere: +app.MapGet("/ask", async (IChatClient client) => +{ + var response = await client.GetResponseAsync("Summarize the repo"); + return response.Text; +}); +``` + +### Streaming + +```csharp +await foreach (var update in client.GetStreamingResponseAsync("Implement the fix")) +{ + Console.Write(update.Text); +} +``` + +### Codex-specific options via ChatOptions + +```csharp +var options = new ChatOptions +{ + ModelId = CodexModels.Gpt53Codex, + AdditionalProperties = new AdditionalPropertiesDictionary + { + ["codex:sandbox_mode"] = "workspace-write", + ["codex:reasoning_effort"] = "high", + }, +}; + +var response = await client.GetResponseAsync("Refactor the auth module", options); +``` + +### Rich content types + +Codex-specific output items (commands, file changes, MCP tool calls, web searches) are preserved as typed `AIContent` subclasses: + +```csharp +foreach (var content in response.Messages.SelectMany(m => m.Contents)) +{ + switch (content) + { + case CommandExecutionContent cmd: + Console.WriteLine($"Command: {cmd.Command} (exit {cmd.ExitCode})"); + break; + case FileChangeContent file: + Console.WriteLine($"File changes: {file.Changes.Count}"); + break; + } +} +``` + +See [docs/Features/meai-integration.md](docs/Features/meai-integration.md) and [ADR 003](docs/ADR/003-microsoft-extensions-ai-integration.md) for full details. + ## Build and Test ```bash diff --git a/docs/ADR/003-microsoft-extensions-ai-integration.md b/docs/ADR/003-microsoft-extensions-ai-integration.md new file mode 100644 index 0000000..9ffb374 --- /dev/null +++ b/docs/ADR/003-microsoft-extensions-ai-integration.md @@ -0,0 +1,74 @@ +# ADR 003: Microsoft.Extensions.AI Integration + +- Status: Accepted +- Date: 2026-03-05 + +## Context + +CodexSharpSDK wraps the Codex CLI with a bespoke API surface (`CodexClient`/`CodexThread`/`RunResult`). The .NET ecosystem has standardized on `Microsoft.Extensions.AI` abstractions (`IChatClient`) for provider-agnostic AI integration with composable middleware pipelines. + +## Decision + +Implement `IChatClient` from `Microsoft.Extensions.AI.Abstractions` in a **separate NuGet package** (`ManagedCode.CodexSharpSDK.Extensions.AI`) that adapts the existing SDK types without modifying the core SDK. + +### Key design choices + +1. **Separate package** — Core SDK remains M.E.AI-free. The adapter is opt-in, following the pattern of `Microsoft.Extensions.AI.OpenAI` being separate from `OpenAI`. + +2. **Custom AIContent types** — Rich Codex items (command execution, file changes, MCP tool calls, web searches, multi-agent collaboration) are surfaced as typed `AIContent` subclasses rather than being flattened to text. This preserves full fidelity of Codex output. + +3. **Codex-specific options via AdditionalProperties** — Standard `ChatOptions` properties (`ModelId`, `ConversationId`) map directly. Codex-unique features use `codex:*` prefixed keys in `ChatOptions.AdditionalProperties` (e.g., `codex:sandbox_mode`, `codex:reasoning_effort`). + +4. **Thread-per-call with ConversationId resume** — Each `GetResponseAsync` call creates or resumes a `CodexThread`. Thread ID flows via `ChatResponse.ConversationId` for multi-turn continuity. + +5. **No AITool support** — Codex CLI manages tools internally (commands, file changes, MCP). Consumer-registered `ChatOptions.Tools` are ignored; tool results surface as custom `AIContent` types instead. + +## Diagram + +```mermaid +flowchart LR + Consumer["Consumer code\n(IChatClient)"] + Adapter["CodexChatClient\n(Extensions.AI)"] + Core["CodexClient\n(Core SDK)"] + CLI["codex exec --json"] + + Consumer --> Adapter + Adapter --> Core + Core --> CLI + + subgraph "M.E.AI Middleware (free)" + Logging["UseLogging()"] + Cache["UseDistributedCache()"] + Telemetry["UseOpenTelemetry()"] + end + + Consumer -.-> Logging + Logging -.-> Cache + Cache -.-> Telemetry + Telemetry -.-> Adapter +``` + +## Consequences + +### Positive + +- SDK participates in .NET AI ecosystem: DI registration, middleware pipelines, provider swapping. +- Consumers get logging, caching, and telemetry for free via M.E.AI middleware. +- Rich Codex items preserved as typed content, not lost. + +### Negative + +- Impedance mismatch: Codex is an agentic coding tool, not a simple chat API. Multi-turn via message history doesn't map cleanly (uses thread resume instead). +- No temperature/topP/topK (Codex uses `ModelReasoningEffort`). +- Streaming is item-level, not token-level. + +### Neutral + +- Additional NuGet package to maintain. +- `ChatOptions.Tools` is a no-op; documented as limitation. + +## Alternatives considered + +- Implement `IChatClient` directly in core SDK: rejected to avoid mandatory M.E.AI dependency. +- Flatten all Codex items to `TextContent`: rejected to preserve rich output fidelity. +- Map Codex commands/file changes as `FunctionCallContent`: rejected because tools are internal to CLI, not consumer-invocable. diff --git a/docs/Architecture/Overview.md b/docs/Architecture/Overview.md index 0b9202a..b72a535 100644 --- a/docs/Architecture/Overview.md +++ b/docs/Architecture/Overview.md @@ -28,6 +28,7 @@ flowchart LR PARSER["Protocol Parsing\nThreadEventParser + Events/Items"] IO["Config & Schema IO\nTomlConfigSerializer + OutputSchemaFile"] META["CLI Metadata\nCodexCliMetadataReader"] + MEAI["M.E.AI Adapter\nCodexChatClient : IChatClient"] TESTS["TUnit Tests"] CI["GitHub Actions\nCI / Release / CLI Watch"] @@ -36,7 +37,9 @@ flowchart LR API --> META EXEC --> PARSER PARSER --> API + MEAI --> API TESTS --> API + TESTS --> MEAI TESTS --> EXEC CI --> TESTS ``` @@ -86,6 +89,7 @@ flowchart LR - `Protocol Parsing` — code: [ThreadEventParser.cs](../../CodexSharpSDK/Internal/ThreadEventParser.cs), [CodexProtocolConstants.cs](../../CodexSharpSDK/Internal/CodexProtocolConstants.cs), [Events.cs](../../CodexSharpSDK/Models/Events.cs), [Items.cs](../../CodexSharpSDK/Models/Items.cs) - `Config & Schema IO` — code: [TomlConfigSerializer.cs](../../CodexSharpSDK/Internal/TomlConfigSerializer.cs), [OutputSchemaFile.cs](../../CodexSharpSDK/Internal/OutputSchemaFile.cs), [CodexOptions.cs](../../CodexSharpSDK/Configuration/CodexOptions.cs) - `CLI Metadata` — code: [CodexCliMetadataReader.cs](../../CodexSharpSDK/Internal/CodexCliMetadataReader.cs), [CodexCliMetadata.cs](../../CodexSharpSDK/Models/CodexCliMetadata.cs); docs: [cli-metadata.md](../Features/cli-metadata.md) +- `M.E.AI Adapter` — code: [CodexSharpSDK.Extensions.AI](../../CodexSharpSDK.Extensions.AI); docs: [meai-integration.md](../Features/meai-integration.md); ADR: [003-microsoft-extensions-ai-integration.md](../ADR/003-microsoft-extensions-ai-integration.md) - `Testing` — code: [CodexSharpSDK.Tests](../../CodexSharpSDK.Tests); docs: [strategy.md](../Testing/strategy.md) - `Automation` — workflows: [.github/workflows](../../.github/workflows) (including `real-integration.yml` and `codex-cli-watch.yml`); docs: [release-and-sync-automation.md](../Features/release-and-sync-automation.md) @@ -107,9 +111,12 @@ flowchart LR - Allowed dependencies: - `CodexSharpSDK.Tests/*` -> `CodexSharpSDK/*` + - `CodexSharpSDK.Extensions.AI/*` -> `CodexSharpSDK/*` + - `CodexSharpSDK.Extensions.AI.Tests/*` -> `CodexSharpSDK.Extensions.AI/*`, `CodexSharpSDK/*` - Public API (`CodexClient`, `CodexThread`) -> internal execution/parsing helpers. - Forbidden dependencies: - No dependency from `CodexSharpSDK/*` to `CodexSharpSDK.Tests/*`. + - No dependency from `CodexSharpSDK/*` to `CodexSharpSDK.Extensions.AI/*` (adapter is opt-in). - No runtime dependency on `submodules/openai-codex`; submodule is reference-only. - Integration style: - sync configuration + async process stream consumption (`IAsyncEnumerable`) @@ -119,6 +126,7 @@ flowchart LR - [001-codex-cli-wrapper.md](../ADR/001-codex-cli-wrapper.md) — wrap Codex CLI process as SDK transport. - [002-protocol-parsing-and-thread-serialization.md](../ADR/002-protocol-parsing-and-thread-serialization.md) — explicit protocol constants and serialized per-thread turn execution. +- [003-microsoft-extensions-ai-integration.md](../ADR/003-microsoft-extensions-ai-integration.md) — IChatClient adapter in separate package. ## 5) Where to go next diff --git a/docs/Features/meai-integration.md b/docs/Features/meai-integration.md new file mode 100644 index 0000000..bbfda64 --- /dev/null +++ b/docs/Features/meai-integration.md @@ -0,0 +1,112 @@ +# Feature: Microsoft.Extensions.AI Integration + +Links: +Architecture: [docs/Architecture/Overview.md](../Architecture/Overview.md) +Modules: [CodexChatClient.cs](../../CodexSharpSDK.Extensions.AI/CodexChatClient.cs) +ADRs: [003-microsoft-extensions-ai-integration.md](../ADR/003-microsoft-extensions-ai-integration.md) + +--- + +## Purpose + +Enable CodexSharpSDK to participate as a first-class provider in the `Microsoft.Extensions.AI` ecosystem by implementing `IChatClient`, unlocking DI registration, middleware pipelines, and provider-agnostic consumer code. + +--- + +## Scope + +### In scope + +- `IChatClient` implementation (`CodexChatClient`) adapting `CodexClient`/`CodexThread` +- Input mapping: `ChatMessage[]` → Codex prompt + images +- Output mapping: `RunResult` → `ChatResponse` with `UsageDetails` and rich content +- Streaming: `ThreadEvent` → `ChatResponseUpdate` mapping +- Custom `AIContent` types for Codex-specific items (commands, file changes, MCP, web search, collab) +- DI registration via `AddCodexChatClient()` / `AddKeyedCodexChatClient()` +- Codex-specific options via `ChatOptions.AdditionalProperties` with `codex:*` prefix + +### Out of scope + +- `IEmbeddingGenerator` (Codex CLI is not an embedding service) +- `IImageGenerator` (Codex CLI is not an image generator) +- Consumer-side `AITool` registration (Codex manages tools internally) +- `Temperature`, `TopP`, `TopK` mapping (Codex uses `ModelReasoningEffort`) + +--- + +## Business Rules + +- `ChatOptions.ModelId` maps to `ThreadOptions.Model`. +- `ChatOptions.ConversationId` triggers thread resume via `ResumeThread(id)`. +- Multiple `ChatMessage` entries are concatenated into a single prompt (Codex CLI is single-prompt-per-turn). +- `ChatOptions.Tools` is silently ignored; tool results surface as custom `AIContent` types. +- `GetService()` returns provider name `"CodexCLI"` with default model from options. +- Streaming events map item-level, not token-level. +- Turn failures (`TurnFailedEvent`) propagate as `InvalidOperationException`. + +--- + +## User Flows + +### Primary flows + +1. Basic chat completion + - Actor: Consumer code using `IChatClient` + - Trigger: `client.GetResponseAsync([new ChatMessage(ChatRole.User, "prompt")])` + - Steps: map messages → create thread → RunAsync → map result + - Result: `ChatResponse` with text, usage, thread ID as ConversationId + +2. Streaming + - Trigger: `client.GetStreamingResponseAsync(messages)` + - Steps: map messages → create thread → RunStreamedAsync → stream events as ChatResponseUpdate + - Result: `IAsyncEnumerable` with incremental content + +3. Multi-turn resume + - Trigger: `client.GetResponseAsync(messages, new ChatOptions { ConversationId = "thread-123" })` + - Steps: resume thread with ID → RunAsync → map result + - Result: Continuation in existing Codex conversation + +--- + +## Diagrams + +```mermaid +flowchart LR + Input["IEnumerable"] + MsgMapper["ChatMessageMapper"] + OptMapper["ChatOptionsMapper"] + Thread["CodexThread.RunAsync"] + RespMapper["ChatResponseMapper"] + Output["ChatResponse"] + + Input --> MsgMapper + MsgMapper --> Thread + OptMapper --> Thread + Thread --> RespMapper + RespMapper --> Output +``` + +--- + +## Verification + +### Test commands + +- build: `dotnet build ManagedCode.CodexSharpSDK.slnx -c Release -warnaserror` +- test: `dotnet test --solution ManagedCode.CodexSharpSDK.slnx -c Release` +- format: `dotnet format ManagedCode.CodexSharpSDK.slnx` + +### Test mapping + +- Mapper tests: `CodexSharpSDK.Extensions.AI.Tests/ChatMessageMapperTests.cs`, `ChatOptionsMapperTests.cs`, `ChatResponseMapperTests.cs`, `StreamingEventMapperTests.cs` +- DI tests: `CodexSharpSDK.Extensions.AI.Tests/CodexServiceCollectionExtensionsTests.cs` + +--- + +## Definition of Done + +- `CodexChatClient` implements `IChatClient` with full mapper coverage. +- DI extensions register client correctly. +- All mapper and DI tests pass. +- ADR and feature docs created. +- Architecture overview updated. diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..765346e --- /dev/null +++ b/nuget.config @@ -0,0 +1,7 @@ + + + + + + +