01

没有官方 API?我们自己造一个

把 DeepSeek 网页对话伪装成 OpenAI / Claude / Gemini API,让既有 SDK 不改一行代码就能跑。

痛点:你的代码不会变,但模型没 API

你已经写好了一套调用 SDK 的代码 —— 比如 OpenAI 的 openai.chat.completions.create()。某天你想换成 DeepSeek,结果发现:DeepSeek 只开放网页聊天,没有官方 API

💡
ds2api 的定位

它是一个 网关:对外假装成 OpenAI / Claude / Gemini API,对内把请求翻译成 DeepSeek 网页能听懂的对话,再把网页的流式回复包装回你期待的 API 格式。SDK 一行不动,模型已经悄悄换掉了。

谁会用它,为什么有意思

🧑‍💻

个人开发者

想把自己的 AI 工具切到 DeepSeek 省钱,但代码用了一堆 OpenAI SDK,不想全部重写。

🏢

小团队

已经在用 Claude Code / Codex CLI / LangChain,想接入 DeepSeek 做对比实验,又不想改框架。

🔧

工程学习者

想拆解一个真实的"协议适配 + 并发控制 + 流式转发"工程是怎么搭起来的。

一次请求会经历什么 —— 跟着数据走

想象你在终端运行 claude "解释一下闭包"。Claude Code 以为它在跟 Anthropic 服务器说话,但环境变量里 ANTHROPIC_BASE_URL 偷偷指向了本地的 ds2api。接下来发生的事:

CC
Claude Code
RT
chi 路由
PC
PromptCompat
AP
账号池
DS
DeepSeek 网页
点击"下一步"开始追踪请求

仓库结构 —— 五个核心目录

你不需要记目录名。但知道每件事住在哪里,能让你跟 AI 协作时一句话说清要改哪:"去 internal/promptcompat 改一下 Claude 的 system 注入"

internal/server/ chi 路由 + 中间件,所有 HTTP 请求的第一站
internal/httpapi/ 三套协议适配器:openai/ · claude/ · gemini/ · admin/
internal/promptcompat/ 把三种协议的消息归一成 DeepSeek 网页能吃的纯文本上下文
internal/account/ 账号池 + 并发槽位 + 等待队列
internal/deepseek/ 真正去敲 DeepSeek 网页的客户端:登录 / PoW / completion
pow/ DeepSeekHashV1 工作量证明的纯 Go 实现
api/ + internal/js/ Vercel Node Runtime 流式桥接,仅在 serverless 部署生效

记一下:路由就是个分检员

Go 后端启动时,internal/server/router.go 把所有协议入口挂到同一棵 chi 路由树上。每条路径只对应一种协议适配器:

CODE · internal/server/NewApp()
store, _ := config.LoadStoreWithError()
pool := account.NewPool(store)
resolver := auth.NewResolver(store, pool, ...)
dsClient := dsclient.NewClient(store, resolver)
if err := dsClient.PreloadPow(ctx); err != nil {
  config.Logger.Warn("[PoW] init failed", ...)
} else {
  config.Logger.Info("[PoW] pure Go solver ready")
}
中文翻译

先把配置读出来 —— 账号、密钥、阈值这些都在里面。

用配置造一个账号池,专门管"哪个 DeepSeek 账号现在空闲"。

造一个鉴权解析器,把客户端发来的 API key 翻译成具体哪个 DeepSeek 账号该接这单。

造一个 DeepSeek 客户端,真正去敲网页那边。

服务还没接客之前,先把 PoW(工作量证明)的求解器预热一下。

预热失败也不挂 —— 写一条警告,让接到第一单时再现算。

预热成功就打日志说"纯 Go PoW 解算器就绪",这是它的招牌之一。

📝
为什么开服前要预热 PoW

DeepSeek 网页每次发对话前要求客户端先算一道哈希难题(防爬)。冷启动时算一次得花几百毫秒,会让第一个用户感觉很慢。预热就是开门前先把灶火点上。

检查一下:你抓住了主干吗?

