Skip to content

Implement Microsoft.Extensions.AI (IChatClient) integration #1

@luisquintanilla

Description

@luisquintanilla

Implement Microsoft.Extensions.AI (IChatClient) integration for CodexSharpSDK

Summary

Create a new NuGet package ManagedCode.CodexSharpSDK.Extensions.AI that implements the IChatClient interface from Microsoft.Extensions.AI.Abstractions, enabling CodexSharpSDK to participate as a first-class provider in the .NET AI ecosystem. This unlocks DI registration, middleware pipelines (logging, caching, telemetry), and provider-agnostic consumer code for free.

Motivation

Today, CodexSharpSDK has a bespoke API (CodexClientCodexThreadRunAsync). Any code written against it is locked to the Codex-specific types. By implementing IChatClient, consumers can:

  • Swap providers — code against IChatClient and swap between Codex CLI, direct OpenAI API, Ollama, etc. without code changes
  • Compose middleware — get UseLogging(), UseOpenTelemetry(), UseDistributedCache() from Microsoft.Extensions.AI for free
  • Register via DIbuilder.Services.AddCodexChatClient(...) with ChatClientBuilder pipeline
  • Interop — any library accepting IChatClient (e.g., Microsoft Agent Framework) works automatically

Reference Material

Resource URL
M.E.AI docs (overview) https://learn.microsoft.com/dotnet/ai/microsoft-extensions-ai
IChatClient usage guide https://learn.microsoft.com/dotnet/ai/ichatclient
IChatClient API reference https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.ichatclient
ChatMessage API reference https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.chatmessage
ChatResponse API reference https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.chatresponse
ChatResponseUpdate API ref https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.chatresponseupdate
ChatOptions API reference https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.chatoptions
ChatClientMetadata API ref https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.chatclientmetadata
UsageDetails API reference https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.usagedetails
TextReasoningContent API ref https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.textreasoningcontent
DelegatingChatClient API ref https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.delegatingchatclient
ChatClientBuilder API ref https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.chatclientbuilder
AddChatClient DI registration https://learn.microsoft.com/dotnet/api/microsoft.extensions.ai.chatclientbuilderservicecollectionextensions.addchatclient
NuGet: M.E.AI.Abstractions 10.3.0 https://www.nuget.org/packages/Microsoft.Extensions.AI.Abstractions/10.3.0
NuGet: M.E.AI 10.3.0 https://www.nuget.org/packages/Microsoft.Extensions.AI/10.3.0
OpenAI provider reference impl https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIChatClient.cs
OpenAI provider project structure https://github.com/dotnet/extensions/tree/main/src/Libraries/Microsoft.Extensions.AI.OpenAI
Sample IChatClient impl (docs) https://learn.microsoft.com/dotnet/ai/ichatclient#implementation-examples
Stateful vs stateless clients https://learn.microsoft.com/dotnet/ai/ichatclient#stateless-vs-stateful-clients
DeepWiki: dotnet/extensions overview https://deepwiki.com/dotnet/extensions
CodexSharpSDK Architecture Overview docs/Architecture/Overview.md in this repo
CodexSharpSDK Thread Run Flow docs/Features/thread-run-flow.md in this repo
ADR 001: CLI Wrapper Transport docs/ADR/001-codex-cli-wrapper.md in this repo

Architecture

Separate Package

Create ManagedCode.CodexSharpSDK.Extensions.AI as a separate project and NuGet package. The core SDK remains M.E.AI-free. This follows the established pattern (e.g., Microsoft.Extensions.AI.OpenAI is separate from the OpenAI NuGet package).

Package Dependencies

<PackageReference Include="Microsoft.Extensions.AI.Abstractions" Version="10.3.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<!-- Project reference to core SDK -->
<ProjectReference Include="..\CodexSharpSDK\CodexSharpSDK.csproj" />

Project Structure

CodexSharpSDK.Extensions.AI/
├── CodexSharpSDK.Extensions.AI.csproj
├── CodexChatClient.cs                          # IChatClient implementation
├── CodexChatClientOptions.cs                   # Adapter configuration
├── Content/
│   ├── CommandExecutionContent.cs              # AIContent for shell commands
│   ├── FileChangeContent.cs                    # AIContent for file patches
│   ├── McpToolCallContent.cs                   # AIContent for MCP tool calls
│   ├── WebSearchContent.cs                     # AIContent for web searches
│   └── CollabToolCallContent.cs                # AIContent for multi-agent collab
├── Internal/
│   ├── ChatMessageMapper.cs                    # ChatMessage[] → Codex input
│   ├── ChatResponseMapper.cs                   # RunResult → ChatResponse
│   ├── ChatOptionsMapper.cs                    # ChatOptions → ThreadOptions/TurnOptions
│   └── StreamingEventMapper.cs                 # ThreadEvent → ChatResponseUpdate
├── Extensions/
│   └── CodexServiceCollectionExtensions.cs     # AddCodexChatClient() DI extensions
└── Properties/
    └── AssemblyInfo.cs

