把 Tabbit 浏览器内置大模型反代成本地 OpenAI 接口的踩坑实录(GLM-5.2 / GPT-5.5 / Gemini)

一、起因

在这里插入图片描述

最近在折腾 AI 浏览器的时候发现了一个叫 Tabbit 的浏览器,它内置了 GLM-5.2、GPT-5.5、Gemini 等主流大模型,而且只要把 Tabbit 设为默认浏览器,就能拿到 Pro 权限免费白嫖这些模型。

作为一个白嫖爱好者,第一反应就是:既然网页端能用,那理论上就能搞个本地反代,把它包装成 OpenAI 兼容的接口,这样所有支持自定义 OpenAI base_url 的客户端(Cursor、Cherry Studio、LobeChat 等)都能直接接入。

于是花了几小时折腾出了这个项目:tabbit2api

二、思路演进:从"直接调 API"到"模拟输入+拦截响应"

在这里插入图片描述

思路一:直接复用 Tabbit 的 API(失败)

最开始想的方案很直接:抓包看一下 Tabbit 网页端调用的是哪个接口,然后我们拿着 cookie 直接请求那个接口就行了。

打开 DevTools 一看,接口是挺标准的:

POST https://web.tabbit.ai/api/v1/chat/completion
Accept: text/event-stream

请求体长这样:

{
  "chat_session_id": "ab12cf7a-544d-4a35-aacb-41d00ab1fee3",
  "message_id": "<uuid>",
  "content": "你好",
  "selected_model": "GLM-5.2",
  "parallel_group_id": null,
  "task_name": "chat",
  "agent_mode": false,
  "metadatas": { "html_content": "<p>你好</p>" },
  "references": [],
  "entity": { "key": "d41d8cd98f00b204e9800998ecf8427e", "extras": { "type": "tab", "url": "" } }
}

看起来很美好,但实际一跑就发现:很多关键参数是加密的entity.key、某些 cookie 字段、以及 message_id 的生成规则都有校验,直接伪造的请求要么 401 要么返回乱码。逆向这些加密逻辑的成本远超收益,这条路堵死了。

思路二:模拟输入 + 模拟点击 + 拦截响应(成功)

既然没法绕过前端直接调接口,那就让前端自己去调,我们只负责把请求"塞进去"和把响应"取出来"

具体方案:

  1. 写一个 Chrome MV3 扩展,常驻在 Tabbit 网页里
  2. 扩展用 document.execCommand('insertText') 把外部请求的 prompt 模拟输入到 Tabbit 的编辑框
  3. 调用 sendButton.click() 模拟点击发送
  4. 注入到页面 MAIN world 的 hook 脚本拦截 /api/v1/chat/completion 的 fetch / XHR 响应,把 SSE 流原样转出来
  5. 本地 Node bridge 把流转换成 OpenAI 格式返回给客户端

这样加密逻辑全交给 Tabbit 自己的前端处理,我们只做"输入"和"输出"的搬运工。

三、整体架构

┌──────────────┐      OpenAI 协议       ┌─────────────────┐
│  任意客户端   │ ──── /v1/chat/comp ───▶│  本地 Node Bridge│
│ (Cursor等)   │ ◀─── OpenAI SSE ──────│  127.0.0.1:8787 │
└──────────────┘                        └────────┬────────┘
                                                  │ 任务队列
                                                  ▼
                              ┌─────────────────────────────────┐
                              │   Chrome MV3 Extension          │
                              │  ┌─────────────────────────┐    │
                              │  │ content.js (轮询任务)    │    │
                              │  │  - 模拟输入 prompt       │    │
                              │  │  - 模拟点击 send 按钮    │    │
                              │  └────────┬────────────────┘    │
                              │           │ postMessage          │
                              │  ┌────────▼────────────────┐    │
                              │  │ page-hook.js (MAIN world)│   │
                              │  │  - hook window.fetch     │    │
                              │  │  - hook XMLHttpRequest   │    │
                              │  │  - 解析 SSE 流并转发     │    │
                              │  └─────────────────────────┘    │
                              └─────────────────────────────────┘
                                                  │
                                                  ▼
                                       https://web.tabbit.ai

