Hagicode 多 AI 提供者切换与互操作实现方案

20 阅读11分钟

Hagicode 多 AI 提供者切换与互操作实现方案

在现代开发工具生态中,开发者经常需要使用不同的 AI 编码助手来辅助开发工作。Anthropic 的 Claude Code CLI 和 OpenAI 的 Codex CLI 各有其优势:Claude 以出色的代码理解和长上下文处理能力著称,而 Codex 在代码生成和工具使用方面表现优异。

本文将深入分析 hagicode 项目如何实现多个 AI 提供者的无缝切换与互操作,包括核心架构设计、关键实现细节以及实践中的注意事项。

背景

问题域

hagicode 项目面临的核心挑战是在同一平台中支持多种 AI CLI,让用户能够:

  1. 根据需求灵活切换不同的 AI 提供者
  2. 在切换过程中保持会话状态的连续性
  3. 统一抽象不同 CLI 的 API 差异
  4. 为未来添加新的 AI 提供者预留扩展空间

技术挑战

  1. 接口差异统一:Claude Code CLI 通过命令行调用,Codex CLI 使用 JSON 事件流
  2. 流式响应处理:两种提供者都支持流式响应,但数据格式不同
  3. 工具调用语义:Claude 和 Codex 对工具调用的表示和生命周期管理不同
  4. 会话生命周期:需要正确管理每个提供者的会话创建、恢复和终止

分析

架构设计思路

hagicode 采用了提供者模式(Provider Pattern)结合工厂模式来抽象 AI 服务的调用。这种设计的核心思想是:

  1. 统一接口抽象:定义 IAIProvider 接口作为所有 AI 提供者的统一抽象
  2. 工厂创建实例:通过 AIProviderFactory 根据类型动态创建对应的提供者实例
  3. 智能选择逻辑:使用 AIProviderSelector 根据场景和配置自动选择最合适的提供者
  4. 会话状态管理:通过数据库持久化会话与 CLI 线程的绑定关系

关键组件

组件职责语言
IAIProvider统一提供者接口C#
AIProviderFactory创建和管理提供者实例C#
AIProviderSelector智能选择提供者C#
ClaudeCodeCliProviderClaude Code CLI 实现C#
CodexCliProviderCodex CLI 实现C#
AgentCliManager桌面端 CLI 管理TypeScript

解决

1. 核心接口设计

IAIProvider 接口 定义了统一的提供者抽象:

public interface IAIProvider
{
    /// <summary>
    /// 提供者显示名称
    /// </summary>
    string Name { get; }

    /// <summary>
    /// 是否支持流式响应
    /// </summary>
    bool SupportsStreaming { get; }

    /// <summary>
    /// 提供者能力描述
    /// </summary>
    ProviderCapabilities Capabilities { get; }

    /// <summary>
    /// 执行单个 AI 请求
    /// </summary>
    Task<AIResponse> ExecuteAsync(AIRequest request, CancellationToken cancellationToken = default);

    /// <summary>
    /// 执行流式 AI 请求
    /// </summary>
    IAsyncEnumerable<AIStreamingChunk> StreamAsync(AIRequest request, CancellationToken cancellationToken = default);

    /// <summary>
    /// 检查提供者连接性和响应速度
    /// </summary>
    Task<ProviderTestResult> PingAsync(CancellationToken cancellationToken = default);

    /// <summary>
    /// 发送带嵌入式命令的消息
    /// </summary>
    IAsyncEnumerable<AIStreamingChunk> SendMessageAsync(
        AIRequest request,
        string? embeddedCommandPrompt = null,
        CancellationToken cancellationToken = default);
}

接口设计的关键特性:

  • 统一的请求/响应模型:所有提供者使用相同的 AIRequestAIResponse 类型
  • 流式支持:通过 IAsyncEnumerable<AIStreamingChunk> 统一流式输出
  • 能力描述ProviderCapabilities 描述提供者支持的功能(流式、工具、最大 token 等)
  • 嵌入式命令SendMessageAsync 支持将 OpenSpec 命令嵌入到提示中

2. 提供者类型枚举

public enum AIProviderType
{
    ClaudeCodeCli,   // Anthropic Claude Code
    OpenCodeCli,     // 其他 CLI(可扩展)
    GitHubCopilot,    // GitHub Copilot
    CodebuddyCli,    // Codebuddy
    CodexCli         // OpenAI Codex
}

这个枚举为系统支持的所有提供者提供了类型安全的表示。

3. 工厂模式实现

AIProviderFactory 负责创建和管理提供者实例:

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)
    {
        // 使用缓存避免重复创建
        if (_cache.TryGetValue(providerType, out var cached))
            return Task.FromResult<IAIProvider?>(cached);

        // 从配置中获取提供者配置
        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);
        }

        // 根据类型创建提供者
        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);
    }
}

工厂模式的优势:

  • 实例缓存:避免重复创建相同类型的提供者
  • 依赖注入:通过 IServiceProvider 创建实例,支持依赖注入
  • 配置驱动:从配置文件读取提供者配置
  • 异常处理:创建失败时返回 null,便于上层处理

