-
Notifications
You must be signed in to change notification settings - Fork 1
Description
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 (CodexClient → CodexThread → RunAsync). Any code written against it is locked to the Codex-specific types. By implementing IChatClient, consumers can:
- Swap providers — code against
IChatClientand swap between Codex CLI, direct OpenAI API, Ollama, etc. without code changes - Compose middleware — get
UseLogging(),UseOpenTelemetry(),UseDistributedCache()fromMicrosoft.Extensions.AIfor free - Register via DI —
builder.Services.AddCodexChatClient(...)withChatClientBuilderpipeline - Interop — any library accepting
IChatClient(e.g., Microsoft Agent Framework) works automatically
Reference Material
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: RunResult → ChatResponse
| 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: ThreadEvent → ChatResponseUpdate
| 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)
- Single prompt per turn — Codex CLI accepts one prompt per
execcall. MultipleChatMessageentries are concatenated, not sent as separate conversation turns. True multi-turn happens via thread resume (ConversationId). - No temperature/topP/topK — Codex uses
ModelReasoningEffortinstead. Set viaChatOptions.AdditionalProperties["codex:reasoning_effort"]. - Tool calling is internal — Codex CLI manages its own tools (commands, file changes, MCP, web search).
ChatOptions.Toolsis ignored. Rich tool results surface as customAIContenttypes. - Process per turn — Each call spawns a CLI process. Response caching via
UseDistributedCache()is especially valuable. - Streaming is item-level — Codex JSONL events are item-granularity, not token-level. Updates will be chunkier than typical LLM streaming.
- CLI must be installed — Requires
codexCLI in PATH and authenticated viacodex login.
Acceptance Criteria
- New
CodexSharpSDK.Extensions.AIproject added to solution -
CodexChatClientimplementsIChatClientwithGetResponseAsync,GetStreamingResponseAsync,GetService -
ChatMessage→ Codex input mapping handlesTextContent,DataContent(images), system/user/assistant roles -
RunResult→ChatResponsemapping includesUsageDetails,ConversationId,TextContent,TextReasoningContent - Custom
AIContenttypes forCommandExecutionItem,FileChangeItem,McpToolCallItem,WebSearchItem,CollabToolCallItem - Streaming maps
ThreadEvent→ChatResponseUpdatecorrectly -
ChatOptions.ModelIdmaps toThreadOptions.Model -
ChatOptions.ConversationIdtriggers thread resume - Codex-specific options work via
AdditionalPropertieswithcodex:*prefix -
AddCodexChatClient()/AddKeyedCodexChatClient()DI extensions work and returnChatClientBuilder - Unit tests for all mappers (message, options, response, streaming)
- Integration tests verifying
CodexChatClientwith 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.mdupdated with new module - Builds with
-warnaserror, passesdotnet 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]
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