跳转到内容

Codex

3 篇包含标签 "Codex" 的文章

Docker 容器中运行 AI CLI 工具:用户隔离与持久化卷实战指南

Docker 容器中运行 AI CLI 工具:用户隔离与持久化卷实战指南

Section titled “Docker 容器中运行 AI CLI 工具:用户隔离与持久化卷实战指南”

在容器化环境中集成 Claude Code、Codex、OpenCode 等 AI 编程工具,听起来简单,实则暗藏玄机。本文将深入解析 HagiCode 项目在 Docker 部署中如何解决用户权限、配置持久化、版本管理等核心挑战,带你避坑避雷。

当我们决定在 Docker 容器内运行 AI 编程 CLI 工具时,最直觉的想法可能是:“容器不就是 root 吗,直接装不就完事了?“其实啊,这想法看似简单,背后却藏着几个必须解决的核心问题。

首先,安全限制是第一道坎。以 Claude CLI 为例,它明确禁止以 root 用户运行——这是强制性的安全检查,检测到 root 直接拒绝启动。你可能会想,那我用 USER 指令切换一下不就行了?事情没那么简单,容器的非 root 用户和宿主机的用户权限之间还存在映射问题。毕竟,这世间的事,哪有那么简单的呢?

其次,状态持久化是第二个坑。Claude Code 需要登录,Codex 有自己的配置,OpenCode 也有缓存目录。如果每次容器重启都重新配置,那这个”自动化”就毫无意义了。我们需要让这些配置在容器生命周期之外持久存在。配置这东西,就像记忆一样,说没就没,那也挺让人郁闷的。

第三个问题就是权限一致性。宿主机用户创建的配置文件,容器内的进程能不能访问?UID/GID 不匹配会导致文件权限报错,这在实际部署中非常常见。这问题说起来也挺无奈的,可是没辙。

这些问题看似独立,实际上环环相扣。HagiCode 项目在开发过程中逐步摸索出了一套可行的解决方案,接下来我会详细分享其中的技术细节和踩坑经历。

本文分享的方案来自我们在 HagiCode 项目中的实践经验。HagiCode 是一个开源的 AI 辅助编程平台,集成了多个主流的 AI 代码助手,包括 Claude Code、Codex、OpenCode 等。作为一个需要跨平台、高可用部署的项目,HagiCode 必须解决容器化部署的各种挑战。

如果你觉得本文分享的技术方案有价值,说明 HagiCode 在工程实践上还是有点东西的——那么 HagiCode 官网GitHub 仓库 值得关注关注。毕竟,好东西值得分享,不是吗?

这里有个常见的误解:Docker 容器默认以 root 运行,那我就直接用 root 装工具呗。这么想的话,Claude CLI 会毫不客气地给你一个下马威。

Terminal window
# 直接以 root 运行 Claude CLI?不行
docker run --rm -it --user root myimage claude
# 输出: Error: This command cannot be run as root user

这是 Claude CLI 的硬性安全限制。原因很简单:这些 CLI 工具会读写用户的敏感配置,包括 API Token、本地缓存、甚至可能执行用户编写的脚本。以 root 权限运行这些工具,潜在风险太大。毕竟,安全这东西,怎么谨慎都不为过。

那么问题来了:怎么才能既满足 CLI 的安全要求,又保持容器管理的灵活性?我们需要换个思路——不是在运行时切换用户,而是从镜像构建阶段就创建专用用户。有时候啊,换个角度看问题,答案就自然浮现了。

创建专用用户:不止是换个名字

Section titled “创建专用用户:不止是换个名字”

你可能会想,那我直接在 Dockerfile 里加一行 USER 指令不就得了?这确实是最简单的方案,但不够健壮。简单的东西往往不够优雅,不是吗?

HagiCode 的方案是创建一个 UID 1000 的 hagicode 用户,这个 UID 通常匹配大多数宿主机的默认用户:

RUN groupadd -o -g 1000 hagicode && \
useradd -o -u 1000 -g 1000 -s /bin/bash -m hagicode && \
mkdir -p /home/hagicode/.claude && \
chown -R hagicode:hagicode /home/hagicode

但这只解决了镜像内置用户的问题。如果宿主机用户是 UID 1001 呢?容器启动时还需要支持动态映射。

docker-entrypoint.sh 中的关键逻辑:

