☰
80行TypeScript Mods:运行时劫持Claude Code接入Deepseek API
2026/10/18 4:34:47 网站建设 项目流程

1. 这个项目到底在折腾什么

第一次看到“80行 TypeScript Mods,把 Claude Code 改造成 Deepseek Harness”这个标题,我脑子里冒出来的第一个念头是:这又是一个拿命令行工具开刀的硬核玩法。Claude Code 是 Anthropic 推出的终端 AI 编程助手,它本身是一个封闭的 CLI 工具,官方并没有开放插件系统或者模型替换接口。而 Deepseek 是国内深度求索公司推出的大模型系列,在代码生成和推理任务上表现相当能打,尤其是 Deepseek Coder 和后续的 V3、R1 系列,API 价格便宜到让人怀疑人生。

这个项目的核心思路就是:不改动 Claude Code 的二进制文件,而是通过 TypeScript 编写的运行时补丁(Mods),在 Node.js 层面拦截并重写它的网络请求逻辑,把原本发往 Anthropic 的 API 调用转发到 Deepseek 的兼容接口上。说白了就是在 Claude Code 启动的时候,用--require或者--import参数预加载一个 TS 编译后的 JS 文件,这个文件在模块加载阶段就把fetch、https.request或者 SDK 内部的请求方法给替换掉。

为什么是 80 行?因为核心逻辑确实不复杂——拦截请求、改写 URL、替换 API Key、调整请求体格式、再把响应格式转回来。但真正有意思的地方在于:Claude Code 内部对 Anthropic API 的请求格式和响应格式有强依赖,包括 system prompt 的结构、tool use 的 schema、流式响应的 SSE 事件类型等等。Deepseek 的 API 虽然号称兼容 OpenAI 格式,但和 Anthropic 的格式差异不小,所以这 80 行里有一大半是在做格式转换和边界处理。

这个项目适合谁?三类人:一是想用 Claude Code 的交互体验但不想付 Anthropic 高昂 API 费用的开发者;二是想研究 CLI 工具运行时劫持技术的安全爱好者;三是想学习如何做 API 协议适配的工程师。如果你只是想让 Deepseek 帮你写代码,直接用 Cursor 或者 Continue 插件就行,没必要折腾这个。但如果你就是喜欢 Claude Code 那种终端里的流畅交互,又想把成本压到最低,那这个方案值得一看。

注意:本文讨论的是 API 协议适配和运行时补丁技术,所有操作均在本地开发环境进行,不涉及任何网络访问方式的改变。Deepseek 提供官方 API 接口,国内可直接调用。

2. 核心思路拆解:为什么选择运行时劫持而不是改源码

2.1 Claude Code 的封闭性与可劫持点分析

Claude Code 本质上是一个 Node.js 打包的 CLI 应用,大概率用了commander或yargs做参数解析,内部通过@anthropic-ai/sdk或者直接fetch调用 API。它没有开源,所以你不能直接改源码重新编译。但 Node.js 生态有一个天然优势:模块加载机制是开放的。

当你执行claude命令时,实际上是在跑一个 JS 文件。Node.js 支持--require参数在正式入口文件之前预加载一个模块,这个模块可以访问全局对象、修改globalThis、劫持Module._load方法、甚至重写process.env。这就是所有 Mods 的入口点。

具体到 Claude Code 这个场景,可劫持的点有好几个:

  • 环境变量层:Claude Code 读取ANTHROPIC_API_KEY和ANTHROPIC_BASE_URL来决定请求地址和认证信息。理论上你只要改这两个环境变量就能把请求指向别处。但问题是 Deepseek 的 API 格式和 Anthropic 不兼容,光改地址没用,请求体发过去对方也不认。
  • HTTP 客户端层:如果 Claude Code 用的是node-fetch或者原生fetch,你可以替换globalThis.fetch。如果用的是axios,就劫持axios.defaults.adapter。如果用的是https.request,就包装https.request。
  • SDK 层:如果它用了@anthropic-ai/sdk,你可以拦截 SDK 的create方法,在请求发出前改写参数,在响应返回后转换格式。