4. 智能选择器

AIProviderSelector 实现提供者选择策略:

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. 尝试从场景映射获取提供者
        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. 尝试使用默认提供者
        if (await IsProviderAvailableAsync(_configuration.DefaultProvider, cancellationToken))
        {
            _logger.LogDebug("Using default provider '{Provider}' for scenario '{Scenario}'",
                _configuration.DefaultProvider, scenario);
            return _configuration.DefaultProvider;
        }

        // 3. 尝试回退链
        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. 无法找到可用提供者
        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}";

        // 使用缓存减少 Ping 调用
        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;
    }
}

选择器策略:

  • 场景映射优先:首先检查业务场景是否有特定的提供者映射
  • 默认提供者回退:场景映射失败时使用默认提供者
  • 回退链兜底:逐个尝试回退链中的提供者
  • 可用性缓存:缓存提供者可用性检查结果,减少 Ping 调用

5. Claude Code CLI 提供者实现

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
        };
    }
}

Claude Code CLI 提供者的特点:

  • 流式管理器集成:使用 IClaudeStreamManager 与 Claude CLI 通信
  • CessionId 会话隔离:使用 CessionId 作为会话唯一标识,与系统 sessionId 区分
  • 工作目录配置:支持配置工作目录、权限模式等
  • 工具支持:支持 AllowedTools、DisallowedTools 等工具权限配置

6. Codex CLI 提供者实现

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;

        // 检查是否已有绑定的线程
        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);
    }
}

Codex CLI 提供者的特点:

  • JSON 事件流处理:解析 Codex 的 JSON 事件流(TurnStarted、ItemStarted、TurnCompleted 等)
  • 会话线程绑定:使用 SQLite 数据库持久化会话与线程的绑定关系
  • 线程复用:支持恢复已有线程,保持会话连续性
  • 工具调用追踪:追踪活动工具调用状态,正确处理工具生命周期

7. 会话线程绑定机制

Codex CLI 使用 SQLite 数据库持久化会话与线程的绑定:

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;
        }

        // 内存缓存
        _sessionThreadBindings.AddOrUpdate(sessionId, threadId, (_, _) => threadId);

        // 持久化到 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;
        }
    }
}

会话线程绑定的优势:

  • 会话恢复:系统重启后可以恢复之前的会话
  • 线程复用:同一会话可以复用已有的 Codex 线程
  • 自动清理:超过 30 天的绑定会被自动清理

8. 桌面端 CLI 管理

hagicode-desktop 通过 AgentCliManager 管理 CLI 选择:

export enum AgentCliType {
  ClaudeCode = 'claude-code',
  Codex = 'codex',
  // 未来可扩展: Aider, Cursor 等其他 CLI
}

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';
  }
}

桌面端 IPC 处理器示例:

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);

    // 根据用户选择确定 CLI 命令
    let commandName = 'claude';
    if (state.agentCliManager) {
      const selectedCliType = state.agentCliManager.getSelectedCliType();
      if (selectedCliType) {
        commandName = state.agentCliManager.getCommandName(selectedCliType);
      }
    }

    // 使用对应的 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 内部的模型提供者系统

Codex 本身也支持多种模型提供者,通过 ModelProviderInfo 配置:

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 的模型提供者支持:

  • 内置提供者:OpenAI、Ollama、LM Studio
  • 自定义提供者:用户可在 config.toml 中添加自定义提供者
  • 重试策略:可配置请求和流的重试次数
  • WebSocket 支持:部分提供者支持 WebSocket 传输

实践

配置示例

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
    }
  }
}

使用示例 - 后端服务

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
        {
            // 智能选择提供者
            var providerType = await _providerSelector.SelectProviderAsync(scenario, request.CancellationToken);

            // 获取提供者实例
            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);

            // 执行请求
            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;
        }
    }
}

使用示例 - 流式响应

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))
    {
        // 处理流式块
        switch (chunk.Type)
        {
            case StreamingChunkType.ContentDelta:
                // 实时显示文本内容
                await SendToClientAsync(chunk.Content);
                break;

            case StreamingChunkType.ToolCallDelta:
                // 处理工具调用
                await HandleToolCallAsync(chunk.ToolCallDelta);
                break;

            case StreamingChunkType.Metadata:
                // 处理完成事件和统计
                if (chunk.IsComplete)
                {
                    _logger.LogInformation("Stream completed, usage: {@Usage}", chunk.Usage);
                }
                break;

            case StreamingChunkType.Error:
                // 处理错误
                _logger.LogError("Stream error: {Error}", chunk.ErrorMessage);
                throw new InvalidOperationException(chunk.ErrorMessage);
        }
    }
}

使用示例 - OpenSpec 命令

public async Task<string> ExecuteOpenSpecCommandAsync(
    string command,
    string arguments,
    BusinessScenario scenario)
{
    var providerType = await _providerSelector.SelectProviderAsync(scenario);
    var provider = await _providerFactory.GetProviderAsync(providerType);

    // 构建嵌入式命令提示
    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;
}

注意事项

