数据截至 (上游 commit 5284672feb57)
SDK 与 API 契约(最底层:怎么跟后端对话)
30 秒导读: Context7 的所有上层功能(MCP 服务器、CLI、AI-SDK 工具)最终都靠
packages/sdk跟后端说话。而这个 SDK 干的事小得惊人——它把「和 context7.com 后端对话」压缩成两个 HTTP 端点: 先搜库拿到库的 ID,再取文档拿到该库的内容。本章讲透这份最底层的数据契约,以及 SDK 用Command模式 + 带重试的HttpClient把它包成好用方法的全过程。
这是全组文档的第一章,也是最浅、最底层的一层。上面几层都站在它肩膀上: 02-mcp-server.md 把这两个方法暴露成 MCP 工具, 03-cli.md 把它们包成命令行, 04-ai-sdk-tools-agent.md 把它们变成给大模型调用的 tool。 全景与阅读顺序见 index.md。
1. 这一层在解决什么(零基础也能懂)
假设你在做一个 AI 编程助手,想让它「查到某个库的最新官方文档,喂给模型」。你需要跟 Context7 的服务器要数据。但你不想每个上层(MCP、CLI、AI-SDK)都各写一遍「怎么拼 URL、怎么带 API key、网络抖动怎么重试、返回的 JSON 怎么解析」。
packages/sdk 就是把这些公共的脏活收进一个包。上层只需要:
// 示意,非源码:上层想要的心智模型
const c7 = new Context7({ apiKey });
const libs = await c7.searchLibrary("如何做路由", "react"); // 第一步:搜库
const docs = await c7.getContext("如何做路由", libs[0].id); // 第二步:取文档
一句话直觉: 把它想成一个「文档点唱机」——你先报个歌名(库名)查到唱片编号(库 ID), 再拿编号点歌(取该库的文档片段)。SDK 就是那台机器的投币口与出片口,后端才是唱片库。
本节到此不碰代码细节。记住一件事就够:这一层 = 两步、两个端点、一套统一的请求/重试/格式化。
2. 顶层全景(SDK 大概怎么转)
SDK 内部分三层,从用户手里的方法调用,一路下到裸 fetch:
你的代码
│ c7.searchLibrary(...) / c7.getContext(...)
▼
┌─────────────────────────────────────────────┐
│ Context7 类 (client.ts) │ ← 门面:校验 API key、建 HttpClient
│ · searchLibrary / getContext 各带重载 │
└───────────────┬─────────────────────────────┘
│ new XxxCommand(...).exec(httpClient)
▼
┌──────────────────── ─────────────────────────┐
│ Command 模式 (commands/*) │ ← 每个端点封成一个「命令对象」
│ · SearchLibraryCommand → v2/libs/search │ 命令知道:方法、路径、query、
│ · GetContextCommand → v2/context │ 以及「响应怎么格式化」
└───────────────┬─────────────────────────────┘
│ client.request({...})
▼
┌─────────────────────────────────────────────┐
│ HttpClient (http/index.ts) │ ← 传输层:拼 URL、带 header、
│ · 指数退避重试、no-store、解析 json/txt │ 重试、判 ok、分流 json vs 文本
└───────────────┬─────────────────────────────┘
▼
fetch → https://context7.com/api/...
各部件一句话职责:
| 部件 | 干什么 | 文件 |
|---|---|---|
Context7 类 | 对外门面,校验 key、装配 HttpClient、暴露两个方法 | packages/sdk/src/client.ts |
Command 基类 | 定义「一条请求 = 方法 + 端点 + query/body + 如何取结果」 | packages/sdk/src/commands/command.ts |
SearchLibraryCommand | 封装「搜库」端点,把响应映射成 Library[] | packages/sdk/src/commands/search-library/index.ts |
GetContextCommand | 封装「取文档」端点,合并代码/信息片段成 Documentation[] | packages/sdk/src/commands/get-context/index.ts |
HttpClient | 传输实现:URL 拼接、重试退避、cache、json/txt 分流 | packages/sdk/src/http/index.ts |
format.ts | 把后端裸字段映射成对外整洁类型,并可渲染成文本 | packages/sdk/src/utils/format.ts |
Context7Error | SDK 统一的错误类型 | packages/sdk/src/error/index.ts |
3. 核心契约:两步 = 两个端点
整个 Context7 后端,SDK 只用到两个 URL。它们被硬编码成一 个常量数组,是本层最重要的事实。
packages/sdk/src/commands/command.ts:3 的 _ENDPOINTS:
export const _ENDPOINTS = ["v2/libs/search", "v2/context"];
这两个端点对应「点唱机」的两步,缺一不可:
| 步骤 | 端点 | SDK 方法 | 输入 | 输出 |
|---|---|---|---|---|
| ① 搜库 | v2/libs/search | searchLibrary(query, libraryName) | 用户问题 + 库名 | Library[](带 id、trust/benchmark 分) |
| ② 取文档 | v2/context | getContext(query, libraryId) | 用户问题 + 库 ID | Documentation[](代码片段 + 信息片段) |
为什么必须两步? 因为库名(如 "react")是模糊的、可能撞 名;后端需要先用 v2/libs/search
做相关性排名,返回规范的库 ID(如 /react/react),第二步 v2/context 才用这个精确 ID 取文档。
第一步的 query 参数不是用来过滤库,而是喂给后端做相关性排序用(见 client.ts:76 的 JSDoc)。
两个端点在 SDK 里都走 GET(见 §5),尽管 Command 基类的默认方法是 POST——这个反差稍后解释。
4. 门面:Context7 类怎么开门
Context7 类(packages/sdk/src/client.ts:18)是上层唯一需要 new 的东西。它的构造函数做三件事,
顺序有讲究。
4.1 API key 的两道关卡
第一道是硬性的——没 key 直接抛错。client.ts:22-28:
const apiKey = config.apiKey || process.env.CONTEXT7_API_KEY;
if (!apiKey) {
throw new Context7Error(
"API key is required. Pass it in the config or set CONTEXT7_API_KEY environment variable."
);
}
key 的来源有优先级:显式传入的 config.apiKey 优先,否则回退到环境变量 CONTEXT7_API_KEY。
第二道是软性的——前缀不对只警告,不拦。client.ts:13 定义前缀 ctx7sk,client.ts:30-32:
if (!apiKey.startsWith(API_KEY_PREFIX)) { // API_KEY_PREFIX = "ctx7sk"
console.warn(`API key should start with '${API_KEY_PREFIX}'`);
}
关键细节: 前缀校验只 console.warn、不 throw。也就是说前缀不对的 key 仍会被拿去发请求
(真正的鉴权交给后端)。SDK 这里只做一个善意提醒。
4.2 装配 HttpClient(默认值都在这)
构造函数最后 new HttpClient(...)(client.ts:34-44),把一堆默认策略钉死在这里:
| 配置 | 值 | 出处 |
|---|---|---|
baseUrl | https://context7.com/api | client.ts:12 DEFAULT_BASE_URL |
| 鉴权头 | Authorization: Bearer <apiKey> | client.ts:36-37 |
| 重试次数 | retries: 5 | client.ts:40 |
| 退避函数 | backoff = (n) => Math.exp(n) * 50(毫秒) | client.ts:41 |
| 缓存 | cache: "no-store" | client.ts:43 |
注意 baseUrl 是写死的(没有从 config 读),所以标准 SDK 永远打 context7.com/api。
cache: "no-store" 保证每次都拿最新文档、不吃浏览器/运行时缓存——对「取最新文档」这个诉求很关键。
4.3 方法重载:同名方法,按 type 返回不同类型
searchLibrary 和 getContext 各写了三个 TypeScript 重载签名 + 一个实现。以 searchLibrary
为例(client.ts:50-88),重载让调用方按 options.type 拿到精确的返回类型:
| 调用 | 返回类型 |
|---|---|
searchLibrary(q, name, { type: "json" }) | Promise<Library[]> |
searchLibrary(q, name, { type: "txt" }) | Promise<string> |
searchLibrary(q, name)(默认) | Promise<Library[]> |
实现体本身很 薄(client.ts:81-88)——只负责建命令、执行命令:
async searchLibrary(query, libraryName, options?) {
const command = new SearchLibraryCommand(query, libraryName, options);
return await command.exec(this.httpClient);
}
真正的逻辑都在 Command 里。这就引出下一节。
5. Command 模式:一个端点 = 一个命令对象
5.1 它要解决的小问题
每个端点都有一堆固定属性:用哪个 HTTP 方法、打哪个路径、带哪些 query、响应怎么转成对外类型。
把这些捆在一个对象里,门面就不用关心细节,只管 new XxxCommand(...).exec(client)。这就是
经典的命令模式(Command Pattern,把「一次请求」封装成可执行对象)。
5.2 基类:请求的骨架
Command<TResult>(packages/sdk/src/commands/command.ts:13)存两样东西:request(方法+query+body)
和 endpoint(打哪个端点),并提供一个默认 exec(command.ts:25-38):
public async exec(client: Requester): Promise<TResult> {
const { result } = await client.request<TResult>({
method: this.request.method || "POST", // 基类默认 POST
path: [this.endpoint],
query: this.request.query,
body: this.request.body,
});
if (result === undefined) throw new TypeError("Request did not return a result");
return result;
}
一个容易踩的反差: 基类 exec 默认方法是 POST,但两个真实命令都用 GET 并且
各自 override 了 exec(见下)。所以基类的这段 exec 实际上在当前两个命令里都没被跑到——
它是模式的「默认实现/兜底」,而子类因为要做响应格式化,选择完全重写。
5.3 子类一:SearchLibraryCommand(搜库)
packages/sdk/src/commands/search-library/index.ts:10。构造时先前置校验参数,再定端点:
if (!query || !libraryName) {
throw new Context7Error("query and libraryName are required"); // index.ts:14-15
}
// ...把 query、libraryName 塞进 queryParams
super({ method: "GET", query: queryParams }, "v2/libs/search"); // index.ts:23
它 override 的 exec(index.ts:28-46)做「请求 → 映射 → 按需转文本」:
const { result } = await client.request<ApiSearchResponse>({ method: "GET", ... });
const libraries = result.results.map(formatLibrary); // 后端裸字段 → 整洁 Library
if (this.responseType === "txt") return formatLibrariesAsText(libraries);
return libraries;
关键细节(搜库的 txt 是客户端渲染的): 搜库从不把 type 发给后端——它总是拿 JSON,再在
本地用 formatLibrariesAsText 拼成文本。这和取文档不同(下一节)。
5.4 子类二:GetContextCommand(取文档)
packages/sdk/src/commands/get-context/index.ts:10。它把 type 发给后端(index.ts:19-22):
const responseType = options?.type ?? DEFAULT_TYPE; // 默认 "json"
queryParams.type = responseType; // ← type 进了 query,发给后端
super({ method: "GET", query: queryParams }, "v2/context");
override 的 exec(index.ts:27-48)据此分流:
if (this.responseType === "txt" && typeof result === "string") {
return result; // 后端已渲染好文本,直接返回
}
const apiResult = result as ApiContextJsonResponse;
const codeDocs = apiResult.codeSnippets.map(formatCodeSnippet); // 代码片段
const infoDocs = apiResult.infoSnippets.map(formatInfoSnippet); // 信息片段
return [...codeDocs, ...infoDocs]; // 合并成一个 Documentation[]
关键契约: v2/context 的 JSON 响应里,文档被拆成两类片段——codeSnippets(代码)和
infoSnippets(说明文字)。SDK 把两者各自格式化后首尾拼接成一个统一的 Documentation[]。
5.5 两个命令的契约对比
| 维度 | SearchLibraryCommand | GetContextCommand |
|---|---|---|
| 端点 | v2/libs/search | v2/context |
| 参数校验 | query 和 libraryName 必填,缺则抛错 | 不做必填校验 |
type 发给后端? | 否(txt 在客户端拼) | 是(txt 由后端渲染) |
| JSON 结构 | { results: [...] } | { codeSnippets, infoSnippets } |
| 映射函数 | formatLibrary | formatCodeSnippet + formatInfoSnippet |
| 对外类型 | Library[] | Documentation[] |
6. 格式化:后端裸字段 → 对外整洁类型
后端返回的字段名很「后端」(codeTitle、trustScore、versions…)。packages/sdk/src/utils/format.ts
负责把它们翻译成文档里承诺的干净类型,同时补齐缺省值。
formatLibrary(format.ts:28-46)把搜库结果映射成 Library,并给可选字段兜底:
return {
id: r.id,
name: r.title, // title → name(改名)
description: r.description,
totalSnippets: r.totalSnippets ?? 0, // 缺省补 0
trustScore: r.trustScore ?? 0,
benchmarkScore: r.benchmarkScore ?? 0,
versions: r.versions,
};
formatCodeSnippet(format.ts:4-18)是精华之一:它把后端的 codeList(多个语言/代码块)
重新拼成 markdown 代码围栏,并在前面接上描述文字:
`codeList` 每项 → ```<language>\n<code>\n``` ,多块以空行相连;
若有 codeDescription,则「描述 + 空行 + 代码块」拼成最终 content。
(源码 format.ts:5-12)
文本渲染这块还有个可读性巧思:getTrustScoreLabel(format.ts:51-56)把数字信任分翻译成人话标签,
供 formatLibraryAsText 用:
| trustScore | 标签 |
|---|---|
undefined 或 < 0 | Unknown |
>= 7 | High |
>= 4(且 < 7) | Medium |
其余(< 4) | Low |
formatLibrariesAsText(format.ts:88-94)则处理空结果——没搜到时返回一句
"No documentation libraries found matching your query.",而不是空字符串。
7. 传输层:HttpClient 的重试、退避与响应分流
HttpClient(packages/sdk/src/http/index.ts:86)是真正碰 fetch 的地方,也是本层最有工程含量的一段。
7.1 拼 URL:GET 的 query 在这里变成 querystring
request(http/index.ts:124)先把 baseUrl 和 path 用 / 连起来;只有 GET 才把 query
对象编码进 URL(http/index.ts:128-139),undefined 的键会被跳过:
let url = [this.baseUrl, ...(req.path ?? [])].join("/");
if (method === "GET" && req.query) {
const queryParams = new URLSearchParams();
Object.entries(req.query).forEach(([key, value]) => {
if (value !== undefined) queryParams.append(key, String(value));
});
// ...url += `?${queryString}`
}
请求选项里钉了 keepalive: true 和构造时传入的 cache(即 no-store)(http/index.ts:141-148)。
7.2 指数退避重试(只重试网络错误)
重试循环在 http/index.ts:153-166。默认 attempts = 5(http/index.ts:119),循环条件是
i <= attempts,所以最多发 6 次 fetch;每次失败后按 backoff(i) 毫秒等待再试:
for (let i = 0; i <= this.retry.attempts; i++) {
try {
res = await fetch(url, requestOptions);
break; // 成功就跳出
} catch (error_) {
if (requestOptions.signal?.aborted) throw error_; // 被 abort:立刻抛,不重试
error = error_ as Error;
if (i < this.retry.attempts) {
await new Promise((r) => setTimeout(r, this.retry.backoff(i))); // 退避等待
}
}
}
退避公式 backoff = (n) => Math.exp(n) * 50(http/index.ts:120、client.ts:41)是指数增长,
不是常见的 2^n。实际等待(毫秒)大致是:
| 重试轮次 n | Math.exp(n) * 50 约等于 |
|---|---|
| 0 | 50 ms |
| 1 | 136 ms |
| 2 | 369 ms |
| 3 | 1004 ms |
| 4 | 2730 ms |
两个重要边界:
- 只有
fetch抛错(网络层失败)才重试。 HTTP 4xx/5xx 属于「请求成功但状态非 ok」,不进这个 catch,而是走 §7.3 直接抛Context7Error——不重试。 - 被 abort 的请求不重试,
signal.aborted时直接把原错误抛出去。
循环结束若仍无 res,抛累积的错误或 "Exhausted all retries"(http/index.ts:167-169)。
7.3 判 ok 与 json/txt 分流
拿到响应后先判 res.ok(http/index.ts:171-177):非 ok 时尝试读 JSON 错误体,按
error → message → statusText 的优先级取一条消息,包成 Context7Error 抛出。
然后按 content-type 分流(http/index.ts:179-188):
content-type 含 "application/json" ?
├─ 是 → res.json(),作为 result 返回
└─ 否 → res.text(),并从响应头抽取分页信息(TxtResponseHeaders)一并返回
文本响应会额外解析 x-context7-* 头(page/limit/totalPages/hasNext/hasPrev/totalTokens),
逻辑在 extractTxtResponseHeaders(http/index.ts:191-211)——任一头缺失就整体返回 undefined
(全有才给)。这套分页头主要服务取文档的 txt 模式。
8. 错误模型:统一到 Context7Error
SDK 对外只抛一种「业务错误」——Context7Error(packages/sdk/src/error/index.ts:1),它就是
一个把 name 设成 "Context7Error" 的 Error 子类。上层可以用 instanceof Context7Error 稳稳分辨
「这是 SDK/后端语义错误」还是「别的意外」。
它出现在这些地方(都可 grep):
| 触发点 | 文件:行 | 含义 |
|---|---|---|
| 缺 API key | client.ts:25 | 构造 Context7 时没 key |
| 搜库缺参 | commands/search-library/index.ts:14 | query/libraryName 为空 |
| 后端返回非 ok | http/index.ts:176 | 4xx/5xx,消息取自响应体 |
| 结果为空 | 两个命令的 exec 里 | 请求成功但无 result,抛 Context7Error(基类兜底处抛的是 TypeError) |
注意基类 Command.exec 对空结果抛的是 TypeError(command.ts:34),而子类 override 后抛的是
Context7Error(如 search-library/index.ts:36)——因为子类走的是自己那份 exec。
9. 端到端:两步调用长什么样
把前面所有部件串起来,一次完整的「搜库 → 取文档」是这样(这也是上层 MCP/CLI 内部真正在做的事):
// 示意,非源码:演示两步契约
import { Context7 } from "@upstash/context7-sdk";
const c7 = new Context7({ apiKey: process.env.CONTEXT7_API_KEY }); // 校验 key、装配 HttpClient
// 第一步:搜库 → GET v2/libs/search,拿到候选库(默认 json)
const libs = await c7.searchLibrary("如何配置路由", "next.js");
const best = libs[0]; // best.id 形如 "/vercel/next.js"
// 第二步:取文档 → GET v2/context,用上一步的库 ID 精确取内容
const docs = await c7.getContext("如何配置路由", best.id);
// docs 是 Documentation[]:先是代码片段,后接信息片段
// 想要给人看的纯文本?把 type 切成 "txt"
const text = await c7.getContext("如何配置路由", best.id, { type: "txt" });
重点看: 第一步的返回决定第二步的输入——libs[0].id 是唯一把两个端点「串起来」的桥。这就是
本章开头那句「两步 = 两个端点」的完整含义。
10. 边界与局限(这一层刻意不做什么)
baseUrl写死。Context7Config只认apiKey(commands/types.ts:1-3),标准客户端无法改 base URL——想打自建/代理后端,得直接new HttpClient(...),门面类不给口子。- 不重试 HTTP 错误。 只有网络层
fetch抛错才退避重试;后端 429/5xx 会立即变成Context7Error,由上层自己决定是否重试(见 §7.2)。 - 前缀不拦。
ctx7sk前缀不对只console.warn,错的 key 照发,失败要等后端返回鉴权错误。 - 契约锁死在两个端点。 SDK 不暴露别的后端能力;
_ENDPOINTS就这两条,想扩展得改 SDK 本体。 - MCP/CLI 专属逻辑不在这层。 传输、认证协商、OAuth、工具封装分别在 02/03 章;本层只管 「裸 HTTP 契约 + 格式化」。
11. 代码地图(导航索引)
按符号名 grep 最抗行号漂移。
| 主题 | 文件路径 | 符号 |
|---|---|---|
| 门面类 / key 校验 / 装配 | packages/sdk/src/client.ts | Context7、DEFAULT_BASE_URL、API_KEY_PREFIX |
| 两个端点常量 | packages/sdk/src/commands/command.ts | _ENDPOINTS、EndpointVariants |
| 命令基类 | packages/sdk/src/commands/command.ts | Command、Command.exec |
| 搜库命令 | packages/sdk/src/commands/search-library/index.ts | SearchLibraryCommand |
| 取文档命令 | packages/sdk/src/commands/get-context/index.ts | GetContextCommand |
| 传输 / 重试 / 分流 | packages/sdk/src/http/index.ts | HttpClient、request、extractTxtResponseHeaders |
| 重试/请求类型 | packages/sdk/src/http/index.ts | Requester、RetryConfig、Context7Response |
| 格式化 | packages/sdk/src/utils/format.ts | formatLibrary、formatLibrariesAsText、formatCodeSnippet、formatInfoSnippet、getTrustScoreLabel |
| 对外类型 | packages/sdk/src/commands/types.ts | Context7Config、Library、Documentation、SearchLibraryOptions、GetContextOptions |
| 搜库/取文档响应型 | packages/sdk/src/commands/search-library/types.ts、.../get-context/types.ts | ApiSearchResponse、ApiContextJsonResponse、ApiCodeSnippet、ApiInfoSnippet |
| 错误类型 | packages/sdk/src/error/index.ts | Context7Error |
下一章: 02-mcp-server.md —— 看这两个方法如何被包成 MCP 的两个工具, 接上传输层与安全边界。