把 Tabbit 浏览器内置大模型反代成本地 OpenAI 接口的踩坑实录(GLM-5.2 / GPT-5.5 / Gemini)
把 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 要么返回乱码。逆向这些加密逻辑的成本远超收益,这条路堵死了。
思路二:模拟输入 + 模拟点击 + 拦截响应(成功)
既然没法绕过前端直接调接口,那就让前端自己去调,我们只负责把请求"塞进去"和把响应"取出来"。
具体方案:
- 写一个 Chrome MV3 扩展,常驻在 Tabbit 网页里
- 扩展用
document.execCommand('insertText')把外部请求的 prompt 模拟输入到 Tabbit 的编辑框 - 调用
sendButton.click()模拟点击发送 - 注入到页面 MAIN world 的 hook 脚本拦截
/api/v1/chat/completion的 fetch / XHR 响应,把 SSE 流原样转出来 - 本地 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,有的甚至直接裸 text。extractChunk 用一个优先级列表兼容:
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,大致步骤:
npm install安装依赖npm run build打包扩展到dist/extension/- Chrome 加载
dist/extension/作为未打包扩展 npm run start:bridge启动本地 bridge(默认127.0.0.1:8787)- 打开
https://web.tabbit.ai/newtab并登录 - 任意 OpenAI 客户端把
base_url指到http://127.0.0.1:8787/v1,API key 随便填
接口兼容:
GET /v1/models—— 列出 Tabbit 当前可用的模型POST /v1/chat/completions—— 支持stream: true/false,支持thinking字段透传
七、总结
这次折腾的几个收获:
- 遇到加密参数别死磕,换个思路让前端自己跑,往往比逆向省事
response.clone()是拦截 fetch 响应的关键,既能读流又不影响页面- execCommand 虽然标记为 deprecated,但在富文本模拟输入场景依然是最佳方案,React 的 onChange 接不住直接 set value
- 白嫖有风险,Pro 额度对编程调用来说远远不够,真要稳定还是得老老实实付费
代码已经开源,欢迎 Star / 研究 / 改造。如果你也想搞类似浏览器内置 AI 的反代,这个项目的 fetch/XHR hook + 任务轮询 + OpenAI 流式转换这套组合拳可以直接复用。
更多推荐

所有评论(0)