Terminal window
if [ -n "$PUID" ] && [ -n "$PGID" ]; then
if ! id hagicode >/dev/null 2>&1; then
groupadd -g "$PGID" hagicode
useradd -u "$PUID" -g "$PGID" -s /bin/bash -m hagicode
fi
fi

这样设计的好处是:镜像构建时使用默认的 UID 1000,运行时可以通过环境变量 PUID/PGID 动态调整。无论宿主机用户是什么 UID,配置文件的所有权都不会出问题。这设计说起来也挺自然的,毕竟,灵活性和默认值之间需要找到一个平衡点罢了。

每个 AI CLI 工具都有自己偏好的配置目录,这需要一一对应:

CLI 工具容器内路径命名卷
Claude/home/hagicode/.claudeclaude-data
Codex/home/hagicode/.codexcodex-data
OpenCode/home/hagicode/.config/opencodeopencode-config-data

为什么用命名卷而不是绑定挂载?三个原因:

  1. 简化管理:命名卷由 Docker 自动管理生命周期,不需要手动创建宿主机目录
  2. 权限隔离:卷的初始内容由容器内用户创建,避免宿主机权限冲突
  3. 独立迁移:卷可以独立于容器存在,升级镜像时数据不会丢失

docker-compose-builder-web 会自动生成对应的卷配置:

volumes:
claude-data:
codex-data:
opencode-config-data:
services:
hagicode:
volumes:
- claude-data:/home/hagicode/.claude
- codex-data:/home/hagicode/.codex
- opencode-config-data:/home/hagicode/.config/opencode
user: "${PUID:-1000}:${PGID:-1000}"

注意这里的 user 字段:通过环境变量注入 PUID/PGID,确保容器进程以匹配宿主机的用户身份运行。这细节说起来挺重要的,毕竟,权限问题一旦出现,排查起来也挺让人头疼的。

版本管理:烘焙版本与运行时覆盖

Section titled “版本管理:烘焙版本与运行时覆盖”

Docker 镜像的版本固定是保证可重现性的关键。但在实际开发中,我们经常需要测试新版本,或者紧急修复一个 bug。如果每次都要重新构建镜像,那效率也太低了。

HagiCode 的策略是固定版本作为默认值,运行时覆盖作为扩展能力。这也算是工程实践中的一种妥协吧,稳定性和灵活性之间总要有个取舍。

Dockerfile.template 中固定版本:

USER hagicode
WORKDIR /home/hagicode
# 配置 npm 全局安装路径
RUN mkdir -p /home/hagicode/.npm-global && \
npm config set prefix '/home/hagicode/.npm-global'
# 安装 CLI 工具(使用固定版本)
RUN npm install -g @anthropic-ai/claude-code@2.1.71 && \
npm install -g @openai/codex@0.112.0 && \
npm install -g opencode-ai@1.2.25 && \
npm cache clean --force

docker-entrypoint.sh 中支持运行时覆盖:

Terminal window
install_cli_override_if_needed() {
local package_name="$2"
local override_version="$5"
if [ -n "$override_version" ]; then
gosu hagicode npm install -g "${package_name}@${override_version}"
fi
}
# 使用示例
install_cli_override_if_needed "" "@anthropic-ai/claude-code" "" "" "${CLAUDE_CODE_CLI_VERSION}"

这样,在不重新构建镜像的情况下,可以通过环境变量测试新版本:

Terminal window
docker run -e CLAUDE_CODE_CLI_VERSION=2.2.0 myimage

这设计说起来也挺实用的,毕竟,谁愿意每次测试新功能都要重新构建镜像呢?

除了手动配置 CLI 工具,有些场景下还需要自动注入配置。最典型的就是 API Token。

Terminal window
if [ -n "$ANTHROPIC_AUTH_TOKEN" ]; then
mkdir -p /home/hagicode/.claude
cat > /home/hagicode/.claude/settings.json <<EOF
{
"env": {
"ANTHROPIC_AUTH_TOKEN": "${ANTHROPIC_AUTH_TOKEN}"
}
}
EOF
chown -R hagicode:hagicode /home/hagicode/.claude
fi

这里需要注意两点:敏感信息通过环境变量传入,不要硬编码到镜像中;配置文件的所有权要正确设置,否则 CLI 工具无法读取。这事儿说起来挺基础的,可是做错的人还真不少。

这是最容易踩的坑。宿主机用户的 UID 是 1001,容器内是 1000,创建的文件互相访问不了。

Terminal window
# 正确做法:让容器匹配宿主机用户
docker run \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
myimage

