HagiCode Multi-AI Provider Switching and Interoperability Implementation Plan
HagiCode Multi-AI Provider Switching and Interoperability Implementation Plan
Section titled “HagiCode Multi-AI Provider Switching and Interoperability Implementation Plan”In the modern developer-tooling ecosystem, developers often need to use different AI coding assistants to support their work. Anthropic’s Claude Code CLI and OpenAI’s Codex CLI each have their own strengths: Claude is known for outstanding code understanding and long-context handling, while Codex excels at code generation and tool usage.
This article takes an in-depth look at how the HagiCode project achieves seamless switching and interoperability across multiple AI providers, including the core architectural design, key implementation details, and practical considerations.
Background
Section titled “Background”Problem Domain
Section titled “Problem Domain”The core challenge faced by the HagiCode project is supporting multiple AI CLIs on the same platform, so users can:
- Flexibly switch between AI providers based on their needs
- Maintain session continuity during provider switching
- Unify the API differences across different CLIs behind a common abstraction
- Reserve extension points for adding new AI providers in the future
Technical Challenges
Section titled “Technical Challenges”- Unifying interface differences: Claude Code CLI is invoked through command-line calls, while Codex CLI uses a JSON event stream
- Handling streaming responses: Both providers support streaming responses, but with different data formats
- Tool-calling semantics: Claude and Codex differ in how they represent tool calls and manage their lifecycle
- Session lifecycle: The system must correctly manage session creation, restoration, and termination for each provider
Analysis
Section titled “Analysis”Architectural Design Approach
Section titled “Architectural Design Approach”HagiCode uses the Provider Pattern combined with the Factory Pattern to abstract AI service invocation. The core ideas of this design are:
- Unified interface abstraction: Define the
IAIProviderinterface as the common abstraction for all AI providers - Factory-created instances: Use
AIProviderFactoryto dynamically create the corresponding provider instance based on type - Intelligent selection logic: Use
AIProviderSelectorto automatically select the most suitable provider based on scenario and configuration - Session state management: Persist the binding relationship between sessions and CLI threads in the database
Key Components
Section titled “Key Components”| Component | Responsibility | Language |
|---|---|---|
IAIProvider | Unified provider interface | C# |
AIProviderFactory | Create and manage provider instances | C# |
AIProviderSelector | Select providers intelligently | C# |
ClaudeCodeCliProvider | Claude Code CLI implementation | C# |
CodexCliProvider | Codex CLI implementation | C# |
AgentCliManager | Desktop-side CLI management | TypeScript |
Solution
Section titled “Solution”1. Core Interface Design
Section titled “1. Core Interface Design”The IAIProvider interface defines the unified provider abstraction:
public interface IAIProvider{ /// <summary> /// Provider display name /// </summary> string Name { get; }
/// <summary> /// Whether streaming responses are supported /// </summary> bool SupportsStreaming { get; }
/// <summary> /// Provider capability description /// </summary> ProviderCapabilities Capabilities { get; }
/// <summary> /// Execute a single AI request /// </summary> Task<AIResponse> ExecuteAsync(AIRequest request, CancellationToken cancellationToken = default);
/// <summary> /// Execute a streaming AI request /// </summary> IAsyncEnumerable<AIStreamingChunk> StreamAsync(AIRequest request, CancellationToken cancellationToken = default);
/// <summary> /// Check provider connectivity and responsiveness /// </summary> Task<ProviderTestResult> PingAsync(CancellationToken cancellationToken = default);
/// <summary> /// Send a message with an embedded command /// </summary> IAsyncEnumerable<AIStreamingChunk> SendMessageAsync( AIRequest request, string? embeddedCommandPrompt = null, CancellationToken cancellationToken = default);}Key characteristics of this interface design:
- Unified request/response model: All providers use the same
AIRequestandAIResponsetypes - Streaming support: Standardize streaming output through
IAsyncEnumerable<AIStreamingChunk> - Capability description:
ProviderCapabilitiesdescribes the features supported by the provider (streaming, tools, maximum tokens, and so on) - Embedded commands:
SendMessageAsyncsupports embedding OpenSpec commands into prompts
2. Provider Type Enumeration
Section titled “2. Provider Type Enumeration”public enum AIProviderType{ ClaudeCodeCli, // Anthropic Claude Code OpenCodeCli, // Other CLIs (extensible) GitHubCopilot, // GitHub Copilot CodebuddyCli, // Codebuddy CodexCli // OpenAI Codex}This enum provides a type-safe representation for all providers supported by the system.
3. Factory Pattern Implementation
Section titled “3. Factory Pattern Implementation”The AIProviderFactory is responsible for creating and managing provider instances:
public class AIProviderFactory : IAIProviderFactory{ private readonly ConcurrentDictionary<AIProviderType, IAIProvider> _cache; private readonly IOptions<AIProviderOptions> _options; private readonly IServiceProvider _serviceProvider;
public Task<IAIProvider?> GetProviderAsync(AIProviderType providerType) { // Use caching to avoid duplicate creation if (_cache.TryGetValue(providerType, out var cached)) return Task.FromResult<IAIProvider?>(cached);
// Get provider configuration from settings var aiOptions = _options.Value; if (!aiOptions.Providers.TryGetValue(providerType, out var config)) { _logger.LogWarning("Provider '{ProviderType}' not found in configuration", providerType); return Task.FromResult<IAIProvider?>(null); }
// Create provider by type var provider = providerType switch { AIProviderType.ClaudeCodeCli => _serviceProvider.GetService(typeof(ClaudeCodeCliProvider)) as IAIProvider, AIProviderType.CodexCli => _serviceProvider.GetService(typeof(CodexCliProvider)) as IAIProvider, AIProviderType.GitHubCopilot => _serviceProvider.GetService(typeof(CopilotAIProvider)) as IAIProvider, _ => null };
if (provider != null) { _cache[providerType] = provider; }
return Task.FromResult<IAIProvider?>(provider); }}Advantages of the factory pattern:
- Instance caching: Avoid repeatedly creating the same type of provider
- Dependency injection: Create instances through
IServiceProvider, with dependency injection support - Configuration-driven: Read provider settings from configuration files
- Exception handling: Return
nullwhen creation fails, making it easier for upper layers to handle errors
4. Intelligent Selector
Section titled “4. Intelligent Selector”The AIProviderSelector implements provider-selection strategies:
public class AIProviderSelector : IAIProviderSelector{ private readonly BusinessLayerConfiguration _configuration; private readonly IAIProviderFactory _providerFactory; private readonly IMemoryCache _cache;
public async Task<AIProviderType> SelectProviderAsync( BusinessScenario scenario, CancellationToken cancellationToken = default) { // 1. Try getting a provider from scenario mapping if (_configuration.ScenarioProviderMapping.TryGetValue(scenario, out var providerType)) { if (await IsProviderAvailableAsync(providerType, cancellationToken)) { _logger.LogDebug("Selected provider '{Provider}' for scenario '{Scenario}'", providerType, scenario); return providerType; }
_logger.LogWarning("Configured provider '{Provider}' for scenario '{Scenario}' is not available", providerType, scenario); }
// 2. Try the default provider if (await IsProviderAvailableAsync(_configuration.DefaultProvider, cancellationToken)) { _logger.LogDebug("Using default provider '{Provider}' for scenario '{Scenario}'", _configuration.DefaultProvider, scenario); return _configuration.DefaultProvider; }
// 3. Try the fallback chain foreach (var fallbackProvider in _configuration.FallbackChain) { if (await IsProviderAvailableAsync(fallbackProvider, cancellationToken)) { _logger.LogInformation("Using fallback provider '{Provider}' for scenario '{Scenario}'", fallbackProvider, scenario); return fallbackProvider; } }
// 4. No available provider can be found throw new InvalidOperationException( $"No available AI provider found for scenario '{scenario}'"); }
public async Task<bool> IsProviderAvailableAsync( AIProviderType providerType, CancellationToken cancellationToken = default) { var cacheKey = $"provider_available_{providerType}";
// Use caching to reduce Ping calls if (_configuration.EnableCache && _cache.TryGetValue<bool>(cacheKey, out var cached)) { return cached; }
var provider = await _providerFactory.GetProviderAsync(providerType); var isAvailable = provider != null;
if (_configuration.EnableCache && isAvailable) { _cache.Set(cacheKey, isAvailable, TimeSpan.FromSeconds(_configuration.CacheExpirationSeconds)); }
return isAvailable; }}Selector strategy:
- Scenario mapping first: First check whether the business scenario has a specific provider mapping
- Fallback to default provider: Use the default provider if scenario mapping fails
- Fallback chain as a final safeguard: Try providers in the fallback chain one by one
- Availability caching: Cache provider availability checks to reduce Ping calls
5. Claude Code CLI Provider Implementation
Section titled “5. Claude Code CLI Provider Implementation”public class ClaudeCodeCliProvider : IAIProvider{ private readonly ILogger<ClaudeCodeCliProvider> _logger; private readonly IClaudeStreamManager _streamManager; private readonly ProviderConfiguration _config;
public string Name => "ClaudeCodeCli"; public bool SupportsStreaming => true;
public ProviderCapabilities Capabilities { get; }
public async Task<AIResponse> ExecuteAsync(AIRequest request, CancellationToken cancellationToken = default) { _logger.LogInformation("Executing AI request with provider: {Provider}", Name);
var sessionOptions = ClaudeRequestMapper.MapToSessionOptions(request, _config);
var messages = _streamManager.SendMessageAsync(request.Prompt, sessionOptions, cancellationToken);
var responseBuilder = new StringBuilder(); ResultMessage? finalResult = null;
await foreach (var streamMessage in messages) { switch (streamMessage.Message) { case ResultMessage result: finalResult = result; responseBuilder.Append(result.Result); break; } }
if (finalResult != null) { return ClaudeResponseMapper.MapToAIResponse(finalResult, Name); }
return new AIResponse { Content = responseBuilder.ToString(), FinishReason = FinishReason.Unknown, Provider = Name }; }}Characteristics of the Claude Code CLI provider:
- Streaming manager integration: Use
IClaudeStreamManagerto communicate with the Claude CLI CessionIdsession isolation: UseCessionIdas the unique session identifier, distinct from the systemsessionId- Working directory configuration: Support configuration of the working directory, permission mode, and more
- Tool support: Support tool-permission settings such as
AllowedToolsandDisallowedTools
6. Codex CLI Provider Implementation
Section titled “6. Codex CLI Provider Implementation”public class CodexCliProvider : IAIProvider{ private readonly ILogger<CodexCliProvider> _logger; private readonly CodexSettings _settings; private readonly ConcurrentDictionary<string, string> _sessionThreadBindings;
public string Name => "CodexCli"; public bool SupportsStreaming => true;
public ProviderCapabilities Capabilities { get; }
public async IAsyncEnumerable<AIStreamingChunk> StreamAsync( AIRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) { _logger.LogInformation("Executing streaming AI request with provider: {Provider}", Name);
var codex = CreateCodexClient(); var thread = ResolveThread(codex, request);
var currentTurn = 0; var activeToolCalls = new Dictionary<string, AIToolCallDelta>();
await foreach (var threadEvent in thread.RunStreamedAsync(BuildPrompt(request), cancellationToken)) { if (threadEvent is TurnStartedEvent) { currentTurn++; }
switch (threadEvent) { case ItemCompletedEvent { Item: AgentMessageItem message }: var messageText = message.Text ?? string.Empty; yield return new AIStreamingChunk { Content = messageText, Type = StreamingChunkType.ContentDelta, IsComplete = false }; break;
case ItemStartedEvent or ItemUpdatedEvent or ItemCompletedEvent: var toolChunk = BuildToolChunk(threadEvent, currentTurn); if (toolChunk?.ToolCallDelta != null) { yield return toolChunk; } break;
case TurnCompletedEvent turnCompleted: activeToolCalls.Clear(); yield return new AIStreamingChunk { Content = string.Empty, Type = StreamingChunkType.Metadata, IsComplete = true, Usage = MapUsage(turnCompleted.Usage) }; break; } }
BindSessionThread(request.SessionId, thread.Id); }
private CodexThread ResolveThread(Codex codex, AIRequest request) { var sessionId = request.SessionId;
// Check whether there is already a bound thread if (!string.IsNullOrWhiteSpace(sessionId) && _sessionThreadBindings.TryGetValue(sessionId, out var threadId) && !string.IsNullOrWhiteSpace(threadId)) { _logger.LogInformation("Resuming Codex thread {ThreadId} for session {SessionId}", threadId, sessionId); return codex.ResumeThread(threadId, threadOptions); }
_logger.LogInformation("Starting new Codex thread for session {SessionId}", sessionId ?? "(none)"); return codex.StartThread(threadOptions); }}Characteristics of the Codex CLI provider:
- JSON event-stream handling: Parse Codex JSON event streams (
TurnStarted,ItemStarted,TurnCompleted, and so on) - Session-thread binding: Persist the binding between sessions and threads with an SQLite database
- Thread reuse: Support resuming existing threads to maintain session continuity
- Tool-call tracking: Track active tool-call state and correctly handle the tool lifecycle
7. Session-Thread Binding Mechanism
Section titled “7. Session-Thread Binding Mechanism”Codex CLI uses an SQLite database to persist the binding between sessions and threads:
public class CodexCliProvider : IAIProvider{ private const int SessionThreadBindingRetentionDays = 30; private readonly ConcurrentDictionary<string, string> _sessionThreadBindings; private readonly string _sessionThreadBindingDatabaseConnectionString; private readonly string _sessionThreadBindingDatabasePath;
private void BindSessionThread(string? sessionId, string? threadId) { if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(threadId)) { return; }
// In-memory cache _sessionThreadBindings.AddOrUpdate(sessionId, threadId, (_, _) => threadId);
// Persist to SQLite PersistSessionThreadBinding(sessionId, threadId); }
private void PersistSessionThreadBinding(string sessionId, string threadId) { try { using var connection = new SqliteConnection(_sessionThreadBindingDatabaseConnectionString); connection.Open();
using var upsertCommand = connection.CreateCommand(); upsertCommand.CommandText = """ INSERT INTO SessionThreadBindings (SessionId, ThreadId, CreatedAtUtc, UpdatedAtUtc) VALUES ($sessionId, $threadId, $createdAtUtc, $updatedAtUtc) ON CONFLICT(SessionId) DO UPDATE SET ThreadId = excluded.ThreadId, UpdatedAtUtc = excluded.UpdatedAtUtc; """; var nowUtc = DateTimeOffset.UtcNow.ToString("O"); upsertCommand.Parameters.AddWithValue("$sessionId", sessionId); upsertCommand.Parameters.AddWithValue("$threadId", threadId); upsertCommand.Parameters.AddWithValue("$createdAtUtc", nowUtc); upsertCommand.Parameters.AddWithValue("$updatedAtUtc", nowUtc); upsertCommand.ExecuteNonQuery(); } catch (Exception ex) { _logger.LogWarning( ex, "Failed to persist Codex session-thread binding for session {SessionId} to {DatabasePath}", sessionId, _sessionThreadBindingDatabasePath); } }
private void LoadPersistedSessionThreadBindings() { using var connection = new SqliteConnection(_sessionThreadBindingDatabaseConnectionString); connection.Open();
using var loadCommand = connection.CreateCommand(); loadCommand.CommandText = "SELECT SessionId, ThreadId FROM SessionThreadBindings;"; using var reader = loadCommand.ExecuteReader(); while (reader.Read()) { var sessionId = reader.GetString(0); var threadId = reader.GetString(1); _sessionThreadBindings[sessionId] = threadId; } }}Advantages of session-thread binding:
- Session restoration: Previous sessions can be restored after a system restart
- Thread reuse: The same session can reuse an existing Codex thread
- Automatic cleanup: Bindings older than 30 days are cleaned up automatically
8. Desktop-Side CLI Management
Section titled “8. Desktop-Side CLI Management”hagicode-desktop manages CLI selection through AgentCliManager:
export enum AgentCliType { ClaudeCode = 'claude-code', Codex = 'codex', // Future extensions: other CLIs such as Aider and Cursor}
export class AgentCliManager { private static readonly STORE_KEY = 'agentCliSelection'; private static readonly EXECUTOR_TYPE_MAP: Record<AgentCliType, string> = { [AgentCliType.ClaudeCode]: 'ClaudeCodeCli', [AgentCliType.Codex]: 'CodexCli', };
constructor(private store: any) {}
async saveSelection(cliType: AgentCliType): Promise<void> { const selection: StoredAgentCliSelection = { cliType, isSkipped: false, selectedAt: new Date().toISOString(), };
this.store.set(AgentCliManager.STORE_KEY, selection); }
loadSelection(): StoredAgentCliSelection { return this.store.get(AgentCliManager.STORE_KEY, { cliType: null, isSkipped: false, selectedAt: null, }); }
getCommandName(cliType: AgentCliType): string { switch (cliType) { case AgentCliType.ClaudeCode: return 'claude'; case AgentCliType.Codex: return 'codex'; default: return 'claude'; } }
getExecutorType(cliType: AgentCliType | null): string { if (!cliType) return 'ClaudeCodeCli'; return this.EXECUTOR_TYPE_MAP[cliType] || 'ClaudeCodeCli'; }}Example desktop-side IPC handler:
ipcMain.handle('llm:call-api', async (event, manifestPath, region) => { if (!state.llmInstallationManager) { return { success: false, error: 'LLM Installation Manager not initialized' }; }
try { const prompt = await state.llmInstallationManager.loadPrompt(manifestPath, region);
// Determine the CLI command based on the user's selection let commandName = 'claude'; if (state.agentCliManager) { const selectedCliType = state.agentCliManager.getSelectedCliType(); if (selectedCliType) { commandName = state.agentCliManager.getCommandName(selectedCliType); } }
// Execute with the selected CLI const result = await state.llmInstallationManager.callApi( prompt.filePath, event.sender, commandName );
return result; } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unknown error' }; }});9. Codex’s Internal Model Provider System
Section titled “9. Codex’s Internal Model Provider System”Codex itself also supports multiple model providers via ModelProviderInfo configuration:
pub const OPENAI_PROVIDER_NAME: &str = "OpenAI";pub const OLLAMA_OSS_PROVIDER_ID: &str = "ollama";pub const LMSTUDIO_OSS_PROVIDER_ID: &str = "lmstudio";
pub fn built_in_model_providers() -> HashMap<String, ModelProviderInfo> { use ModelProviderInfo as P;
[ ("openai", P::create_openai_provider()), (OLLAMA_OSS_PROVIDER_ID, create_oss_provider(DEFAULT_OLLAMA_PORT, WireApi::Responses)), (LMSTUDIO_OSS_PROVIDER_ID, create_oss_provider(DEFAULT_LMSTUDIO_PORT, WireApi::Responses)), ] .into_iter() .map(|(k, v)| (k.to_string(), v)) .collect()}
pub struct ModelProviderInfo { pub name: String, pub base_url: Option<String>, pub env_key: Option<String>, pub query_params: Option<HashMap<String, String>>, pub http_headers: Option<HashMap<String, String>>, pub request_max_retries: Option<u64>, pub stream_max_retries: Option<u64>, pub stream_idle_timeout_ms: Option<u64>, pub requires_openai_auth: bool, pub supports_websockets: bool,}Codex model-provider support includes:
- Built-in providers: OpenAI, Ollama, and LM Studio
- Custom providers: Users can add custom providers in
config.toml - Retry strategy: Configurable retry counts for requests and streams
- WebSocket support: Some providers support WebSocket transport
Practice
Section titled “Practice”Configuration Example
Section titled “Configuration Example”Configure multiple providers in appsettings.json:
{ "AI": { "Providers": { "DefaultProvider": "ClaudeCodeCli", "Providers": { "ClaudeCodeCli": { "Type": "ClaudeCodeCli", "Model": "claude-sonnet-4-20250514", "WorkingDirectory": "/path/to/workspace", "PermissionMode": "acceptEdits", "AllowedTools": ["file-edit", "command-run", "bash"] }, "CodexCli": { "Type": "CodexCli", "Model": "gpt-4.1", "ExecutablePath": "codex", "SandboxMode": "enabled", "WebSearchMode": "auto", "NetworkAccessEnabled": false } }, "ScenarioProviderMapping": { "CodeAnalysis": "ClaudeCodeCli", "CodeGeneration": "CodexCli", "Refactoring": "ClaudeCodeCli", "Debugging": "CodexCli" }, "FallbackChain": ["CodexCli", "ClaudeCodeCli"] }, "Selector": { "EnableCache": true, "CacheExpirationSeconds": 300 } }}Usage Example - Backend Service
Section titled “Usage Example - Backend Service”public class AIOrchestrator{ private readonly IAIProviderFactory _providerFactory; private readonly IAIProviderSelector _providerSelector; private readonly ILogger<AIOrchestrator> _logger;
public AIOrchestrator( IAIProviderFactory providerFactory, IAIProviderSelector providerSelector, ILogger<AIOrchestrator> logger) { _providerFactory = providerFactory; _providerSelector = providerSelector; _logger = logger; }
public async Task<AIResponse> ProcessRequestAsync( AIRequest request, BusinessScenario scenario) { _logger.LogInformation("Processing request for scenario: {Scenario}", scenario);
try { // Select a provider intelligently var providerType = await _providerSelector.SelectProviderAsync(scenario, request.CancellationToken);
// Get the provider instance var provider = await _providerFactory.GetProviderAsync(providerType); if (provider == null) { throw new InvalidOperationException($"Provider {providerType} not available"); }
_logger.LogInformation("Using provider: {Provider} for request", provider.Name);
// Execute the request var response = await provider.ExecuteAsync(request, request.CancellationToken);
_logger.LogInformation("Request completed with provider: {Provider}, tokens used: {Tokens}", provider.Name, response.Usage?.TotalTokens ?? 0);
return response; } catch (Exception ex) { _logger.LogError(ex, "Failed to process request for scenario: {Scenario}", scenario); throw; } }}Usage Example - Streaming Responses
Section titled “Usage Example - Streaming Responses”public async IAsyncEnumerable<AIStreamingChunk> StreamResponseAsync( AIRequest request, BusinessScenario scenario){ var providerType = await _providerSelector.SelectProviderAsync(scenario); var provider = await _providerFactory.GetProviderAsync(providerType);
if (provider == null) { throw new InvalidOperationException($"Provider {providerType} not available"); }
await foreach (var chunk in provider.StreamAsync(request)) { // Process streaming chunks switch (chunk.Type) { case StreamingChunkType.ContentDelta: // Show text content in real time await SendToClientAsync(chunk.Content); break;
case StreamingChunkType.ToolCallDelta: // Handle tool calls await HandleToolCallAsync(chunk.ToolCallDelta); break;
case StreamingChunkType.Metadata: // Handle completion events and stats if (chunk.IsComplete) { _logger.LogInformation("Stream completed, usage: {@Usage}", chunk.Usage); } break;
case StreamingChunkType.Error: // Handle errors _logger.LogError("Stream error: {Error}", chunk.ErrorMessage); throw new InvalidOperationException(chunk.ErrorMessage); } }}Usage Example - OpenSpec Commands
Section titled “Usage Example - OpenSpec Commands”public async Task<string> ExecuteOpenSpecCommandAsync( string command, string arguments, BusinessScenario scenario){ var providerType = await _providerSelector.SelectProviderAsync(scenario); var provider = await _providerFactory.GetProviderAsync(providerType);
// Build an embedded command prompt var commandPrompt = $""" Execute the following OpenSpec command: Command: {command} Arguments: {arguments}
Please execute this command and return the results. """;
var request = new AIRequest { Prompt = "Process this command request", EmbeddedCommandPrompt = commandPrompt, WorkingDirectory = Directory.GetCurrentDirectory() };
var response = await provider.SendMessageAsync(request, commandPrompt);
return response.Content;}Considerations
Section titled “Considerations”1. Provider Health Checks
Section titled “1. Provider Health Checks”Before switching providers, it is recommended to call PingAsync first to ensure the target provider is available:
public async Task<bool> IsProviderHealthyAsync(AIProviderType providerType){ var provider = await _providerFactory.GetProviderAsync(providerType); if (provider == null) return false;
var testResult = await provider.PingAsync();
return testResult.Success && testResult.ResponseTimeMs < 5000; // A response within 5 seconds is considered healthy}2. Session Isolation
Section titled “2. Session Isolation”Use CessionId (Claude) or ThreadId (Codex) to ensure session isolation:
- Claude Code CLI: use
CessionIdas the unique session identifier - Codex CLI: use
ThreadIdas the session identifier
// Claude Code CLI session optionsvar claudeSessionOptions = new ClaudeSessionOptions{ CessionId = CessionId.New(), // Generate a unique ID WorkingDirectory = workspacePath, AllowedTools = allowedTools, PermissionMode = PermissionMode.acceptEdits};
// Codex thread optionsvar codexThreadOptions = new ThreadOptions{ Model = "gpt-4.1", SandboxMode = "enabled", WorkingDirectory = workspacePath};3. Error Handling
Section titled “3. Error Handling”Fallback mechanisms must be robust when a provider is unavailable, ensuring that at least one provider remains usable:
public async Task<AIResponse> ExecuteWithFallbackAsync( AIRequest request, List<AIProviderType> preferredProviders){ Exception? lastException = null;
foreach (var providerType in preferredProviders) { try { var provider = await _providerFactory.GetProviderAsync(providerType); if (provider == null) continue;
// Try execution return await provider.ExecuteAsync(request); } catch (Exception ex) { _logger.LogWarning(ex, "Provider {ProviderType} failed, trying next", providerType); lastException = ex; } }
// All providers failed throw new InvalidOperationException( "All preferred providers failed. Last error: " + lastException?.Message, lastException);}4. Configuration Validation
Section titled “4. Configuration Validation”Validate settings for all configured providers at startup to avoid runtime errors:
public void ValidateConfiguration(AIProviderOptions options){ foreach (var (providerType, config) in options.Providers) { // Validate executable paths (for CLI-based providers) if (IsCliBasedProvider(providerType)) { if (string.IsNullOrWhiteSpace(config.ExecutablePath)) { throw new ConfigurationException( $"Provider {providerType} requires ExecutablePath"); }
if (!File.Exists(config.ExecutablePath)) { throw new ConfigurationException( $"Executable not found for {providerType}: {config.ExecutablePath}"); } }
// Validate API keys (for API-based providers) if (IsApiBasedProvider(providerType)) { if (string.IsNullOrWhiteSpace(config.ApiKey)) { throw new ConfigurationException( $"Provider {providerType} requires ApiKey"); } }
// Validate model names if (string.IsNullOrWhiteSpace(config.Model)) { _logger.LogWarning("No model configured for {ProviderType}, using default", providerType); } }}5. Cache Management
Section titled “5. Cache Management”Provider instances are cached, so pay attention to lifecycle management and memory usage:
// Clean up the cache periodicallypublic void ClearInactiveProviders(TimeSpan inactiveThreshold){ var now = DateTimeOffset.UtcNow; var keysToRemove = new List<AIProviderType>();
foreach (var (type, instance) in _cache) { // Assume providers have a LastUsedTime property if (instance.LastUsedTime.HasValue && now - instance.LastUsedTime.Value > inactiveThreshold) { keysToRemove.Add(type); } }
foreach (var key in keysToRemove) { _cache.TryRemove(key, out _); _logger.LogInformation("Cleared inactive provider: {Provider}", key); }}6. Logging
Section titled “6. Logging”Log provider selection, switching, and execution in detail to make debugging easier:
public class AIProviderLogging{ private readonly ILogger _logger;
public void LogProviderSelection( BusinessScenario scenario, AIProviderType selectedProvider, SelectionReason reason) { _logger.LogInformation( "[ProviderSelection] Scenario={Scenario}, Provider={Provider}, Reason={Reason}", scenario, selectedProvider, reason); }
public void LogProviderSwitch( AIProviderType fromProvider, AIProviderType toProvider, string reason) { _logger.LogWarning( "[ProviderSwitch] From={FromProvider} To={ToProvider}, Reason={Reason}", fromProvider, toProvider, reason); }
public void LogProviderError( AIProviderType provider, Exception error, AIRequest request) { _logger.LogError(error, "[ProviderError] Provider={Provider}, RequestLength={Length}, Error={Message}", provider, request.Prompt.Length, error.Message); }}7. Thread Safety
Section titled “7. Thread Safety”Using concurrent collections such as ConcurrentDictionary ensures thread safety:
public class ThreadSafeProviderCache{ private readonly ConcurrentDictionary<AIProviderType, IAIProvider> _cache; private readonly ReaderWriterLockSlim _lock = new();
public IAIProvider? GetProvider(AIProviderType type) { // Read operations do not require a lock if (_cache.TryGetValue(type, out var provider)) return provider;
// Creation requires a write lock _lock.EnterWriteLock(); try { // Double-check if (_cache.TryGetValue(type, out provider)) return provider;
var newProvider = CreateProvider(type); if (newProvider != null) { _cache[type] = newProvider; } return newProvider; } finally { _lock.ExitWriteLock(); } }}8. Database Migration
Section titled “8. Database Migration”When the session-thread binding database schema changes, data migration must be considered:
public class SessionThreadMigration{ public async Task MigrateAsync(string dbPath) { var version = await GetSchemaVersionAsync(dbPath);
if (version >= 2) return; // Already the latest version
using var connection = new SqliteConnection(dbPath); connection.Open();
// Migrate to v2: add the CreatedAtUtc column if (version < 2) { _logger.LogInformation("Migrating SessionThreadBindings to v2...");
using var addColumnCommand = connection.CreateCommand(); addColumnCommand.CommandText = "ALTER TABLE SessionThreadBindings ADD COLUMN CreatedAtUtc TEXT;"; addColumnCommand.ExecuteNonQuery();
using var backfillCommand = connection.CreateCommand(); backfillCommand.CommandText = """ UPDATE SessionThreadBindings SET CreatedAtUtc = COALESCE(NULLIF(UpdatedAtUtc, ''), $nowUtc) WHERE CreatedAtUtc IS NULL OR CreatedAtUtc = ''; """; backfillCommand.Parameters.AddWithValue("$nowUtc", DateTimeOffset.UtcNow.ToString("O")); backfillCommand.ExecuteNonQuery(); }
await UpdateSchemaVersionAsync(dbPath, 2); _logger.LogInformation("Migration to v2 completed"); }}Conclusion
Section titled “Conclusion”HagiCode combines the provider pattern, factory pattern, and selector pattern to implement a flexible and extensible multi-AI provider architecture:
- Unified interface abstraction: The
IAIProviderinterface hides the differences between CLIs - Dynamic instance creation:
AIProviderFactorysupports runtime creation of provider instances - Intelligent selection strategy:
AIProviderSelectorimplements scenario-driven provider selection - Session state persistence: Database bindings ensure session continuity
- Desktop integration:
AgentCliManagersupports user selection and configuration
The advantages of this architecture are:
- Extensibility: Adding a new AI provider only requires implementing the
IAIProviderinterface - Testability: Providers can be tested and mocked independently
- Maintainability: Each provider implementation is isolated and has a single responsibility
- User-friendliness: Support both scenario-based automatic selection and manual switching
With this design, HagiCode successfully enables seamless switching and interoperability between Claude Code CLI and Codex CLI, giving developers a flexible and powerful AI coding assistant experience.
References
Section titled “References”- HagiCode project repository: github.com/HagiCode-org/site
- HagiCode official website: hagicode.com
- Claude Code official documentation: docs.anthropic.com
- OpenAI Codex documentation: platform.openai.com
- Codex SDK official repository: github.com/openai/codex
- HagiCode multi-platform CLI support: https://docs.hagicode.com/blog/hagicode-ai-cli-multi-platform-support/
Thank you for reading. If you found this article useful, please click the like button below 👍 so more people can discover it.
This content was created with AI-assisted collaboration, reviewed by me, and reflects my own views and position.
- Author: newbe36524
- Article link: https://docs.hagicode.com/blog/2026-03-09-hagicode-multi-ai-provider-switching-interop/
- Copyright notice: Unless otherwise stated, all blog posts on this site are licensed under BY-NC-SA. Please credit the source when reprinting!