朋友问你 ds2api 到底是什么,下面哪个解释最准确?

在追踪一个 Claude Code 请求时,下一步它会撞到哪个组件?

你想改"Claude 的 system prompt 注入逻辑",应该改哪个目录?

02

三种协议形状如何归一

联合国同传亭:三国大使各讲各的语言,最后都翻译成一种"大堂语"递交。

问题:上游只懂一种语言

DeepSeek 网页对话的输入是一段纯文本上下文 —— 像你在网页框里贴了一长串"用户问、助手答、用户又问"的剧本。它不认识 OpenAI 的 messages: [{role, content}]、不认识 Claude 的 system + messages、更不认识 Gemini 的 contents: [{parts: [{text}]}]

🌐
同传亭比喻

三位大使分别讲法语、阿拉伯语、日语。上游领导只听一种"大堂语"。同传亭就是 PromptCompat:把任何一种入境语言翻译成大堂语,再把领导的回话翻译回各自原语递回去。 三种 API 的协议差异,全部在这里被吃掉。

三个大使,三种"形状"

🅾️
OpenAI 形状

扁平的 messages: [{role: "user"|"assistant"|"system"|"tool", content}]。工具描述放在顶层 tools 字段。

🅰️
Claude 形状

system 单独一个顶层字段;messages 里 user/assistant 严格交替;工具描述放 tools,工具结果以 tool_result 块嵌入 user 消息。

🅶
Gemini 形状

contents: [{role, parts: [{text}|{inlineData}|{functionCall}]}],多模态切片更细,role 只有 user/model。

🏛️
大堂语:DeepSeek 网页上下文

纯文本剧本,像 System: ...\n\nUser: ...\n\nAssistant: ...\n\nUser: ...。没有结构化字段,只有顺序和角色标记。

核心代码:归一的入口

不论你来自哪种协议,最终都会调到 BuildOpenAIPrompt 或它的兄弟函数。Claude 和 Gemini 适配器在前面做格式转换,把消息改造成 OpenAI 风格的 messages,然后复用同一条管道。

CODE · internal/promptcompat/prompt_build.go
func BuildOpenAIPrompt(messagesRaw []any, toolsRaw any,
    traceID string, toolPolicy ToolChoicePolicy,
    thinkingEnabled bool) (string, []string) {
  messages := NormalizeOpenAIMessagesForPrompt(messagesRaw, traceID)
  toolNames := []string{}
  if tools, ok := toolsRaw.([]any); ok && len(tools) > 0 {
    messages, toolNames = injectToolPrompt(messages, tools, toolPolicy)
  }
  return prompt.MessagesPrepareWithThinking(messages, thinkingEnabled), toolNames
}
中文翻译

这个函数接两件大件:原始消息列表、原始工具描述列表。

还有 traceID(追踪同一个请求的标签)和"是否开启思考链"的开关。

第一步:把消息归一化 —— 同样的 user/assistant/tool 角色都按 OpenAI 形状摆好,缺字段补默认值。

第二步:如果客户端传了工具,就把每个工具的名字和参数 schema 注入到 system 提示里。

DeepSeek 网页根本不懂"工具调用",所以工具说明只能用人话写进 prompt 里告诉模型:"你可以这样调用工具,输出按这个格式"。

最后一步:把所有消息按角色标签拼成纯文本剧本,可选地包一层 思考链 包装。

返回两件东西:拼好的纯文本上下文,以及这次允许调用的工具名清单(用于后面流式防泄漏)。

看一眼归一前后的对比

同一个"用户问太阳系有几大行星"的请求,OpenAI 形状进来后,最终递给 DeepSeek 网页的样子是一段纯文本:

客户端原始请求(OpenAI 形状)
{
  "model": "gpt-4.1",
  "messages": [
    {"role": "system", "content": "你是天文学助手"},
    {"role": "user", "content": "太阳系有几大行星?"}
  ],
  "stream": true
}
PromptCompat 拼好后递给 DeepSeek 网页的"剧本"

System: 你是天文学助手

User: 太阳系有几大行星?