Detailed Type Mapping

Input: ChatMessage[] → Codex Input

The IChatClient contract passes IEnumerable<ChatMessage> where each message has a Role and Contents collection. Codex CLI accepts a single prompt string + optional images per turn.

Mapping rules:

M.E.AI Source Codex Target Notes
ChatMessage(ChatRole.System, ...) with TextContent Prepend to prompt with [System] prefix Codex CLI has no separate system message concept; prepend to prompt
ChatMessage(ChatRole.User, ...) with TextContent Concatenate as main prompt text Join multiple user messages with \n\n
ChatMessage(ChatRole.User, ...) with DataContent where MediaType starts with "image/" LocalImageInput Materialize ReadOnlyMemory<byte> to temp file, or use URI if available
ChatMessage(ChatRole.Assistant, ...) Append to prompt as context: [Assistant] {text} For multi-turn conversation simulation
ChatOptions.ConversationId (non-null) client.ResumeThread(conversationId) Resume existing Codex thread instead of starting new one
ChatOptions.ConversationId (null) client.StartThread(...) Start fresh thread

Implementation reference: See how OpenAIChatClient converts ChatMessage to provider-specific format at OpenAIChatClient.cs.

Output: RunResultChatResponse

Codex Source M.E.AI Target Notes
RunResult.FinalResponse ChatMessage(ChatRole.Assistant, [TextContent(...)]) Primary response text
RunResult.Usage.InputTokens UsageDetails.InputTokenCount Direct mapping
RunResult.Usage.OutputTokens UsageDetails.OutputTokenCount Direct mapping
RunResult.Usage.InputTokens + OutputTokens UsageDetails.TotalTokenCount Computed sum
RunResult.Usage.CachedInputTokens UsageDetails.AdditionalCounts["CachedInputTokens"] Codex-specific usage detail
Thread ID (from ThreadStartedEvent) ChatResponse.ConversationId Enables resume on next call
ReasoningItem in RunResult.Items TextReasoningContent in message Contents M.E.AI has native reasoning content support (docs)
CommandExecutionItem CommandExecutionContent (custom AIContent) See Custom Content Types
FileChangeItem FileChangeContent (custom AIContent)
McpToolCallItem McpToolCallContent (custom AIContent)
WebSearchItem WebSearchContent (custom AIContent)
CollabToolCallItem CollabToolCallContent (custom AIContent)
TodoListItem Include as TextContent summary or skip Low priority
ErrorItem ErrorContent (built-in M.E.AI type) If available, else TextContent

Streaming: ThreadEventChatResponseUpdate

Codex Event ChatResponseUpdate Mapping
ThreadStartedEvent Yield update with ConversationId = threadId set
ItemCompletedEvent(AgentMessageItem) Yield update with TextContent
ItemCompletedEvent(ReasoningItem) Yield update with TextReasoningContent
ItemCompletedEvent(CommandExecutionItem) Yield update with CommandExecutionContent
ItemCompletedEvent(FileChangeItem) Yield update with FileChangeContent
ItemUpdatedEvent(AgentMessageItem) Yield update with partial TextContent (streaming text delta)
TurnCompletedEvent Yield final update with FinishReason = ChatFinishReason.Stop and UsageContent
TurnFailedEvent Throw InvalidOperationException with error message
ThreadErrorEvent Throw InvalidOperationException with error message

ChatOptions → Codex Options

ChatOptions Property Codex Target Notes
ModelId ThreadOptions.Model Direct mapping
ConversationId Thread resume Non-null → ResumeThread(id), null → StartThread()
ResponseFormat (JSON schema) TurnOptions.OutputSchema Convert ChatResponseFormatJson schema to StructuredOutputSchema
MaxOutputTokens Not supported Document as limitation
Temperature, TopP, TopK Not supported Codex uses ModelReasoningEffort instead
StopSequences Not supported CLI doesn't expose this
Tools Ignored Codex manages tools internally; document this

Codex-specific options via ChatOptions.AdditionalProperties:

Key Type Maps To
"codex:sandbox_mode" SandboxMode ThreadOptions.SandboxMode
"codex:working_directory" string ThreadOptions.WorkingDirectory
"codex:reasoning_effort" ModelReasoningEffort ThreadOptions.ModelReasoningEffort
"codex:network_access" bool ThreadOptions.NetworkAccessEnabled
"codex:web_search" WebSearchMode ThreadOptions.WebSearchMode
"codex:approval_policy" ApprovalMode ThreadOptions.ApprovalPolicy
"codex:full_auto" bool ThreadOptions.FullAuto
"codex:ephemeral" bool ThreadOptions.Ephemeral
"codex:profile" string ThreadOptions.Profile
"codex:skip_git_repo_check" bool ThreadOptions.SkipGitRepoCheck
"codex:additional_directories" IReadOnlyList<string> ThreadOptions.AdditionalDirectories