这问题说起来也挺常见的,可是第一次遇到的时候,还是挺让人郁闷的。

如果你发现每次重启都要重新登录,检查一下是不是忘记挂载持久化卷了:

volumes:
- claude-data:/home/hagicode/.claude

配置这东西,辛辛苦苦设置好了,说没就没了,那感觉,怎么说呢,挺让人崩溃的。

不要直接在运行的容器里执行 npm install -g。正确做法是:

  1. 设置环境变量触发覆盖安装
  2. 或者重新构建镜像
Terminal window
# 方式一:运行时覆盖
docker run -e CLAUDE_CODE_CLI_VERSION=2.2.0 myimage
# 方式二:重新构建
docker build -t myimage:v2 .

条条大路通罗马,只是有的路好走一点,有的路稍微曲折一点罢了。

  • API Token 通过环境变量传入,不写入镜像
  • 配置文件设置 600 权限
  • 始终以非 root 用户运行应用
  • 定期更新 CLI 版本,修复安全漏洞

安全这东西,说起来挺重要,可是真正落实的时候,又有多少人能做得好呢?

如果以后要支持新的 CLI 工具,只需要三步:

  1. Dockerfile.template:添加安装步骤
  2. docker-entrypoint.sh:添加版本覆盖逻辑
  3. docker-compose-builder-web:添加持久化卷映射

模板化的设计让扩展变得简单,不需要改动核心逻辑。这也算是过来人的一点心得,不是什么大道理,只是踩过的坑罢了。

Docker 容器中运行 AI CLI 工具,核心挑战在于用户权限、配置持久化、版本管理三个维度。HagiCode 项目通过创建专用用户、命名卷隔离、环境变量覆盖的组合方案,实现了既安全又灵活的部署架构。

关键设计要点:

  • 用户隔离:从镜像构建阶段创建专用用户,运行时支持 PUID/PGID 动态映射
  • 持久化策略:每个 CLI 工具对应独立的命名卷,容器重启不影响配置
  • 版本灵活性:固定默认值确保可重现性,运行时覆盖提供测试能力
  • 自动化配置:支持通过环境变量自动注入敏感配置

这套方案在 HagiCode 项目中已经稳定运行了一段时间,希望能给有类似需求的开发者一些参考。其实也没那么复杂,不过是些工程实践罢了。

感谢您的阅读,如果您觉得本文有用,欢迎点赞、收藏和分享支持。 本内容采用人工智能辅助协作,最终内容由作者审核并确认。

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

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

Section titled “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

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 命令嵌入到提示中
public enum AIProviderType
{
ClaudeCodeCli, // Anthropic Claude Code
OpenCodeCli, // 其他 CLI(可扩展)
GitHubCopilot, // GitHub Copilot
CodebuddyCli, // Codebuddy
CodexCli // OpenAI Codex
}

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

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,便于上层处理

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 调用
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 等工具权限配置
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 数据库持久化会话与线程的绑定关系
  • 线程复用:支持恢复已有线程,保持会话连续性
  • 工具调用追踪:追踪活动工具调用状态,正确处理工具生命周期

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 天的绑定会被自动清理

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

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

在切换提供者前,建议先调用 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 秒内响应视为健康
}

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

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

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

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

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

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

// 定期清理缓存
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);
}
}

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

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

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

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

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 编码助手体验。


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

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

从 TypeScript 到 C#:Codex SDK 的跨语言移植实践

从 TypeScript 到 C#:Codex SDK 的跨语言移植实践

Section titled “从 TypeScript 到 C#:Codex SDK 的跨语言移植实践”

怎么说呢,这篇文章也算是个孩子,记录了我们把官方 TypeScript Codex SDK 完整移植到 C# 的全过程。说是”移植”,其实更像是一场漫长的冒险,毕竟两种语言的脾性不太一样,总得想办法让它们好好相处。

Codex 这东西,是 OpenAI 推出的 AI Agent CLI 工具,确实挺强大的。官方给了 TypeScript SDK,放在 @openai/codex 这个包里。它呢,通过调用 codex exec --experimental-json 命令跟 Codex CLI 交互,解析 JSONL 格式的事件流。

可是吧,我们在 HagiCode 项目里,需要在一个纯 .NET 环境中使用它。具体来说,就是 C# 后端服务和桌面端应用。你说这事闹的,总不能为了调用一个 CLI 工具而在 .NET 项目中引入 Node.js 运行时吧?那也太折腾了。