Assistant: (等待网页流式吐字)

这一段才是 DeepSeek 网页真正认识的输入。从这里之后,三种协议彻底无差别。

不同适配器怎么"对话"

三种协议适配器和归一层的实际对话长这样:

为什么这样设计

🎯

单一收敛点

所有协议都走同一条管道。新增 Mistral 兼容?只需要加一个适配器把它"翻成 OpenAI 形状"。

🧱

最小公倍数

挑了 OpenAI 风格当中转格式 —— 因为它最接近"扁平消息列表",转成纯文本最便宜。

🔧

工具用 prompt 模拟

DeepSeek 网页没有结构化 tool call 接口。用一段精心写的 system 提示告诉模型"按 DSML 格式输出工具调用",再在流上拦截解析。

💡
普适设计教训

当你要兼容 N 种输入格式 + M 种输出格式,不要写 N×M 个适配器。选一种"中转格式",让所有输入归一到它,所有输出从它分发。 工程师叫这个 hub-and-spoke 架构。

检查理解

PromptCompat 最终递给 DeepSeek 上游的是什么?

为什么 ds2api 选 OpenAI 形状当所有协议归一的中转格式?

客户端发来一个带 tools 的请求,PromptCompat 怎么处理?

03

账号池:健身房储物柜的并发哲学

每个账号是一个储物柜,柜子有满员上限;满员时新人排队,柜子腾出来按到达顺序唤醒。

问题:账号会被掐

DeepSeek 网页对话有反爬规则:单账号短时间发太多请求会被风控。你想跑高并发?要么多备几个账号,要么慢一点 —— 最好两件事都做。账号池就是给多账号 + 限速排出秩序的那个调度员。

🏋️
健身房储物柜比喻

每个 DeepSeek 账号是一个储物柜,每个柜子最多能同时塞两件衣服(maxInflightPerAccount,默认 2)。来锻炼的人(请求)找空柜子;柜子全满时排队等;有人取走衣服,按排队顺序通知下一位。

账号池的内部结构

读一下 Pool 的核心字段,就知道它在记什么:

CODE · internal/account/pool_core.go
type Pool struct {
  store                  *config.Store
  mu                     sync.Mutex
  queue                  []string
  inUse                  map[string]int
  waiters                []chan struct{}
  maxInflightPerAccount  int
  recommendedConcurrency int
  maxQueueSize           int
  globalMaxInflight      int
}
每个字段在管什么

store:账号清单的来源,配置改了从这里重新读。

mu互斥锁。所有借/还账号的操作都先抢这把锁,避免两个请求同时改同一个柜子。

queue:账号 ID 的"轮询顺序"。每次借出一个账号,把它挪到队尾,让别的账号也有机会被选。

inUse:每个账号当前正在跑几单。比如 {"acc-A": 2, "acc-B": 0} 表示 A 满员、B 空闲。

waiters:等不到空柜子的请求。每个等待者持有一个 channel,柜子腾出来时被唤醒。

maxInflightPerAccount:单个柜子最多能同时塞几件衣服,默认 2。

recommendedConcurrency:动态算出来的"建议总并发" = 账号数 × 每柜上限。

maxQueueSize / globalMaxInflight:等待队列长度上限和全局正在跑的总数上限,两道保险防止雪崩。

核心动作:AcquireWait —— 借一个柜子,等不到就排队

CODE · internal/account/pool_acquire.go
func (p *Pool) AcquireWait(ctx context.Context,
    target string, exclude map[string]bool) (config.Account, bool) {
  for {
    if ctx.Err() != nil { return config.Account{}, false }
    p.mu.Lock()
    if acc, ok := p.acquireLocked(target, exclude); ok {
      p.mu.Unlock(); return acc, true
    }
    if !p.canQueueLocked(target, exclude) {
      p.mu.Unlock(); return config.Account{}, false
    }
    waiter := make(chan struct{})
    p.waiters = append(p.waiters, waiter)
    p.mu.Unlock()
    select {
      case <-ctx.Done(): /* 客户端取消,登记销毁 */
      case <-waiter:    /* 有人还柜子了,被唤醒 */
    }
  }
}
中文翻译