1. 提供者健康检查

在切换提供者前,建议先调用 PingAsync 确保目标提供者可用:

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; // 5 秒内响应视为健康
}

2. 会话隔离

使用 CessionId(Claude)或 ThreadId(Codex)确保会话隔离:

  • Claude Code CLI:使用 CessionId 作为会话唯一标识
  • Codex CLI:使用 ThreadId 作为会话标识
// Claude Code CLI 会话选项
var claudeSessionOptions = new ClaudeSessionOptions
{
    CessionId = CessionId.New(),  // 生成唯一 ID
    WorkingDirectory = workspacePath,
    AllowedTools = allowedTools,
    PermissionMode = PermissionMode.acceptEdits
};

// Codex 线程选项
var codexThreadOptions = new ThreadOptions
{
    Model = "gpt-4.1",
    SandboxMode = "enabled",
    WorkingDirectory = workspacePath
};

3. 错误处理

提供者不可用时的回退机制要健壮,确保至少有一个可用提供者:

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;

            // 尝试执行
            return await provider.ExecuteAsync(request);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Provider {ProviderType} failed, trying next", providerType);
            lastException = ex;
        }
    }

    // 所有提供者都失败
    throw new InvalidOperationException(
        "All preferred providers failed. Last error: " + lastException?.Message,
        lastException);
}

4. 配置验证

启动时验证所有配置的提供者设置,避免运行时错误:

public void ValidateConfiguration(AIProviderOptions options)
{
    foreach (var (providerType, config) in options.Providers)
    {
        // 验证可执行文件路径(CLI 类型提供者)
        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}");
            }
        }

        // 验证 API 密钥(API 类型提供者)
        if (IsApiBasedProvider(providerType))
        {
            if (string.IsNullOrWhiteSpace(config.ApiKey))
            {
                throw new ConfigurationException(
                    $"Provider {providerType} requires ApiKey");
            }
        }

        // 验证模型名称
        if (string.IsNullOrWhiteSpace(config.Model))
        {
            _logger.LogWarning("No model configured for {ProviderType}, using default", providerType);
        }
    }
}

5. 缓存管理

提供者实例会被缓存,注意生命周期管理和内存使用:

// 定期清理缓存
public void ClearInactiveProviders(TimeSpan inactiveThreshold)
{
    var now = DateTimeOffset.UtcNow;
    var keysToRemove = new List<AIProviderType>();

    foreach (var (type, instance) in _cache)
    {
        // 假设提供者有 LastUsedTime 属性
        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. 日志记录

详细记录提供者选择、切换和执行过程,便于调试:

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. 线程安全

ConcurrentDictionary 等并发集合的使用确保线程安全:

public class ThreadSafeProviderCache
{
    private readonly ConcurrentDictionary<AIProviderType, IAIProvider> _cache;
    private readonly ReaderWriterLockSlim _lock = new();

    public IAIProvider? GetProvider(AIProviderType type)
    {
        // 读取操作无需锁
        if (_cache.TryGetValue(type, out var provider))
            return provider;

        // 创建需要写锁
        _lock.EnterWriteLock();
        try
        {
            // 双重检查
            if (_cache.TryGetValue(type, out provider))
                return provider;

            var newProvider = CreateProvider(type);
            if (newProvider != null)
            {
                _cache[type] = newProvider;
            }
            return newProvider;
        }
        finally
        {
            _lock.ExitWriteLock();
        }
    }
}

8. 数据库迁移

会话线程绑定数据库结构变更时需要考虑数据迁移:

public class SessionThreadMigration
{
    public async Task MigrateAsync(string dbPath)
    {
        var version = await GetSchemaVersionAsync(dbPath);

        if (version >= 2) return; // 已是最新版本

        using var connection = new SqliteConnection(dbPath);
        connection.Open();

        // 迁移到 v2:添加 CreatedAtUtc 列
        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");
    }
}

总结

hagicode 通过提供者模式、工厂模式和选择器模式的组合,实现了一个灵活、可扩展的多 AI 提供者架构:

  • 统一接口抽象IAIProvider 接口屏蔽了不同 CLI 的差异
  • 动态实例创建AIProviderFactory 支持运行时创建提供者实例
  • 智能选择策略AIProviderSelector 实现场景驱动的提供者选择
  • 会话状态持久化:通过数据库绑定确保会话连续性
  • 桌面端集成AgentCliManager 支持用户选择和配置

这种架构设计的优势在于:

  1. 可扩展性:添加新的 AI 提供者只需实现 IAIProvider 接口
  2. 可测试性:提供者可以独立测试和模拟
  3. 可维护性:每个提供者的实现独立,职责单一
  4. 用户友好:支持场景自动选择和手动切换

通过这种设计,hagicode 成功实现了 Claude Code CLI 和 Codex CLI 的无缝切换与互操作,为开发者提供了灵活、强大的 AI 编码助手体验。

参考资料


感谢您的阅读,如果您觉得本文有用,快点击下方点赞按钮👍,让更多的人看到本文。

本内容采用人工智能辅助协作,经本人审核,符合本人观点与立场。