核心组件三个:

  • src/server.js:本地 bridge,暴露 OpenAI 兼容接口
  • src/extension/content.js:扩展内容脚本,负责模拟输入点击 + 任务轮询
  • src/extension/page-hook.js:注入到页面上下文,hook fetch / XHR 拦截响应

四、关键实现细节

1. 模拟输入:用 execCommand 而不是直接 set value

Tabbit 的编辑框是富文本组件(div[data-blur-action="editor-focus"]),直接 editor.textContent = 'xxx' 不会触发 React 的 onChange。必须用 document.execCommand:

async function focusAndSetEditorText(editor, text) {
  editor.scrollIntoView({ block: 'center', inline: 'nearest' });
  editor.click();
  editor.focus();
  await delay(50);

  document.execCommand('selectAll', false, null);
  document.execCommand('delete', false, null);

  const inserted = document.execCommand('insertText', false, text);
  if (!inserted) {
    // execCommand 在某些情况下会失败,fallback 到剪贴板
    await navigator.clipboard.writeText(text);
    editor.focus();
    document.execCommand('paste', false, null);
  }

  editor.dispatchEvent(new InputEvent('input', {
    bubbles: true,
    inputType: 'insertText',
    data: text,
  }));
}

发送按钮也必须等它从 disabled 变成可点击状态才能 click():

async function waitForClickableSendButton(timeoutMs) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const button = document.querySelector('#ChatSendButton');
    if (button && !button.disabled && button.getAttribute('aria-disabled') !== 'true') {
      return button;
    }
    await delay(100);
  }
  throw new Error('Tabbit 发送按钮不可用');
}

2. 拦截响应:同时 hook fetch 和 XHR

Tabbit 网页端有时候用 fetch,有时候用 XMLHttpRequest(取决于有没有流式压缩),所以两个都得 hook。page-hook.js 通过 <script> 标签注入到页面 MAIN world,这样能拿到页面真实的 window.fetch:

function hookFetch() {
  const originalFetch = window.fetch;
  window.fetch = async function fetchHook(input, init) {
    const url = getRequestUrl(input);
    const response = await originalFetch.call(this, input, init);

    if (!url.includes(TABBIT_COMPLETION_PATH)) {
      return response;
    }

    // 关键:response.clone() 一份用来读,原 response 还给页面
    const cloned = response.clone();
    readCompletionStream(cloned, 'fetch').catch(...);

    return response;
  };
}

XHR 那边更麻烦一点,得在 readystatechange 里持续读取 responseText,增量解析 SSE:

xhr.addEventListener('readystatechange', function () {
  const url = xhr.__tabbitUrl ?? '';
  if (!url.includes(TABBIT_COMPLETION_PATH)) return;
  if (xhr.readyState < 3) return;

  // 增量读取,只处理上次之后新增的部分
  const fullText = xhr.responseText ?? '';
  processXhrSse(fullText, accumulator);
});

3. SSE 解析:兼容各种 chunk 字段

不同模型的返回字段不一样,GLM 有 thinking/reasoning_content,OpenAI 系是 delta.content,有的甚至直接裸 textextractChunk 用一个优先级列表兼容:

function extractChunk(payload) {
  const chunk = {};

  const content = pick(payload, [
    'content', 'delta', 'text',
    'message.content', 'data.content', 'data.delta',
    'choices.0.delta.content', 'choices.0.message.content',
  ]);
  if (content) chunk.content = content;

  // 保留 thinking 字段,这样 GLM-5.2 的推理过程也能透传给客户端
  const thinking = pick(payload, [
    'thinking', 'reasoning', 'reasoning_content',
    'data.thinking', 'choices.0.delta.reasoning_content',
  ]);
  if (thinking) chunk.thinking = thinking;

  const finishReason = pick(payload, [
    'finish_reason',
    'choices.0.finish_reason',
    'data.finish_reason',
  ]);
  if (finishReason) chunk.finish_reason = finishReason;

  return Object.keys(chunk).length === 0 ? null : chunk;
}