这是一个"循环重试"风格:先试着借,借不到就排队,被叫醒后再试。

每轮先看 context 是不是已经被取消(客户端跑了?超时?),是就空手回去。

抢锁、调 acquireLocked 试着借柜子。能借到就解锁、立刻拿着账号回去。

借不到,再问一句"我有资格排队吗" —— 全局上限到了 / 队列已经爆了 / 唯一可用账号在 exclude 黑名单里,都直接放弃。

能排队就建一个空 channel,把它登记到 waiters 列表,解锁后挂起来等。

挂起方式:select 在两件事上同时等 —— 上下文取消 或 有人通过 channel 叫醒我。

被叫醒后,循环回到顶部,重新抢锁、重新尝试借柜子。

这种 lock + 排队 + 信号通知的模式叫"生产者-消费者 + 公平唤醒",是并发编程的经典骨架。

追一遍三人抢两个柜子

假设你有 1 个账号、单柜上限 2 件。三个请求同时到:

R1
请求 1
A
账号池
R3
请求 3(排队中)
点"下一步"看请求怎么排队

设计取舍

⚖️

公平 vs 性能

用 channel 列表 + FIFO 唤醒,保证先到先服务,不会出现"运气不好的请求永远等不到"的饥饿问题。

🛑

两道保险

maxQueueSize 防排队雪崩 —— 队列爆了就直接拒绝,而不是让客户端无限等下去。

🔄

轮询 + bumpQueue

借出一个账号就把它挪到队尾,下次优先选别的 —— 让多账号"轮流喝水",分散每个账号的瞬时压力。

📝
为什么不直接信号量

用一个全局 信号量限总并发更简单 —— 但你失去了"哪些请求该等更久"的精细控制。账号池的设计允许:让目标账号已被指定的请求只在那一柜里排队,不被别人插队。

检查理解

inUse = {"acc-A": 2}maxInflightPerAccount=2,新请求要 acc-A,会发生什么?

为什么 AcquireWait 在挂起前要先调用 canQueueLocked 检查一下?

客户端在排队过程中断开连接,会发生什么?

04

工具调用防泄漏:直播延迟器的设计

SSE 是直播,模型可能突然吐出工具调用标记。来不及切的话,"幕后台词"会直接喷给客户端 —— Tool Sieve 就是那 5 秒延迟。

问题:模型嘴比手快

客户端用 OpenAI/Claude/Gemini 协议发来一个带工具的请求。DeepSeek 网页不懂结构化工具调用,所以模型会按 ds2api 在 system prompt 里教的格式,直接在文本流里吐类似这样的标记:

模型直接吐出来的原文(流式)
好的,我先查一下天气。

<|DSML|tool_calls>
  <|DSML|invoke name="get_weather">
    <|DSML|parameter name="city">Beijing</parameter>
  </invoke>
</tool_calls>
客户端期待看到的(OpenAI 格式)

"好的,我先查一下天气。" ← 普通文本,正常输出

然后是结构化的 delta.tool_calls JSON 字段,里面有 function name 和 arguments。

原始的 <|DSML|tool_calls>...</tool_calls> 标记绝不能让客户端看到 —— 那是内部协议,客户端的 SDK 不认识、用户更不认识。

📺
直播延迟器比喻

电视直播都有 5 秒缓冲:嘉宾突然说脏话,导播按一下钮把那段切掉。Tool Sieve 就是那个缓冲 + 切换:流来一段就缓存一段,识别到工具调用前缀就"延迟发布",等收完整段后切成结构化字段输出。

三种状态:流过、捕获、整段切换

1
流过模式 (passthrough)

不像工具调用前缀的字符直接发给客户端。注意"前缀"两字 —— 哪怕只看到 <|D,也得先停下来等等看完整。

2
捕获模式 (capturing)

认出可疑前缀后切到捕获模式,把后续 chunk 都先吃进 capture 缓冲,不发出去。直到看到结束标签 </tool_calls>,才整段解析。