于是摆在我们面前的就两条路:一是维护一套复杂的 Node.js 桥接层,二是自己动手丰衣足食,实现一个原生 C# SDK。

我们选择了后者。

其实这篇文也是来自我们在 HagiCode 项目里的实践经验。HagiCode 是个开源的 AI 代码助手项目,听起来挺高大上的,但说白了也就是同时维护着前端 VSCode 扩展、后端 AI 服务、跨平台桌面客户端等多种组件。这种多语言、多平台的复杂度,正是我们需要原生 C# SDK 的直接原因——总不能真的在 .NET 项目里跑个 Node.js 吧?那也太魔幻了。

如果你觉得这篇文章有点帮助,欢迎来 GitHub 给个 Star:github.com/HagiCode-org/site,也欢迎访问官网了解更多:hagicode.com。毕竟一个人品无限的项目能得到支持,也是件开心的事。

在开始代码层面的转化之前,我们得先理解两套 SDK 的架构设计。毕竟知己知彼,百战不殆嘛。

TypeScript SDK 的核心架构是这样的:

Codex (入口类)
└── CodexExec (执行器,管理子进程)
└── Thread (对话线程)
├── run() / runStreamed() (同步/异步执行)
└── 事件流解析

C# SDK 呢,保持了相同的架构层次,但在实现细节上做了适配。整体思路是:保持 API 的一致性,但在具体实现上充分利用 C# 语言特性。毕竟语言不同,总得有点区别才行。

这是最基础也是最重要的工作。毕竟万丈高楼平地起,基础打不好,后面全是麻烦。

TypeScript 的类型系统比 C# 更灵活,这是事实。我们需要找到合适的映射方式:

TypeScriptC#说明
interface / typerecordC# 使用 record 实现不可变数据结构
string | nullstring?可空引用类型
boolean | undefinedbool?可空布尔值
AsyncGeneratorIAsyncEnumerable异步迭代器

事件类型系统是一个典型的例子。TypeScript 使用联合类型来定义事件:

export type ThreadEvent =
| ThreadStartedEvent
| TurnStartedEvent
| TurnCompletedEvent
| ...

在 C# 中,我们使用继承层次和模式匹配来实现类似的效果:

public abstract record ThreadEvent(string Type);
public sealed record ThreadStartedEvent(string ThreadId) : ThreadEvent("thread.started");
public sealed record TurnStartedEvent() : ThreadEvent("turn.started");
public sealed record TurnCompletedEvent(Usage Usage) : ThreadEvent("turn.completed");
// ...

使用 record 而不是 class,是因为事件对象应该是不可变的,这和 TypeScript 中使用普通对象是一个道理。而 sealed 关键字则确保不会有额外的子类继承,编译器可以进行优化。其实也就那么回事,习惯就好了。

事件解析是整个 SDK 的核心,毕竟这决定了我们能否正确理解 Codex CLI 返回的每一条信息。解析错了,后面全是白忙活。

TypeScript 版本使用 JSON.parse() 来解析每一行 JSON:

export function parseEvent(line: string): ThreadEvent {
const data = JSON.parse(line);
// 处理各种事件类型...
}

C# 版本则使用 System.Text.Json.JsonDocument

public static ThreadEvent Parse(string line)
{
using var document = JsonDocument.Parse(line);
var root = document.RootElement;
var type = GetRequiredString(root, "type", "event.type");
return type switch
{
"thread.started" => new ThreadStartedEvent(GetRequiredString(root, "thread_id", ...)),
"turn.started" => new TurnStartedEvent(),
"turn.completed" => new TurnCompletedEvent(ParseUsage(...)),
// ...
_ => new UnknownThreadEvent(type, root.Clone()),
};
}

这里有一个小技巧:root.Clone() 是必要的,因为 JsonDocument 的元素在文档释放后就会失效,我们需要保留一份副本给未知的事件类型。这也是没办法的事,毕竟 C# 的 JSON 处理和 JavaScript 不太一样。

这是两个 SDK 差异最大的地方。毕竟 Node.js 和 .NET 的脾性不太一样,总得适应适应。

TypeScript 使用 Node.js 的 spawn() 函数:

const child = spawn(this.executablePath, commandArgs, { env, signal });

C# 使用 .NET 的 System.Diagnostics.Process

using var process = new Process { StartInfo = startInfo };
process.Start();
// 需要手动管理 stdin/stdout/stderr

