HagiCode Multi-AI Provider Switching and Interoperability Implementation Plan
페이지 편집HagiCode Multi-AI Provider Switching and Interoperability Implementation Plan
섹션 제목: “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
섹션 제목: “Background”Problem Domain
섹션 제목: “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
섹션 제목: “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
섹션 제목: “Analysis”Architectural Design Approach
섹션 제목: “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
섹션 제목: “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
섹션 제목: “Solution”1. Core Interface Design
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “Practice”Configuration Example
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “Considerations”1. Provider Health Checks
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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
섹션 제목: “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!