4. 任务队列:bridge 和扩展之间的轮询通信

扩展不能主动给 bridge 推消息(没有 WebSocket),所以走的是长轮询:bridge 把任务塞进 pendingTasks 队列,扩展每 500ms 来取一次。

// bridge 端:把任务入队
pendingTasks.push(task);

// 扩展端:轮询取任务
async function pollTasks() {
  if (isRunning || !bridgeOnline) return;
  isRunning = true;
  try {
    while (true) {
      const response = await fetch(`${BRIDGE_ORIGIN}/__tabbit/tasks/next`);
      if (response.status === 204) break;  // 没有任务
      const task = await response.json();
      await executeTask(task);
    }
  } finally {
    isRunning = false;
  }
}

流式响应通过分块回传:

// 扩展每收到一个 chunk 就 POST 给 bridge
await serializedPostTaskUpdate(task.id, 'chunk', {
  content: chunk.content,
  thinking: chunk.thinking,
});

// bridge 收到 chunk 立刻转发给客户端
if (task.stream && (content || thinking)) {
  task.res.write(`data: ${JSON.stringify(createOpenAIStreamChunk({
    model: task.model,
    content: content || undefined,
    thinking: thinking || undefined,
  }))}\n\n`);
}

注意 serializedPostTaskUpdate 里用了 Promise 链式队列,保证同一个 task 的 chunk 顺序不会乱。

五、为什么弃坑了

跑通之后测了一阵子,最后还是决定弃坑,原因有两个:

1. Tabbit 内置 tools,不支持自定义工具调用

在这里插入图片描述

Tabbit 的 /api/v1/chat/completion 请求体里强制带 agent_mode 和内部 tools 列表,而且服务端会校验。我们没法在请求里塞自定义的 tools 字段,也没法拦截 function call。这意味着所有依赖 function calling 的客户端(Cursor 写代码、Agent 框架跑工具链)都跑不起来,只能当个普通聊天接口用。

2. Pro 额度根本扛不住编程调用

把它设为默认浏览器送的那个 Pro 额度,日常聊天够用,但编程场景下随便一个 Cursor 会话就是几十次调用,一下就触限了。白嫖的意义就没了。

所以最终这个项目就当成一个逆向研究样本发出来,有兴趣的可以看看。

六、使用方式

完整代码开源在 https://github.com/wlor0623/tabbit2api,大致步骤:

  1. npm install 安装依赖
  2. npm run build 打包扩展到 dist/extension/
  3. Chrome 加载 dist/extension/ 作为未打包扩展
  4. npm run start:bridge 启动本地 bridge(默认 127.0.0.1:8787)
  5. 打开 https://web.tabbit.ai/newtab 并登录
  6. 任意 OpenAI 客户端把 base_url 指到 http://127.0.0.1:8787/v1,API key 随便填

接口兼容:

  • GET /v1/models —— 列出 Tabbit 当前可用的模型
  • POST /v1/chat/completions —— 支持 stream: true/false,支持 thinking 字段透传

七、总结

这次折腾的几个收获:

  1. 遇到加密参数别死磕,换个思路让前端自己跑,往往比逆向省事
  2. response.clone() 是拦截 fetch 响应的关键,既能读流又不影响页面
  3. execCommand 虽然标记为 deprecated,但在富文本模拟输入场景依然是最佳方案,React 的 onChange 接不住直接 set value
  4. 白嫖有风险,Pro 额度对编程调用来说远远不够,真要稳定还是得老老实实付费

代码已经开源,欢迎 Star / 研究 / 改造。如果你也想搞类似浏览器内置 AI 的反代,这个项目的 fetch/XHR hook + 任务轮询 + OpenAI 流式转换这套组合拳可以直接复用。

项目地址:https://github.com/wlor0623/tabbit2api

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