这个项目选择的是组合方案:先改环境变量把 base URL 指向本地代理或者直接指向 Deepseek,然后在 fetch 层做请求体和响应体的格式转换。为什么不全在 SDK 层做?因为 SDK 层拦截需要精确知道它调用的是哪个方法、传了什么参数,而 Claude Code 内部可能用了多种调用方式(比如流式和非流式混用),在 fetch 层拦截更通用。

2.2 为什么是 TypeScript 而不是直接写 JS

用 TypeScript 写 Mods 有几个实际好处。第一,Claude Code 内部的类型定义虽然不公开,但 Anthropic API 的请求响应格式是有公开文档的,你可以自己定义 interface 来做类型检查,避免在格式转换时写错字段名。第二,TS 编译后的 JS 可以兼容不同 Node 版本,你可以用tsc编译成 ES2020 或者 CommonJS,根据 Claude Code 的运行环境选择。第三,80 行的 TS 编译出来大概 60 行 JS,体积小,加载快,不会明显拖慢 CLI 启动速度。

但这里有个坑:Claude Code 可能对加载的模块有安全检查。虽然概率不大,但如果它检测到globalThis.fetch被替换过,可能会拒绝运行。实际测试中,大部分 CLI 工具不会做这种检查,但如果你遇到奇怪的行为,可以试试用Object.defineProperty来劫持,把替换后的函数描述符设置成和原生一样,降低被检测的风险。

2.3 Deepseek API 与 Anthropic API 的格式差异

这是整个项目最核心的技术难点。我整理了一个对比表格,方便你理解需要做哪些转换:

维度Anthropic APIDeepseek API转换要点
认证方式x-api-key请求头Authorization: Bearer请求头替换请求头字段名
请求体结构messages数组,system单独字段messages数组,system 作为第一条消息把 system 字段塞进 messages
工具调用tools数组,input_schema字段tools数组,parameters字段字段名映射
流式响应SSE 事件类型content_block_deltaSSE 事件类型delta事件类型转换
停止原因stop_reason: "end_turn"finish_reason: "stop"枚举值映射
最大 tokenmax_tokens必填max_tokens可选补默认值

这个表格里的每一行都对应 Mods 里的一段转换逻辑。80 行代码要处理这么多差异,说明每行都很紧凑,没有废话。

3. 80 行代码的逐段拆解与实操要点

3.1 入口逻辑:如何让 Claude Code 加载你的 Mods

Claude Code 的启动方式决定了你怎么注入代码。如果你是通过npx或者全局安装的claude命令启动,最直接的方式是找到它的入口 JS 文件,然后在同目录下创建一个mods.js,再用node --require ./mods.js /path/to/claude-entry.js来启动。但这样每次都要手动敲路径,很麻烦。

更优雅的方式是写一个 wrapper 脚本,比如claude-ds.sh:

#!/bin/bash export ANTHROPIC_BASE_URL="https://api.deepseek.com/v1" export ANTHROPIC_API_KEY="sk-your-deepseek-key" export NODE_OPTIONS="--require /path/to/mods.js" exec claude "$@"

这个脚本做了三件事:设置 base URL 指向 Deepseek、设置 API Key、通过NODE_OPTIONS注入预加载模块。NODE_OPTIONS是 Node.js 官方支持的环境变量,比直接改启动命令更干净。

但这里有个细节:Claude Code 可能在启动时覆盖ANTHROPIC_BASE_URL。如果它内部硬编码了 API 地址,环境变量就不生效。这时候就需要在 Mods 里直接拦截 fetch,把 URL 里的api.anthropic.com替换成api.deepseek.com。所以环境变量和 fetch 拦截是双保险,哪个生效用哪个。

实操心得:先只改环境变量测试,如果请求还是发往 Anthropic,再加 fetch 拦截。不要一上来就全套改,否则出问题了不好定位是哪一层的问题。

3.2 请求体转换:从 Anthropic 格式到 OpenAI 兼容格式

Deepseek 的 API 兼容 OpenAI 的/v1/chat/completions接口,所以请求体需要从 Anthropic 的格式转成 OpenAI 的格式。核心转换逻辑如下:

function convertRequest(anthropicBody: any): any { const messages = []; // system 字段转成第一条 system 消息 if (anthropicBody.system) { messages.push({ role: "system", content: anthropicBody.system }); } // 转换 messages 数组 for (const msg of anthropicBody.messages) { if (typeof msg.content === "string") { messages.push({ role: msg.role, content: msg.content }); } else if (Array.isArray(msg.content)) { // 处理多模态内容块 const textParts = msg.content .filter((c: any) => c.type === "text") .map((c: any) => c.text) .join("\n"); messages.push({ role: msg.role, content: textParts }); } } // 转换 tools const tools = anthropicBody.tools?.map((t: any) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.input_schema, }, })); return { model: "deepseek-chat", // 或 deepseek-coder messages, tools, max_tokens: anthropicBody.max_tokens || 4096, stream: anthropicBody.stream || false, }; }

这段代码看起来简单,但有几个容易踩坑的地方。第一,Anthropic 的 content 可以是字符串也可以是数组,数组里可能有text、image、tool_use、tool_result等多种类型。如果你的使用场景涉及图片或者工具调用,需要更复杂的转换逻辑。第二,tool_result 的处理:Anthropic 把工具执行结果放在 user 消息的 content 数组里,类型是tool_result,而 OpenAI 格式要求单独的role: "tool"消息。这个转换如果做错了,工具调用会直接失败。

第三,max_tokens 的默认值。Anthropic 要求必填,Deepseek 可选但建议填。如果你不填,Deepseek 默认可能是 4096 或者更小,长代码生成会被截断。建议根据实际需求设置,一般 8192 够用。

3.3 响应体转换:SSE 流式事件的映射

Claude Code 大概率用流式响应来实现打字机效果,所以响应转换比请求转换更关键。Anthropic 的 SSE 事件类型和 OpenAI 的差异很大:

Anthropic 的流式事件序列大概是:

  • message_start:消息开始,包含 message id 和 model
  • content_block_start:内容块开始,包含 index 和 type
  • content_block_delta:内容增量,包含text_delta或input_json_delta
  • content_block_stop:内容块结束
  • message_delta:消息级别的增量,包含 stop_reason
  • message_stop:消息结束

OpenAI 的流式事件就简单多了:

  • 每个 chunk 是一个data: {...},包含choices[0].delta.content
  • 最后一个是data: [DONE]

转换逻辑需要把 OpenAI 的 chunk 包装成 Anthropic 的事件序列。核心代码如下:

function convertStreamChunk(openaiChunk: any, state: any): string[] { const events: string[] = []; const delta = openaiChunk.choices?.[0]?.delta; if (!state.started) { state.started = true; events.push(`event: message_start\ndata: ${JSON.stringify({ type: "message_start", message: { id: openaiChunk.id, type: "message", role: "assistant", content: [] } })}\n\n`); events.push(`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}\n\n`); } if (delta?.content) { events.push(`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: delta.content } })}\n\n`); } if (openaiChunk.choices?.[0]?.finish_reason) { events.push(`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}\n\n`); events.push(`event: message_delta\ndata: ${JSON.stringify({ type: "message_delta", delta: { stop_reason: "end_turn" } })}\n\n`); events.push(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`); } return events; }

这段代码的关键在于状态管理。因为 SSE 是流式的,你需要在内存里维护一个 state 对象,记录消息是否已经开始、当前 content block 的 index 是多少、是否已经发送过 stop 事件。如果状态管理出错,Claude Code 可能会卡住或者显示乱码。

注意:Anthropic 的 SSE 格式要求每个事件有event:行和data:行,最后以两个换行符结束。少一个换行符都可能导致解析失败。我在测试的时候因为少写了一个\n,调试了半小时才发现。

3.4 工具调用的特殊处理

Claude Code 的核心功能之一是工具调用——它能读写文件、执行命令、搜索代码。这些工具通过 Anthropic 的 tool use 机制实现。当你把请求转发到 Deepseek 时,工具调用的格式需要特别处理。

Deepseek 的 function calling 格式和 OpenAI 一致,但和 Anthropic 有几个关键差异:

  • Anthropic 的tool_use内容块包含id、name、input三个字段,其中input是已经解析好的对象
  • OpenAI 的tool_calls包含id、function.name、function.arguments,其中arguments是 JSON 字符串

所以转换时需要把input对象序列化成 JSON 字符串,同时把id和name映射到正确的字段。反过来,当 Deepseek 返回工具调用时,你需要把 JSON 字符串解析成对象,再包装成 Anthropic 的tool_use格式。

这里有个隐藏的坑:Deepseek 可能不支持并行工具调用。Anthropic 允许一次返回多个 tool_use,但 Deepseek 的 API 可能只返回一个。如果你的 Mods 没有处理这种情况,Claude Code 可能会等待第二个工具调用而卡死。解决方案是在转换时检测tool_calls数组长度,如果只有一个就正常处理,如果有多个就分批发送或者只取第一个。

4. 实操全流程:从零搭建你的 Deepseek Harness

4.1 环境准备与依赖安装

先确认你的 Node.js 版本。Claude Code 要求 Node 18 以上,建议用 20 LTS。然后安装 TypeScript 和必要的类型定义:

npm init -y npm install -D typescript @types/node npx tsc --init

tsconfig.json需要调整几个关键配置:

{ "compilerOptions": { "target": "ES2020", "module": "CommonJS", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true } }

为什么用 CommonJS 而不是 ESM?因为 Claude Code 的入口文件大概率是 CommonJS 打包的,用 CommonJS 编译的 Mods 兼容性更好。如果你用 ESM,--require可能不生效,需要用--import并且配合--experimental-vm-modules,麻烦得多。

4.2 编写核心 Mods 文件

在src/mods.ts里写入完整逻辑。我把它分成四个部分:保存原生 fetch、定义转换函数、替换全局 fetch、处理错误。

const originalFetch = globalThis.fetch; function convertRequest(url: string, init: RequestInit): [string, RequestInit] { // URL 替换 const newUrl = url.replace("api.anthropic.com", "api.deepseek.com"); // 请求头替换 const headers = new Headers(init.headers); const apiKey = headers.get("x-api-key"); if (apiKey) { headers.delete("x-api-key"); headers.set("Authorization", `Bearer ${apiKey}`); } headers.set("Content-Type", "application/json"); // 请求体转换 let body = init.body; if (body && typeof body === "string") { try { const parsed = JSON.parse(body); body = JSON.stringify(convertRequest(parsed)); } catch (e) { // 不是 JSON 就原样传递 } } return [newUrl, { ...init, headers, body }]; } globalThis.fetch = async function(input: any, init?: any) { const url = typeof input === "string" ? input : input.url; if (url.includes("api.anthropic.com")) { const [newUrl, newInit] = convertRequest(url, init || {}); const response = await originalFetch(newUrl, newInit); // 流式响应转换 if (response.headers.get("content-type")?.includes("text/event-stream")) { return convertStreamResponse(response); } // 非流式响应转换 const data = await response.json(); return new Response(JSON.stringify(convertResponse(data)), { status: response.status, headers: { "Content-Type": "application/json" }, }); } return originalFetch(input, init); };

这段代码的核心是只拦截发往 Anthropic 的请求,其他请求原样放行。这样不会影响 Claude Code 的其他网络操作,比如检查更新、遥测上报等。

4.3 编译与注入

编译 TS 文件:

npx tsc

然后在 Claude Code 的启动脚本里注入。如果你用的是 zsh 或 bash,可以在.zshrc或.bashrc里加一个 alias:

alias claude-ds='NODE_OPTIONS="--require /path/to/dist/mods.js" ANTHROPIC_BASE_URL="https://api.deepseek.com/v1" ANTHROPIC_API_KEY="sk-your-key" claude'

这样每次输入claude-ds就会用 Deepseek 作为后端启动 Claude Code。第一次运行的时候,建议先跑一个简单任务测试,比如让它读一个文件或者生成一段代码,确认请求和响应都正常。

实操心得:如果 Claude Code 启动时报错说找不到模块,检查NODE_OPTIONS里的路径是不是绝对路径。相对路径在--require里可能不生效,因为工作目录可能被改变了。

4.4 验证与调试

验证是否生效的最简单方法是看网络请求。你可以在 Mods 里加一行console.error("Intercepted:", url),这样每次拦截请求都会在终端输出。如果看到api.anthropic.com被打印出来,说明拦截成功。

另一个验证点是看响应内容。如果 Claude Code 正常显示 Deepseek 生成的代码,说明格式转换没问题。如果显示乱码或者报错,大概率是 SSE 事件格式不对。这时候可以把原始响应和转换后的响应都打印出来对比,找出差异。

调试流式响应的时候,建议先把stream参数强制设为false,用非流式模式测试。非流式模式下响应是一个完整的 JSON,转换逻辑简单得多,容易定位问题。等非流式跑通了,再开流式。

5. 常见问题与排查技巧实录

5.1 请求发出去了但返回 401

这是最常见的问题,原因通常是 API Key 没有正确传递。Claude Code 可能从多个地方读取 Key:环境变量、配置文件、命令行参数。你的 Mods 只替换了请求头里的x-api-key,但如果 Claude Code 根本没往请求头里放 Key,而是放在别的地方,替换就无效。

排查方法:在 Mods 里打印init.headers的全部内容,看看 Key 到底在不在里面。如果不在,检查 Claude Code 的文档或者用strings命令分析它的二进制文件,找到它读取 Key 的逻辑。

另一个可能是 Deepseek 的 Key 格式不对。Deepseek 的 Key 以sk-开头,如果你复制的时候多了空格或者换行,也会导致 401。

5.2 流式响应卡住不输出

如果 Claude Code 显示“正在思考”但一直不出内容,大概率是 SSE 格式有问题。Anthropic 的 SSE 解析器对格式要求很严格,每个事件必须以event:开头,data:行必须是合法 JSON,事件之间用空行分隔。

常见错误包括:JSON 字符串里有多余的换行符、事件类型拼写错误、缺少message_stop事件导致解析器一直等待。建议用curl直接请求 Deepseek 的流式接口,把原始响应保存下来,然后对照你的转换逻辑逐行检查。

还有一个隐藏问题:Deepseek 的流式响应可能包含空 delta。有些 chunk 的delta是空对象,只有finish_reason。如果你的转换逻辑没有处理这种情况,可能会生成空的content_block_delta,导致 Claude Code 显示空白。

5.3 工具调用失败或参数错误

工具调用失败通常表现为 Claude Code 说“正在执行工具”但没有任何结果,或者报错说参数格式不对。根本原因往往是input_schema和parameters的转换不完整。

Anthropic 的input_schema是一个 JSON Schema 对象,包含type、properties、required等字段。OpenAI 的parameters格式基本一致,但有些细节差异,比如$schema字段在 OpenAI 里不被支持,需要删掉。另外,Anthropic 允许input_schema里嵌套复杂的anyOf、oneOf,而 Deepseek 可能不支持这些高级特性,需要简化。

排查方法:把转换后的tools数组打印出来,和 Deepseek 的 API 文档对比。如果 schema 太复杂,先简化成最基本的type: "object"加几个简单属性,确认工具能调用后再逐步加回复杂结构。

5.4 常见问题速查表

问题现象可能原因排查方法解决方案
401 未授权API Key 未传递或格式错误打印请求头检查 Key确认 Key 在请求头中且无多余字符
流式响应卡住SSE 格式错误或缺少结束事件对比原始响应和转换后响应检查事件类型和换行符
工具调用无结果schema 转换不完整打印转换后的 tools 数组简化 schema 或补全字段映射
响应内容截断max_tokens 设置过小检查请求体中的 max_tokens设置为 8192 或更大
Claude Code 启动报错Mods 加载失败检查 NODE_OPTIONS 路径使用绝对路径并确认文件存在
请求未被拦截fetch 替换未生效在 Mods 里加日志输出确认 Claude Code 用的是全局 fetch

5.5 几个我踩过的坑

第一个坑是循环拦截。我在替换globalThis.fetch的时候,不小心在替换函数里又调用了globalThis.fetch,导致无限递归,Node 直接栈溢出。正确做法是在文件顶部先把原生 fetch 保存到变量里,替换函数里调用这个变量。

第二个坑是Headers 对象不可变。init.headers可能是Headers实例,也可能是普通对象,还可能是数组。直接修改可能会报错。稳妥的做法是new Headers(init.headers)创建一个新实例再改。

第三个坑是Deepseek 的模型名称。Claude Code 请求体里的model字段是claude-3-5-sonnet-20241022之类的,Deepseek 不认识。你需要在转换时把 model 替换成deepseek-chat或deepseek-coder。如果不替换,Deepseek 会返回模型不存在的错误。

第四个坑是并发请求。Claude Code 可能同时发起多个请求,比如一个用于主对话,一个用于代码索引。如果你的 Mods 用了全局状态来管理流式转换,并发请求会互相干扰。解决方案是每个请求创建一个独立的 state 对象,用 Map 或者 WeakMap 来关联请求和状态。

6. 这个方案的边界与扩展思路

6.1 当前方案的局限性

80 行代码能做到的事情有限。目前这个方案主要支持文本对话和基础工具调用,对于以下场景可能不够用:

  • 图片输入:Anthropic 支持图片内容块,Deepseek 的视觉模型 API 格式不同,需要额外转换
  • 长上下文:Claude 支持 200K token,Deepseek 的上下文窗口可能更小,超长对话会被截断
  • 并行工具调用:如前所述,Deepseek 可能不支持一次返回多个工具调用
  • 提示词缓存:Anthropic 的 prompt caching 功能在 Deepseek 上没有对应实现

这些局限性意味着,如果你重度依赖 Claude Code 的高级功能,这个方案可能只能作为轻量替代,不能完全替代原生体验。

6.2 可以扩展的方向

如果你想把这个方案做得更完善,有几个方向值得尝试:

方向一:本地代理服务器。不在 Node 层面劫持,而是启动一个本地 HTTP 服务器,把 Claude Code 的 base URL 指向http://localhost:3000,由代理服务器做格式转换。这样做的好处是可以用任何语言写代理,调试更方便,而且不依赖 Node 的模块加载机制。

方向二:多模型路由。在 Mods 里根据请求内容动态选择后端模型。比如代码生成用 Deepseek Coder,对话用 Deepseek Chat,长文本用其他模型。这样能发挥不同模型的优势。

方向三:请求日志与回放。把所有请求和响应保存到本地文件,方便分析问题。更进一步,可以实现回放功能,用保存的响应模拟 API 返回,用于离线测试。

方向四:配置化。把模型名称、API 地址、超时时间等参数抽到配置文件里,不用改代码就能切换后端。这样你可以轻松在 Deepseek、其他兼容 OpenAI 格式的模型之间切换。

6.3 关于合规与成本的提醒

Deepseek 的 API 价格确实便宜,但要注意几点:第一,API 调用会产生费用,虽然单价低,但大量使用也要控制;第二,Deepseek 的 API 有速率限制,高频调用可能被限流;第三,生成的代码质量因模型而异,Deepseek Coder 在部分任务上可能不如 Claude,需要根据实际效果调整预期。

另外,这个方案本质上是在使用 Claude Code 的客户端体验,但后端换成了其他模型。从技术角度这是完全合法的 API 调用,但建议你了解相关服务的使用条款,确保自己的使用方式符合规定。

我个人在实际操作中的体会是,这个方案最大的价值不是省钱,而是让你理解 CLI 工具的运行时结构。当你亲手拦截了一个请求、转换了一个响应、看到 Claude Code 用 Deepseek 的模型正常工作时,你对 Node.js 模块系统、HTTP 协议、SSE 流式传输的理解会上一个台阶。这种理解比省下的那点 API 费用值钱得多。

最后分享一个小技巧:如果你在调试过程中把 Claude Code 搞崩了,不用重装,直接删掉NODE_OPTIONS环境变量再启动就恢复原样了。Mods 是运行时注入的,不会修改任何原始文件,所以随时可以回退。这个特性让你可以放心大胆地折腾,不用担心搞坏环境。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询