具体来说,C# 版本需要这样配置进程:

var startInfo = new ProcessStartInfo
{
FileName = _executablePath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};

最大的区别在于取消机制。TypeScript 使用 AbortSignal,这是 Web API 的一部分,用起来挺顺手的:

const child = spawn(cmd, args, { signal: cancellationSignal });

C# 则使用 CancellationToken

public async IAsyncEnumerable<string> RunAsync(
CodexExecArgs args,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// 在循环中检查取消状态
while (!cancellationToken.IsCancellationRequested)
{
// 处理输出...
}
// 取消时终止进程
if (cancellationToken.IsCancellationRequested)
{
try { process.Kill(entireProcessTree: true); } catch { }
}
}

这其中的区别,大概就是Web API 和 .NET 生态的差异吧,说到底也就是那么回事。

两套 SDK 都实现了将 JSON 配置转换为 TOML 配置的逻辑,因为 Codex CLI 接受 TOML 格式的配置覆盖。这部分逻辑必须完全保持一致,否则同样的配置在两个 SDK 中会产生不同的行为。

这叫什么?这就叫工匠精神嘛。毕竟细节决定成败,有些事不能将就。

我们创建了这样的项目结构:

CodexSdk/
├── CodexSdk.csproj
├── Codex.cs # 入口类
├── CodexThread.cs # 对话线程
├── CodexExec.cs # 执行器
├── Events.cs # 事件类型定义
├── Items.cs # 项目类型定义
├── EventParser.cs # 事件解析器
├── OutputSchemaTempFile.cs # 临时文件管理
└── ...

看起来也挺整齐的,不是吗?

基本的使用方式和 TypeScript SDK 保持一致:

using CodexSdk;
// 创建 Codex 实例
var codex = new Codex();
var thread = codex.StartThread();
// 执行查询
var result = await thread.RunAsync("Summarize this repository.");
Console.WriteLine(result.FinalResponse);

流式事件处理利用了 C# 的模式匹配能力:

await foreach (var @event in thread.RunStreamedAsync("Analyze the code."))
{
switch (@event)
{
case ItemCompletedEvent itemCompleted
when itemCompleted.Item is AgentMessageItem msg:
Console.WriteLine($"Assistant: {msg.Text}");
break;
case TurnCompletedEvent completed:
Console.WriteLine($"Tokens: in={completed.Usage.InputTokens}");
break;
case CommandExecutionItem command:
Console.WriteLine($"Command: {command.Command}");
break;
}
}

在实现过程中,我们也不算是白忙活,总结点经验如下:

  1. 进程管理:C# 版本需要手动管理进程的生命周期,包括取消时的进程终止。使用 Kill(entireProcessTree: true) 确保子进程也被清理。这叫什么?这就叫有始有终。

  2. 错误处理:我们使用 InvalidOperationException 抛出解析错误,保持与 TypeScript SDK 相似的错误处理方式。毕竟错误处理这事儿,不能太随意。

  3. 资源清理OutputSchemaTempFile 实现 IAsyncDisposable,确保临时文件被正确清理。这也是没办法的事,资源不清理干净,总会有问题。

  4. 环境变量:C# 版本支持通过 CodexOptions.Env 完全覆盖进程环境变量。这功能虽然小,但挺实用的。

  5. 平台差异:C# 版本不包含 TypeScript 版本中自动查找 npm 包中二进制文件的逻辑。这是因为 .NET 项目通常不依赖 npm,所以需要通过 CODEX_EXECUTABLE 环境变量或 CodexPathOverride 指定 codex 可执行文件路径。这叫什么?这就叫因地制宜。

将一个成熟的 TypeScript SDK 移植到 C#,不仅仅是语法层面的转换,更是对两种语言设计哲学的理解。TypeScript 的灵活性和 JavaScript 生态特性(如 AbortSignal)在 C# 中需要找到对应的替代方案。这其中的酸甜苦辣,大概也只有真正做过的人才能体会。

关键体会是:保持 API 的一致性比保持实现细节的一致性更重要。用户关心的是接口是否易用,而不是内部实现是否相同。这话听起来简单,但做起来需要取舍。

如果你也在做类似的跨语言移植工作,我们的经验是:先完整理解原 SDK 的架构设计,然后逐个模块进行转化,最后通过完整的测试用例确保行为一致。毕竟急不得,一口吃不成胖子。

一切都会好的,都会有的…



如果本文对你有帮助


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

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