Custom AIContent Types

Create Codex-specific AIContent subclasses for rich item types that have no M.E.AI equivalent. This preserves the full fidelity of Codex output within the M.E.AI type system.

All custom content types should follow this pattern:

// Example: CommandExecutionContent.cs
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; }
}
Content Type Wraps Key Properties
CommandExecutionContent CommandExecutionItem Command, AggregatedOutput, ExitCode, Status
FileChangeContent FileChangeItem Changes (list of path+kind), Status
McpToolCallContent McpToolCallItem Server, Tool, Arguments, Result, Error, Status
WebSearchContent WebSearchItem Query
CollabToolCallContent CollabToolCallItem Tool, SenderThreadId, ReceiverThreadIds, AgentsStates, Status

These types allow consumers to inspect rich Codex operations:

foreach (var content in response.Messages.SelectMany(m => m.Contents))
{
    switch (content)
    {
        case TextContent text:
            Console.WriteLine(text.Text);
            break;
        case CommandExecutionContent cmd:
            Console.WriteLine($"Executed: {cmd.Command} → exit {cmd.ExitCode}");
            break;
        case FileChangeContent fc:
            Console.WriteLine($"Changed {fc.Changes.Count} files");
            break;
    }
}

CodexChatClient Implementation

Skeleton

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<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        var (input, images) = 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(input, images);
            var result = await thread.RunAsync(userInput, turnOptions).ConfigureAwait(false);
            return ChatResponseMapper.ToChatResponse(result, thread.Id);
        }
    }

    public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        var (input, images) = 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(input, images);
            var streamed = await thread.RunStreamedAsync(userInput, turnOptions)
                .ConfigureAwait(false);

            await foreach (var update in StreamingEventMapper.ToUpdates(streamed.Events, 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))
        {
            var metadata = _client.GetCliMetadata();
            return new ChatClientMetadata(
                providerName: "CodexCLI",
                providerUri: null,
                defaultModelId: metadata.DefaultModel ?? _options.DefaultModel);
        }

        if (serviceType.IsInstanceOfType(this)) return this;

        return null;
    }

    public void Dispose() => _client.Dispose();
}

CodexChatClientOptions

public sealed record CodexChatClientOptions
{
    /// <summary>Core Codex SDK options (executable path, API key, logger, etc.).</summary>
    public CodexOptions? CodexOptions { get; init; }

    /// <summary>Default model when ChatOptions.ModelId is not specified.</summary>
    public string? DefaultModel { get; init; }

    /// <summary>Default thread options applied to every call unless overridden via ChatOptions.</summary>
    public ThreadOptions? DefaultThreadOptions { get; init; }
}

DI Registration Extensions

public static class CodexServiceCollectionExtensions
{
    public static ChatClientBuilder AddCodexChatClient(
        this IServiceCollection services,
        Action<CodexChatClientOptions>? configure = null)
    {
        var options = new CodexChatClientOptions();
        configure?.Invoke(options);
        return services.AddChatClient(new CodexChatClient(options));
    }

    public static ChatClientBuilder AddKeyedCodexChatClient(
        this IServiceCollection services,
        object serviceKey,
        Action<CodexChatClientOptions>? configure = null)
    {
        var options = new CodexChatClientOptions();
        configure?.Invoke(options);
        return services.AddKeyedChatClient(serviceKey, new CodexChatClient(options));
    }
}

This enables the full middleware pipeline pattern:

builder.Services.AddCodexChatClient(options =>
{
    options.DefaultModel = CodexModels.Gpt53Codex;
    options.DefaultThreadOptions = new ThreadOptions
    {
        SandboxMode = SandboxMode.WorkspaceWrite,
        ModelReasoningEffort = ModelReasoningEffort.High,
    };
})
.UseLogging()
.UseOpenTelemetry()
.UseDistributedCache(cache);

Reference: See AddChatClient docs and M.E.AI DI registration patterns.


Consumer Usage Examples

Basic (provider-agnostic)

IChatClient client = new CodexChatClient(new CodexChatClientOptions
{
    DefaultModel = CodexModels.Gpt53Codex,
});

ChatResponse response = await client.GetResponseAsync("Diagnose failing tests and propose a fix");
Console.WriteLine(response.Text);
Console.WriteLine($"Tokens: {response.Usage?.InputTokenCount} in / {response.Usage?.OutputTokenCount} out");

Streaming

await foreach (var update in client.GetStreamingResponseAsync("Implement the fix"))
{
    Console.Write(update);
}

