Codex SDK跨语言移植实战:从TypeScript到C#
从 TypeScript 跨语言迁移至 C#:Codex SDK 完整重构实录
本文记录了将 OpenAI 官方 TypeScript Codex SDK 完整移植到 C# 的全过程。这不仅是一次简单的语法转译,更是一场针对两种语言生态差异的系统性适配。核心挑战在于确保行为一致性的前提下,充分利用 C# 的语言特性。
项目背景
Codex 是 OpenAI 推出的 AI Agent CLI 工具,官方以 TypeScript SDK 形式发布(@openai/codex),通过调用 codex exec --experimental-json 命令与 Codex CLI 通信,解析 JSONL 格式的事件流。
在 HagiCode 项目中,我们需要在纯 .NET 环境中集成此能力——包括 C# 后端服务与桌面客户端。若引入 Node.js 运行时作为桥接层,会增加部署复杂度与维护成本。经过评估,我们决定从零构建原生 C# SDK,而非维护混合运行时方案。
最终选择了一条更彻底的路:直接实现 C# 版本的 SDK,保持 API 语义一致,同时适配 .NET 生态的习惯与最佳实践。
关于 HagiCode
本文所涉经验源于 HagiCode 开源项目——一个面向 AI 代码辅助的全栈工具链,包含前端 VSCode 扩展、后端 AI 服务、跨平台桌面客户端等组件。多语言、多运行时的架构使原生 C# SDK 成为刚需:直接通过 .NET 进程管理调用 CLI,避免在 .NET 项目中嵌入 Node.js。
核心内容
架构设计对比
迁移前必须先吃透两套 SDK 的架构分层。TypeScript 版本的核心结构如下:
Codex (入口类)
└── CodexExec (执行器,管理子进程)
└── Thread (对话线程)
├── run() / runStreamed() (同步/异步执行)
└── 事件流解析
C# 版本保留了相同的层次结构,但每个模块的实现细节均针对 .NET 运行时进行了适配。整体策略是:对外 API 用法保持高度一致,对内则充分利用 C# 的强类型、模式匹配与异步迭代等特性。
类型系统转化
类型映射是基础底座,决定了后续所有代码的正确性与可读性。TypeScript 的灵活类型(联合、可选、泛型)需要与 C# 的静态类型体系一一对应:
| TypeScript | C# | 说明 |
|---|---|---|
interface / type | record | C# 使用 record 实现不可变数据结构 |
string | null | string? | 可空引用类型 |
boolean | undefined | bool? | 可空布尔值 |
AsyncGenerator | IAsyncEnumerable | 异步迭代器 |
事件类型系统是最典型的案例。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 关键字则保证编译期能进行封闭分发优化,避免运行时动态派发开销。
核心转化点
1. 事件解析器
事件流解析是 SDK 的命脉——错误解析将直接导致下游行为错乱。TypeScript 版本逐行调用 JSON.parse():
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 释放后其元素会失效,克隆一份深拷贝才能安全挂载到 UnknownThreadEvent 中供后续处理。
2. 进程管理差异
这是两套 SDK 实现方式差距最大的模块。TypeScript 通过 Node.js 的 spawn() 快速启动子进程:
const child = spawn(this.executablePath, commandArgs, { env, signal });
C# 则必须使用 System.Diagnostics.Process 手动配置:
using var process = new Process { StartInfo = startInfo };
process.Start();
// 需要手动管理 stdin/stdout/stderr
具体启动配置:
var startInfo = new ProcessStartInfo
{
FileName = _executablePath,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
最大的适配挑战在于取消机制。TypeScript 原生支持 AbortSignal,可一行代码绑定到子进程:
const child = spawn(cmd, args, { signal: cancellationSignal });
C# 使用 CancellationToken 需要开发者手动轮询取消状态并执行清理:
public async IAsyncEnumerable RunAsync(
CodexExecArgs args,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
// 在循环中检查取消状态
while (!cancellationToken.IsCancellationRequested)
{
// 处理输出...
}
// 取消时终止进程
if (cancellationToken.IsCancellationRequested)
{
try { process.Kill(entireProcessTree: true); } catch { }
}
}
这种差异本质上是 Web API 与 .NET 生态在异步原语上的根本分歧,无法完全对齐。我们选择在文档中明确提示用户自行传入 CancellationToken,并确保进程树被彻底清理。
3. 配置序列化行为一致性
两套 SDK 都需要将用户传入的 JSON 配置转换为 Codex CLI 所需的 TOML 格式的覆盖配置。这部分逻辑必须严格镜像,否则相同的配置在不同环境中会产生不同的执行结果。我们在两个版本中分别实现了相同的 TOML 序列化算法,并通过集成测试逐条验证。
实现细节
项目结构
C# SDK 的项目布局如下:
CodexSdk/
├── CodexSdk.csproj
├── Codex.cs # 入口类
├── CodexThread.cs # 对话线程
├── CodexExec.cs # 执行器
├── Events.cs # 事件类型定义
├── Items.cs # 项目类型定义
├── EventParser.cs # 事件解析器
├── OutputSchemaTempFile.cs # 临时文件管理
└── ...
使用示例
基本调用模式与 TypeScript 版本保持一致,降低用户学习成本:
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;
}
}
注意事项
移植过程中积累的经验供同行参考:
- 进程生命周期管理:C# 版本必须显式管理子进程的启动、等待与终止。取消时务必调用
Kill(entireProcessTree: true)确保所有子进程(包括 Codex CLI 可能产生的子进程)被清理。 - 错误处理语义对齐:我们使用
InvalidOperationException抛出解析失败异常,与 TypeScript 版本中 throw Error 的语义一致。用户可按相同模式捕获并处理。 - 临时文件资源释放:
OutputSchemaTempFile实现了IAsyncDisposable接口,确保在异步作用域结束时自动删除临时文件,避免磁盘残留。 - 环境变量覆盖:通过
CodexOptions.Env属性可完全覆盖传递给 Codex CLI 的进程环境变量,适用于需要自定义 PATH 或代理配置的场景。 - 平台依赖差异:C# 版本不包含 TypeScript 版本中自动从 npm 包查找二进制文件的逻辑,因为 .NET 项目通常不依赖 npm。用户需通过
CODEX_EXECUTABLE环境变量或CodexPathOverride选项手动指定 codex 可执行文件路径。这是由 .NET 生态的部署习惯决定的,亦是合理的折衷。
经验总结
将成熟的 TypeScript SDK 移植到 C#,远不止于语法层面的机械映射。它要求开发者同时理解两种语言的设计哲学、运行时特性与生态惯例。TypeScript 的灵活性(如联合类型、AbortSignal)与 .NET 生态的严谨性(如 sealed record、CancellationToken)之间需要找到可维护的平衡点。
核心原则:保持 API 契约一致性比保持内部实现细节一致性更重要。用户面对的是接口,关注的是易用性与行为可预测性,而非内部代码长什么样。这一理念指导了我们在类型映射、取消机制、错误处理等所有关键决策。
如果你也正面临类似的跨语言移植任务,建议按以下步骤推进:先完整剖析原始 SDK 的架构图,将模块依赖关系梳理清楚;然后逐个模块进行转换,确保每个模块都能通过单元测试;最后用完整的端到端场景测试验证行为一致性。切忌一次性大规模重写,分而治之更可控。
参考资料
- 官方 TypeScript SDK:github.com/openai/codex
- C# SDK 源码:github.com/HagiCode-org/site/tree/main/repos/playground/CodexDotnet
- Codex 官方文档:codex.docs.anysphere.co