3
整段切换

解析成功 → 当成结构化 tool_calls 事件吐出去;解析失败(其实是误识别) → 把缓冲的原文当普通文本补发,恢复流过模式。

核心循环:ProcessChunk

CODE · internal/toolstream/tool_sieve_core.go
func ProcessChunk(state *State, chunk string,
    toolNames []string) []Event {
  if chunk != "" { state.pending.WriteString(chunk) }
  events := make([]Event, 0, 2)
  for {
    if state.capturing {
      state.capture.WriteString(state.pending.String())
      state.pending.Reset()
      prefix, calls, suffix, ready := consumeToolCapture(state, toolNames)
      if !ready { break }  // 还没收完整,挂着等下一 chunk
      state.capturing = false
      if len(calls) > 0 {
        if prefix != "" { events = append(events, Event{Content: prefix}) }
        state.pendingToolCalls = calls
      }
      continue
    }
    pending := state.pending.String()
    start := findToolSegmentStart(state, pending)
    if start >= 0 {
      // 切捕获,前缀文本先吐出
      state.capturing = true
    } else {
      // 安全字符:直接吐到客户端
      events = append(events, Event{Content: pending})
    }
  }
  return events
}
中文翻译

每次上游来一段新文本(chunk),先扔进 pending 这个临时缓冲。

events 是这一轮要吐给客户端的事件列表,可能是文本、可能是工具调用、可能两者都没有(还在攒)。

无限循环:根据当前是不是"捕获模式"分两条路。

捕获模式下:把 pending 全倒进 capture 缓冲,调 consumeToolCapture 看能不能解出完整工具调用。

如果 ready=false 说明还没收齐 —— 比如 <invoke> 还没看到结束标签,先 break 等下一 chunk。

如果解析出有效 calls,就退出捕获,把工具调用前面的普通文本(prefix)先发出去。

流过模式下:扫 pending 看有没有可疑工具前缀。

有就切捕获模式,下一轮循环再处理。

没有就把 pending 当普通文本一次性吐给客户端。

这种"边收边判,可疑就缓冲,安全就放行"的状态机模式,就是流式协议解析的标准骨架。

三个角色的内心对话

误识别怎么办

模型有时会在解释代码时,正经地写出 <tool_calls> 这种字符串作为示例。Tool Sieve 怎么避免把它当真?

📦

代码块跳过