Multi-turn with thread resume

string? conversationId = null;
var options = new ChatOptions();

// Turn 1
var response = await client.GetResponseAsync("Analyze the codebase", options);
conversationId = response.ConversationId;

// Turn 2 — resumes same Codex thread
options.ConversationId = conversationId;
var response2 = await client.GetResponseAsync("Now fix the bugs you found", options);

With middleware pipeline

using var loggerFactory = LoggerFactory.Create(b => b.AddConsole());
var cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));

IChatClient client = new CodexChatClient(new CodexChatClientOptions
{
    DefaultModel = CodexModels.Gpt53Codex,
})
.AsBuilder()
.UseLogging(loggerFactory)
.UseDistributedCache(cache)
.Build();

var response = await client.GetResponseAsync("Refactor the auth module");

Inspecting rich Codex items

var response = await client.GetResponseAsync("Fix the failing test");

foreach (var message in response.Messages)
{
    foreach (var content in message.Contents)
    {
        switch (content)
        {
            case TextContent text:
                Console.WriteLine($"Response: {text.Text}");
                break;
            case TextReasoningContent reasoning:
                Console.WriteLine($"Reasoning: {reasoning.Text}");
                break;
            case CommandExecutionContent cmd:
                Console.WriteLine($"Ran: {cmd.Command} (exit {cmd.ExitCode})");
                Console.WriteLine(cmd.AggregatedOutput);
                break;
            case FileChangeContent fc:
                foreach (var change in fc.Changes)
                    Console.WriteLine($"  {change.Kind}: {change.Path}");
                break;
        }
    }
}

Known Limitations (Must Document)

  1. Single prompt per turn — Codex CLI accepts one prompt per exec call. Multiple ChatMessage entries are concatenated, not sent as separate conversation turns. True multi-turn happens via thread resume (ConversationId).
  2. No temperature/topP/topK — Codex uses ModelReasoningEffort instead. Set via ChatOptions.AdditionalProperties["codex:reasoning_effort"].
  3. Tool calling is internal — Codex CLI manages its own tools (commands, file changes, MCP, web search). ChatOptions.Tools is ignored. Rich tool results surface as custom AIContent types.
  4. Process per turn — Each call spawns a CLI process. Response caching via UseDistributedCache() is especially valuable.
  5. Streaming is item-level — Codex JSONL events are item-granularity, not token-level. Updates will be chunkier than typical LLM streaming.
  6. CLI must be installed — Requires codex CLI in PATH and authenticated via codex login.

Acceptance Criteria

  • New CodexSharpSDK.Extensions.AI project added to solution
  • CodexChatClient implements IChatClient with GetResponseAsync, GetStreamingResponseAsync, GetService
  • ChatMessage → Codex input mapping handles TextContent, DataContent (images), system/user/assistant roles
  • RunResultChatResponse mapping includes UsageDetails, ConversationId, TextContent, TextReasoningContent
  • Custom AIContent types for CommandExecutionItem, FileChangeItem, McpToolCallItem, WebSearchItem, CollabToolCallItem
  • Streaming maps ThreadEventChatResponseUpdate correctly
  • ChatOptions.ModelId maps to ThreadOptions.Model
  • ChatOptions.ConversationId triggers thread resume
  • Codex-specific options work via AdditionalProperties with codex:* prefix
  • AddCodexChatClient() / AddKeyedCodexChatClient() DI extensions work and return ChatClientBuilder
  • Unit tests for all mappers (message, options, response, streaming)
  • Integration tests verifying CodexChatClient with real Codex CLI
  • ADR document created in docs/ADR/
  • Feature doc created in docs/Features/
  • README updated with M.E.AI usage examples
  • docs/Architecture/Overview.md updated with new module
  • Builds with -warnaserror, passes dotnet format, all tests green

Implementation Order

flowchart TD
    A[1. Create project + csproj] --> B[2. Custom AIContent types]
    A --> C[3. CodexChatClientOptions]
    B --> D[4. ChatMessageMapper]
    B --> E[5. ChatResponseMapper]
    B --> F[6. StreamingEventMapper]
    A --> G[7. ChatOptionsMapper]
    D --> H[8. CodexChatClient]
    E --> H
    F --> H
    G --> H
    C --> H
    H --> I[9. DI extensions]
    H --> J[10. Tests]
    I --> J
    H --> K[11. Docs: ADR + Feature + README + Overview]
Loading

Suggested starting point: Begin with the project scaffolding (step 1) and custom AIContent types (step 2) — these are self-contained and establish the foundation. Then work through the mappers (steps 4–7) which are pure functions with clear inputs/outputs and easy to unit test. The CodexChatClient (step 8) simply wires the mappers together.


Labels

enhancement, architecture, microsoft-extensions-ai

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions