1. 从零搭 LSP 工程时,AI 补全请求为什么总卡在鉴权上
如果你正在写一个 VS Code 语言服务器插件,前面几节应该已经把 client 和 server 的骨架跑通了:createConnection建好连接,onCompletion返回几个写死的CompletionItem,按 F5 能在新窗口里看到补全列表。但接下来想把补全结果换成大模型实时生成的内容时,问题就来了——语言服务器进程里怎么安全地拿到模型能力,又不让每个插件各自维护一份密钥。
这个场景在 vscode 插件开发里非常典型。LSP 的 server 端是一个独立进程,它通过connection.onCompletion接收补全请求,返回CompletionItem[]。如果想让这个返回值来自大模型,server 进程就需要发起一次 HTTP 请求。问题在于:请求的 endpoint 写在哪、Key 从哪读、多个插件之间怎么复用同一套配置。我见过不少项目直接把 Key 硬编码在server.ts里,或者每个插件各写一份.env,结果换一次 Key 要改五六个仓库。
TaoToken 在这里扮演的角色就是一个统一的模型接入层。它提供兼容 OpenAI 风格的接口,语言服务器只要把baseURL指向https://taotoken.net/api,用同一个 Key 就能调用不同模型。这样 LSP 工程里只需要维护一份配置,client 端和 server 端都不用关心具体模型厂商的差异。对于正在写 vscode 插件、想让 AI 补全链路走统一 Key 通道的开发者来说,这一节要解决的就是「server 进程如何拿到模型能力」这个具体问题。
先说清楚整体链路:VS Code 编辑器触发补全 → client 通过 IPC 把请求发给 server → server 的onCompletion回调被调用 → 回调里向 TaoToken 发请求 → 拿到模型返回的文本 → 包装成CompletionItem[]返回给编辑器。我们要改的就是中间那一步,把原来写死的数组换成真实请求。
在动手之前,你需要确认三件事:Node 环境能跑(建议 18 以上)、已经有一个能跑通的 LSP 骨架工程、以及一个 TaoToken 的 API Key。Key 的获取在控制台里完成,后面会给出具体路径。整个改造不需要动 client 端的extension.ts,所有工作都集中在 server 目录。
这里有个容易踩的坑:LSP server 进程默认没有网络请求的依赖,你需要自己装一个 HTTP 客户端。用 Node 内置的fetch也行,但要注意 LSP server 的 Node 版本可能和编辑器宿主不一致。稳妥起见,我在 server 的package.json里显式加了node-fetch,避免版本差异导致的fetch is not defined。这个报错在 LSP 工程里很常见,因为 server 进程的运行时是编辑器拉起来的,不一定是你终端里的那个 Node。
另外要提醒的是,补全请求是高频操作。用户每敲一个字符都可能触发一次onCompletion,如果每次都同步等模型返回,编辑器会明显卡顿。所以真实工程里通常要做防抖或者缓存,但这一节先聚焦链路打通,把请求发出去、结果能回来、编辑器能显示,这三步走通之后再谈优化。链路不通的时候谈性能没有意义。
2. TaoToken 统一 Key 接入:把 endpoint 和鉴权收口到一处
这一节讲怎么把模型接入配置收口。核心思路是:LSP server 不直接读环境变量里的散装 Key,而是通过一个统一的配置模块拿到baseURL、apiKey、model三件套。这样以后换模型或者换 Key,只改一个地方。
先说 TaoToken 的接入信息。API 地址是https://taotoken.net/api,注意这个地址不带任何查询参数,直接作为 OpenAI 兼容接口的 base。官网在https://taotoken.net/,控制台里可以创建和管理 API Key。如果你还没建 Key,进控制台后找到 API Keys 页面新建一个,复制出来备用。文档页有完整的接口说明,遇到参数不确定的时候可以对照。
为什么要在 LSP 工程里做「统一 Key」这件事?因为一个 VS Code 插件工程往往不止一个语言服务器。你可能同时有 JS 的 server、Python 的 server、还有 Markdown 的 server,如果每个 server 各自读一份 Key,配置就会散落。更麻烦的是 client 端有时候也需要调模型(比如做命令面板的 AI 功能),如果 client 和 server 各维护一份,轮换 Key 的时候必然漏掉一个。
我的做法是在工程根目录放一个config/ai.json,client 和 server 都从这个文件读。但 LSP server 进程的工作目录不一定等于工程根目录,所以路径要用context.asAbsolutePath或者环境变量传进去。更稳的方式是通过initializationOptions把配置从 client 传给 server,这样 server 启动时就拿到了配置,不用自己去找文件。
具体来说,client 端在创建LanguageClient的时候,clientOptions里可以带initializationOptions。server 端在onInitialize回调的params.initializationOptions里就能读到。这条通道是 LSP 协议原生支持的,不需要额外开文件或者环境变量,非常适合传这种启动期就确定的配置。
配置的结构建议长这样:一个baseURL指向 TaoToken 的 API 地址,一个apiKey放密钥,一个model指定默认模型,再加一个timeout控制请求超时。model 字段很重要,因为不同任务适合不同模型,补全这种场景通常用响应快的模型,而代码解释可以用能力更强的。把这些都放在配置里,切换的时候不用改代码。
安全方面要注意:apiKey不要提交到 git。在工程里加.gitignore排除config/ai.json,然后提供一个config/ai.example.json作为模板。团队协作时每个人复制一份填自己的 Key。如果你要把插件发布到市场,更不能把 Key 打包进去,这种情况应该让用户在自己的设置里填 Key,插件通过workspace.getConfiguration读取。这一节先按本地开发场景走,发布场景后面再展开。
还有一个细节:TaoToken 的接口是 OpenAI 兼容的,所以请求体格式和 OpenAI 的/v1/chat/completions一致。但 base URL 的拼法要注意,https://taotoken.net/api后面接/v1/chat/completions,不要重复拼/v1。我见过有人写成https://taotoken.net/api/v1/v1/chat/completions,结果 404。这个在排障章节会再提。
3. 可复制的 LSP 初始化配置片段
这一节给出可以直接抄的配置。先看 server 端的package.json,在原有依赖基础上加node-fetch:
{ "name": "lsp-demo-server", "description": "demo language server with ai completion", "version": "1.0.0", "license": "MIT", "engines": { "node": "*" }, "dependencies": { "vscode-languageserver": "^8.1.0", "vscode-languageserver-textdocument": "^1.0.8", "node-fetch": "^2.7.0" }, "scripts": {} }注意vscode-languageserver的版本,老教程里常见的是 4.x,但 4.x 的 API 和现在差别较大。如果你是从头写,建议用 8.x,TextDocuments的用法更清晰。node-fetch用 2.x 是因为 3.x 是 ESM only,在 CommonJS 的 LSP server 里引入会报错。这个坑我踩过,require('node-fetch')在 3.x 下会抛ERR_REQUIRE_ESM。
然后是配置模块server/src/aiConfig.ts:
export interface AiConfig { baseURL: string; apiKey: string; model: string; timeout: number; } export const defaultAiConfig: AiConfig = { baseURL: "https://taotoken.net/api", apiKey: "", model: "gpt-4o-mini", timeout: 8000 }; export function resolveAiConfig( initOptions: any ): AiConfig { const fromInit = initOptions?.ai ?? {}; return { baseURL: fromInit.baseURL || defaultAiConfig.baseURL, apiKey: fromInit.apiKey || process.env.TAOTOKEN_API_KEY || "", model: fromInit.model || defaultAiConfig.model, timeout: fromInit.timeout || defaultAiConfig.timeout }; }这个模块做了两件事:定义默认配置,以及从initializationOptions里解析出实际配置。注意apiKey的兜底顺序是先看初始化参数,再看环境变量。这样本地开发时你可以用环境变量,团队协作时用初始化参数,两种方式都支持。
client 端的extension.ts里,创建LanguageClient时把配置塞进initializationOptions:
import * as path from "path"; import { workspace, ExtensionContext } from "vscode"; import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from "vscode-languageclient/node"; let client: LanguageClient; export function activate(context: ExtensionContext) { const serverModule = context.asAbsolutePath( path.join("server", "out", "server.js") ); const serverOptions: ServerOptions = { run: { module: serverModule, transport: TransportKind.ipc }, debug: { module: serverModule, transport: TransportKind.ipc, options: { execArgv: ["--nolazy", "--inspect=6009"] } } }; const clientOptions: LanguageClientOptions = { documentSelector: [{ scheme: "file", language: "javascript" }], initializationOptions: { ai: { baseURL: "https://taotoken.net/api", apiKey: process.env.TAOTOKEN_API_KEY || "", model: "gpt-4o-mini", timeout: 8000 } } }; client = new LanguageClient( "DemoLanguageServer", "Demo Language Server", serverOptions, clientOptions ); client.start(); } export function deactivate(): Thenable<void> | undefined { if (!client) { return undefined; } return client.stop(); }这里initializationOptions里的ai对象就是 server 端resolveAiConfig读的那个。三件套baseURL、apiKey、model都在这里出现,缺一不可。如果你用的是 Codex 的auth.json或者 Cline 的 MCP 配置,思路是一样的:把这三个值填到对应的配置位置。CC Switch 这类工具也是同样的逻辑,切换的就是这三件套。
server 端的server.ts里,onInitialize回调要保存配置:
import { createConnection, TextDocuments, ProposedFeatures, InitializeParams, CompletionItem, CompletionItemKind, TextDocumentPositionParams } from "vscode-languageserver"; import { TextDocument } from "vscode-languageserver-textdocument"; import { resolveAiConfig, AiConfig } from "./aiConfig"; let connection = createConnection(ProposedFeatures.all); let documents = new TextDocuments(TextDocument); let aiConfig: AiConfig; connection.onInitialize((params: InitializeParams) => { aiConfig = resolveAiConfig(params.initializationOptions); return { capabilities: { textDocumentSync: 1, completionProvider: { resolveProvider: true, triggerCharacters: [".", " "] } } }; });textDocumentSync: 1表示全量同步,简单场景够用。triggerCharacters里加.和空格,这样用户敲这两个字符时会主动触发补全。注意resolveProvider: true要保留,因为后面onCompletionResolve还要用。
到这里配置部分就齐了。baseURL是https://taotoken.net/api,apiKey从环境变量或初始化参数来,model指定模型 ID。这三个值在 client 和 server 之间通过 LSP 协议传递,不需要额外的文件读写。
4. 用一次补全请求验证链路是否生效
配置写完了,现在要验证链路真的通了。这一节给出完整的onCompletion实现,以及怎么确认请求确实发到了 TaoToken。
先看 server 端的补全回调。核心是把用户当前行的上下文拼成 prompt,发给模型,再把返回的文本包装成CompletionItem:
import fetch from "node-fetch"; connection.onCompletion( async ( textDocumentPosition: TextDocumentPositionParams ): Promise<CompletionItem[]> => { const doc = documents.get(textDocumentPosition.textDocument.uri); if (!doc) { return []; } const line = doc.getText({ start: { line: textDocumentPosition.position.line, character: 0 }, end: textDocumentPosition.position }); if (!aiConfig.apiKey) { connection.window.showWarningMessage( "TaoToken API Key 未配置,补全走本地兜底" ); return localFallback(textDocumentPosition); } try { const controller = new AbortController(); const timer = setTimeout( () => controller.abort(), aiConfig.timeout ); const resp = await fetch( `${aiConfig.baseURL}/v1/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${aiConfig.apiKey}` }, body: JSON.stringify({ model: aiConfig.model, messages: [ { role: "system", content: "你是代码补全助手,只返回补全的代码片段,不要解释。" }, { role: "user", content: `补全下面这行 JavaScript 代码:\n${line}` } ], max_tokens: 64, temperature: 0.2 }), signal: controller.signal } ); clearTimeout(timer); if (!resp.ok) { const errText = await resp.text(); connection.console.error( `TaoToken 请求失败 ${resp.status}: ${errText}` ); return localFallback(textDocumentPosition); } const data: any = await resp.json(); const content = data?.choices?.[0]?.message?.content?.trim() ?? ""; if (!content) { return localFallback(textDocumentPosition); } return [ { label: content, kind: CompletionItemKind.Text, detail: `AI 补全 (${aiConfig.model})`, documentation: "由 TaoToken 统一 Key 通道生成", data: 100 } ]; } catch (e: any) { connection.console.error(`补全请求异常: ${e.message}`); return localFallback(textDocumentPosition); } } ); function localFallback( pos: TextDocumentPositionParams ): CompletionItem[] { return [ { label: "console.log", kind: CompletionItemKind.Snippet, data: 1 }, { label: "function", kind: CompletionItemKind.Keyword, data: 2 } ]; }这段代码有几个关键点。第一,fetch的 URL 是${baseURL}/v1/chat/completions,baseURL 是https://taotoken.net/api,拼出来就是https://taotoken.net/api/v1/chat/completions。第二,鉴权用Authorization: Bearer <key>,这是 OpenAI 兼容接口的标准写法。第三,加了AbortController做超时,避免模型响应慢的时候编辑器一直转圈。第四,任何异常都走localFallback,保证补全功能不会因为网络问题完全不可用。
onCompletionResolve也要补上,因为resolveProvider: true时编辑器会二次调用:
connection.onCompletionResolve( (item: CompletionItem): CompletionItem => { if (item.data === 100) { item.detail = "AI 补全结果"; item.documentation = "该补全由语言服务器通过 TaoToken 统一 Key 通道请求模型生成。"; } return item; } ); documents.listen(connection); connection.listen();现在验证链路。在终端里设置环境变量,然后按 F5 启动调试:
export TAOTOKEN_API_KEY="你的Key"VS Code 会打开一个新的扩展开发宿主窗口。在里面新建一个.js文件,输入const arr = [1,2,3]; arr.,等一两秒,补全列表应该出现模型生成的内容,detail 显示AI 补全 (gpt-4o-mini)。如果出现了,说明链路通了。
想确认请求真的发出去了,可以在 server 端加一行日志,或者直接看 TaoToken 控制台的用量记录。控制台里能看到每次请求的模型、token 数和时间戳。如果控制台有记录但编辑器没显示补全,问题就在返回值的包装上;如果控制台没记录,问题在请求发出之前,检查 Key 和 URL。
还有一个更直接的验证方式:在onCompletion里加connection.console.log,把resp.status打出来。VS Code 的输出面板里选「Demo Language Server」,就能看到日志。200 表示成功,401 表示 Key 有问题,404 表示 URL 拼错了。
5. 本篇常见错排查:401、local proxy failed 与 reading choices
链路跑不通的时候,报错信息往往很含糊。这一节把几个高频错误对照着讲清楚。
401 Unauthorized。这是最常见的。原因通常是 Key 没传进去或者传错了。先检查aiConfig.apiKey是不是空字符串。如果 client 端用process.env.TAOTOKEN_API_KEY读,要确认启动 VS Code 的终端里确实export了这个变量。注意:如果你是从桌面图标启动 VS Code,它继承的是系统环境变量,不是终端里的。调试场景下按 F5 启动的宿主窗口继承的是当前终端的环境,所以export之后按 F5 是有效的。另一个可能是 Key 复制的时候带了空格或者换行,Bearer后面多一个空格就会 401。建议在代码里apiKey.trim()一下。
local proxy failed。这个报错通常出现在企业网络环境里,系统配了 HTTP 代理但 Node 的 fetch 没走代理,或者代理配置和实际网络不匹配。LSP server 进程默认不读系统的代理设置。如果你确实需要走代理,要在 fetch 里显式配置 agent。但更常见的情况是:这个报错和 TaoToken 无关,是本地网络栈的问题。先确认curl https://taotoken.net/api/v1/models能不能通,如果 curl 通而插件不通,就是 Node 进程的网络配置问题。检查HTTP_PROXY/HTTPS_PROXY环境变量,必要时在 server 启动时清掉。
Cannot read properties of undefined (reading 'choices')。这个报错说明data.choices是 undefined,也就是返回的 JSON 结构不符合预期。原因通常是 URL 拼错了,请求打到了别的路径,返回了一个错误页面的 HTML 或者别的 JSON 结构。检查baseURL后面拼的是不是/v1/chat/completions。如果 baseURL 已经带了/v1,再拼一次就变成/v1/v1/chat/completions,会 404。TaoToken 的 base 是https://taotoken.net/api,不带/v1,所以拼一次是对的。另外,如果返回的是流式响应(stream: true),data.choices的结构也不一样,这一节用的是非流式,别混用。
OAuth 相关报错。如果你在配置里误用了 OAuth 的鉴权方式,会看到invalid_grant或者unsupported_grant_type。TaoToken 的 API 用的是 Bearer Token,不是 OAuth 流程。把Authorization头改成Bearer <key>就行。Codex 的auth.json里如果配的是 OAuth 凭据,也要换成 API Key 模式。Cline 的 MCP 配置里同理,鉴权字段填 API Key。
补全不触发。配置都对但补全列表不弹出来,检查documentSelector里的language是不是javascript,以及文件后缀是不是.js。如果你在.ts文件里测试,要把language改成typescript或者加一条。另外triggerCharacters里如果没有.,手动按 Ctrl+Space 也能触发。
server 进程起不来。按 F5 后新窗口里没有任何反应,看「输出」面板的 server 日志。常见原因是server/out/server.js不存在,也就是 TypeScript 没编译。检查tsconfig.json的outDir和rootDir,以及有没有跑tsc -b。如果用了 project references,根目录的tsconfig.json要正确引用 client 和 server 两个子项目。
改了代码不生效。LSP server 是独立进程,改了 server 代码要重启宿主窗口才生效。client 代码改了也要重启。调试时用tsc -b -w开 watch,改完保存后重启窗口。
6. 把统一 Key 通道接到你的下一个 LSP 工程
链路打通之后,接下来可以做的事就多了。最直接的是把补全从「单行」扩展到「多行上下文」,把光标前后的代码都拼进 prompt,让模型给出更准确的建议。这时候要注意 token 消耗,补全场景下 prompt 不宜太长,通常取当前行加上面几行就够了。
另一个方向是加缓存。同样的前缀不应该重复请求模型,可以在 server 端用一个 Map 缓存line -> completion的映射,命中就直接返回。缓存要设过期时间,否则内存会涨。这个优化对编辑器流畅度提升很明显。
如果你打算把这个插件发布出去,Key 的管理方式要改。不能把 Key 打包进插件,而是让用户在 VS Code 的设置里填。用workspace.getConfiguration('yourPlugin').get('apiKey')读取,然后在package.json的contributes.configuration里声明这个设置项。这样每个用户用自己的 Key,插件本身不含任何凭据。
TaoToken 的 Coding Plan 适合长期做编码类插件的场景,因为补全请求量大,按量计费的模式更灵活。如果你只是偶尔测试,用 API Keys 页面建的 Key 就够了。模型对话页面可以用来快速验证某个模型对代码补全的效果,不用每次都改插件代码。
接入文档里有完整的接口参数说明,遇到不确定的字段可以对照。控制台里能看用量和余额,调试阶段建议盯着点,避免 Key 泄露导致意外消耗。
最后说一个实际经验:LSP server 里的网络请求一定要有超时和兜底。模型服务再稳也有抖动的时候,如果补全请求卡住,整个编辑器的补全功能都会受影响。我现在的做法是超时设 8 秒,超时后返回本地静态补全,同时给用户一个不打扰的提示。这样即使模型不可用,基本的编码体验还在。链路打通只是第一步,让它稳定可用才是工程化的开始。