先识别 ``` 代码块边界,里面的所有标记都不当工具调用,直接当文本流过。

白名单校验

解析出工具名后必须在 toolNames 清单里 —— 这一单请求允许的工具。不在就当文本放出去。

🔁

解析失败回放

捕获完了但 JSON 不合法,或工具名不认 —— 把整段 capture 当普通文本补发,客户端看到的就是模型本来想写的字,不会丢字。

⚠️
泄漏 = 灾难

如果让 <|DSML|tool_calls> 漏到客户端,客户端 SDK 解析失败 → 用户看到一段奇怪的 XML/标签。这种 bug 在生产里会立刻被截图发到 issue 区。"宁可慢半拍,绝不漏一字" 是流式协议的铁律。

检查理解

上游一个 chunk 只有 <|D 三个字符,Tool Sieve 应该怎么做?

Tool Sieve 切到捕获模式,但最后发现是模型在解释代码,里面的 <tool_calls> 不在白名单里。怎么办?

用户反馈"工具调用前的最后几个字总是隔一拍才出来",原因是?

05

PoW + Vercel:进门先做 100 个深蹲,又快又能在云上跑

DeepSeek 网页要求每个请求先解一道哈希难题(PoW)。ds2api 用纯 Go 实现的解算器把它压到毫秒级,并在 Vercel 上用 Go+Node 双语接力。

什么是 PoW,为什么 DeepSeek 要

PoW 是反爬手段。DeepSeek 网页给每个请求发一道题:"在 [0, 1,000,000) 内找一个 nonce,使得 DeepSeekHashV1(salt + expireAt + nonce) 的前缀等于这个 challenge"。你不解出来,发请求会被拒。

🏋️
健身房门禁比喻

会员卡进门免费 —— 但要做 100 个深蹲。机器人没有耐心做深蹲,不是不能进,是性价比太低。PoW 把"发请求的边际成本"从近零拉到几毫秒 CPU,海量爬取就不划算了。

DeepSeekHashV1:被改一刀的 SHA3-256

注释里直接写明:"DeepSeekHashV1 = SHA3-256 但跳过 Keccak-f[1600] round 0 (只做 rounds 1..23)"。意思是它故意改了标准 SHA3 的一行,让外部 OpenSSL/通用库算不出来 —— 想解 PoW 必须自己实现。

CODE · pow/deepseek_hash.go 注释
// Package pow 提供 DeepSeekHashV1 纯 Go 实现。
// DeepSeekHashV1 = SHA3-256 但跳过 Keccak-f[1600] round 0
// (只做 rounds 1..23)。

func keccakF23(s *[25]uint64) {
  // 标准 Keccak-f[1600] 是 24 轮(0..23),
  // 这里从 r=1 开始,等于跳过第 0 轮。
  for r := 1; r < 24; r++ {
    ...
  }
}
中文翻译

SHA3-256 标准里 Keccak 置换是 24 轮,编号 0..23。

DeepSeekHashV1 只跑 1..23 这 23 轮,等于把第 0 轮跳过。

这一改,标准库的 SHA3 实现就不能直接用了 —— 你必须深入算法层手写一份。

这是 DeepSeek 反爬的"额外门槛":不光算 PoW 要 CPU,连能算 PoW 的实现都要自己写

ds2api 的卖点之一就是这份纯 Go 实现,不依赖 Python / OpenSSL,毫秒级解题,编译进单二进制。

SolvePow:在 [0, difficulty) 里碰运气

CODE · pow/deepseek_pow.go(节选)
func SolvePow(ctx context.Context, challengeHex, salt string,
    expireAt, difficulty int64) (int64, error) {
  prefix := []byte(BuildPrefix(salt, expireAt))
  // 把 prefix 预吸收进 Keccak 状态,循环里零分配
  var baseState [25]uint64
  // ... prefix 吸收逻辑 ...

  for n := int64(0); n < difficulty; n++ {
    if n&0x3FF == 0 {
      if err := ctx.Err(); err != nil { return 0, err }
    }
    s := baseState
    // 把当前 nonce 写进 numBuf,吸收进 s,跑 keccakF23
    // 比较输出前 32 字节是否等于 challenge
  }
}
中文翻译

入参:上下文、挑战的目标哈希值、盐、过期时间戳、难度上限。

先用 salt 和 expireAt 拼成固定前缀,把它"吸收"进 Keccak 内部状态。这一步只做一次,下面循环里就不重做了 —— 经典优化。

从 0 数到 difficulty,每个 n 当成候选 nonce 试一次。

每 1024 次循环检查一下 context —— 如果客户端断开 / 超时,立刻退出,不浪费 CPU 在没人要的答案上

复制基础状态(不污染原状态),把当前 nonce 的字符串形式吸收进去,跑剩下的 23 轮 Keccak。

输出的 32 字节如果等于 challenge,就找到了,返回这个 nonce。

这就是 PoW 的本质:暴力穷举。靠纯 Go 写得足够快(零分配、状态预吸收),DeepSeek 看到的请求带 nonce 才放行。

为什么纯 Go 是卖点

很多反向 DeepSeek 项目用 Python 或 wrap C 库实现 PoW —— 部署时要装 OpenSSL、要管 Python 环境。ds2api 单一 Go 二进制部署:拷一个文件、改个配置就能跑,Docker 镜像也能小到几兆。这种"零依赖"是它在小团队里被选中的核心原因。

Vercel 双语接力:Go 管账,Node 接管直播

本地部署一切都是 Go。但部署到 Vercel 时遇到一个工程难题:

🐹

Go 函数擅长

拿 PoW、做鉴权、操作账号池 —— 因为整个核心都在 Go 这边。Vercel 的 Go runtime 在这些事上跑得很好。

🟢

Node 函数擅长

Vercel 上 Node 的流式响应支持更顺:能稳定保持长连接、SSE 转发延迟低。Go runtime 在这件事上有时会 buffer 住。

🤝

于是接力

Node 函数收请求 → 调 Go 那边的 prepare(解 PoW、租账号、构造上游请求) → Node 自己直接和 DeepSeek 拉 SSE 流 → 完成后调 Go 的 release(还账号)。

Node 桥的入口长这样

CODE · internal/js/chat-stream/index.js(节选)
async function handler(req, res) {
  setCorsHeaders(res, req);
  if (req.method === 'OPTIONS') { res.statusCode = 204; res.end(); return; }
  const rawBody = await readRawBody(req);

  // 非 Vercel 环境一律转发给 Go
  if (!isVercelRuntime()) {
    await proxyToGo(req, res, rawBody); return;
  }
  const payload = JSON.parse(rawBody.toString('utf8'));

  // 非流式 / 非 OpenAI chat 路径 → 也丢给 Go
  if (!toBool(payload.stream) || !isNodeStreamSupportedPath(req.url)) {
    await proxyToGo(req, res, rawBody); return;
  }
  await handleVercelStream(req, res, rawBody, payload);
}
中文翻译

这是 Node 函数的入口。先处理 CORS 预检请求。

读完整请求体到 rawBody。

关键判断 1:如果不是 Vercel 环境(比如本地 docker),Node 啥都不做,直接代理给 Go 的 5001 端口。这保证只有云上才用 Node 路径。

关键判断 2:解析 body,看 stream 是不是 true、URL 是不是 OpenAI chat 路径。

如果不是流式、或者是 Claude/Gemini 路径,仍然交给 Go 处理(Go 实现完整、协议形状容易出错的事还是 Go 强)。

只有"OpenAI 风格 + 流式"才交给 Node 自己跑 —— 因为 Vercel 上 Node 流式更稳,这是它唯一的优势区间。

这种"默认 Go,少数情况让 Node 接管"的设计叫双 runtime 协同:保留单语言简洁,只在必要时引入第二语言。

设计哲学

🎓
这堂课最大的工程教训

当你必须在多个 runtime 之间分工,把"哪一段在哪边"写成清晰的判断规则,而不是让两边各做一半。这里 Node 的判断逻辑只有 3 行:不在 Vercel?滚回 Go。不是 stream?滚回 Go。不是 OpenAI 路径?滚回 Go。剩下的才自己干。简单的边界让协议形状漂移最小化。

检查理解

DeepSeekHashV1 跟标准 SHA3-256 最关键的差别是?

SolvePow 的循环里 if n&0x3FF == 0 { ctx.Err() } 这段是干嘛的?

为什么 Vercel 上的 Node 入口大部分情况会把请求转发给 Go?

课程小结

你看完了 ds2api 五个最值钱的设计:

1
它是协议层网关

不训新模型,只让上游 DeepSeek 网页假装成 OpenAI/Claude/Gemini API。

2
PromptCompat 是灵魂

三种协议归一到一种"扁平消息中转格式",再压扁成纯文本剧本。Hub-and-spoke。

3
账号池公平排队

每账号 in-flight 上限 + waiters channel 列表,FIFO 唤醒,背压双保险。

4
Tool Sieve 防泄漏

状态机 passthrough/capturing 切换,可疑前缀就缓冲,宁慢不漏。

5
PoW + 双 runtime 接力

纯 Go 解 DeepSeekHashV1,单二进制零依赖;Vercel 上 Node 只在它擅长的路径接管。

🚀
把这些用到你自己的项目

下次你要兼容多种输入形状,想想 hub-and-spoke。下次你要并发限速,想想"每实例 in-flight 上限 + 等待队列"。下次你要在流上识别特殊标记,想想"状态机 + 缓冲 + 失败回放"。这些就是真实工程师写代码的肌肉记忆。