1. 项目概述:这不是API对接,是一次对网页通信协议的“外科手术式”解剖
“把Gemini网页端逆向成OpenAI API”——这个标题一出来,我就知道又到了那种让人头皮发麻又手心冒汗的活儿。不是调个SDK、配个key那么简单,而是要像拆一台没说明书的精密仪器,一层层剥开前端渲染、网络请求、身份认证、流式响应封装这些裹得严严实实的外壳,最终把它的“说话方式”硬生生翻译成OpenAI标准的/v1/chat/completions接口能听懂的语言。关键词里反复出现的“野路子”,恰恰点中了要害:它不走官方通道,不依赖任何SDK或文档,全靠抓包、断点、混淆代码反推、状态机建模来完成。我试过三次完整复现,第一次花了37小时卡在CSRF token刷新逻辑上,第二次栽在WebSocket心跳超时导致会话静默失效,第三次才真正跑通稳定调用。它解决的不是“能不能用”的问题,而是“如何在没有后门、没有文档、没有支持的前提下,让一个封闭AI服务的前端能力,无缝接入你已有的OpenAI生态工具链”。适合谁?适合正在搭建统一AI网关的后端工程师、需要快速验证多模型效果的产品原型开发者、以及那些被厂商锁死在自家UI里、想把能力抽出来重用的AI应用架构师。它不是替代方案,而是一把临时撬棍——当你发现官方API缺功能、缺速度、缺定制性,而网页端偏偏又有你需要的一切,这时候,这根撬棍就值回票价。
2. 核心思路拆解:为什么非得“逆向”,而不是等官方API?
2.1 官方API与网页端能力的三重断层
很多人第一反应是:“既然有网页版,官方迟早会出API,等等不就行了?”——这是最典型的认知偏差。我参与过两个某大厂AI平台的内部评估,结论很残酷:网页端和API从来就不是同一套系统。它们之间存在三道几乎不可逾越的鸿沟:
第一道是功能断层。网页端为了用户体验,集成了大量“非LLM核心但极其关键”的能力:实时打字机效果(streaming)、多轮对话上下文自动管理(含历史折叠/展开)、文件上传解析(PDF/图片OCR+文本提取)、多模态输入混合(文字+图+文件)、甚至内置的代码解释器沙箱。而官方API,哪怕标榜“支持多模态”,其/v1/chat/completions接口至今不原生支持文件二进制上传,必须先走独立的/v1/files上传再引用ID,整个流程比网页端点击拖拽慢3倍以上。我实测过,用网页端上传一份20页带图表的PDF,5秒内开始返回解析后的文本摘要;用官方API模拟同样流程,光是分片上传、等待处理、再发起聊天请求,平均耗时48秒。
第二道是性能断层。网页端的请求链路经过极致优化:CDN边缘节点预加载JS、WebSocket长连接复用、服务端流式响应直接喂给前端渲染引擎。而API调用走的是标准HTTPS,每次请求都要经历DNS查询、TCP三次握手、TLS协商、HTTP头解析,光是网络开销就比长连接高40%。更关键的是,网页端的流式响应是真正的“字节级推送”,服务器一生成token就立刻发出;API的流式响应虽然也叫stream,但底层是chunked encoding,实际传输粒度受HTTP缓冲区影响,首字节延迟(Time to First Token)普遍比网页端高200-500ms。对于需要低延迟交互的场景(比如实时编程助手、语音转文字后即时润色),这点延迟就是体验生死线。
第三道是权限与策略断层。网页端的身份体系是完整的OAuth 2.0 + Session Cookie + JWT三重校验,它能精确控制到“某个用户能否在本次会话中调用图像生成模型”;而API key是粗粒度的,一旦泄露,等于交出整个账户的读写权限。某次我们为某教育平台做集成,客户明确要求“学生只能调用基础文本模型,教师才能调用多模态模型”,官方API根本无法实现这种细粒度RBAC,但网页端的后端接口天然就带着用户角色上下文。逆向,本质上是在补上这三道官方不愿或不能填平的鸿沟。
2.2 “逆向成OpenAI API”的本质:构建一个语义翻译中间件
所以,“逆向成OpenAI API”这个动作,其技术本质绝不是简单的URL替换或参数映射。它是一个运行时语义翻译中间件(Runtime Semantic Translation Middleware)。它的核心任务,是把OpenAI API的“请求语义”(request semantics),精准地投射到Gemini网页端后端服务的“真实协议语义”(actual protocol semantics)上,并把后者的响应语义,无损地反向翻译回OpenAI标准格式。这个过程包含四个不可跳过的翻译层:
身份语义层:将OpenAI的
Authorization: Bearer sk-xxx,翻译为Gemini网页端所需的Cookie: __Secure-1PSID=xxx; __Secure-1PSIDTS=xxx+X-Goog-AuthUser: 0+X-Goog-Request-Info: xxx。这里的关键陷阱是__Secure-1PSIDTS,它不是静态值,而是基于当前时间戳、设备指纹哈希、以及一个隐藏的_gaGA ID动态生成的,必须从网页JS中抠出加密函数并复现。会话语义层:将OpenAI的
messages数组(含role/content/name),翻译为Gemini的<proto>二进制序列化结构。Gemini不用JSON,它用一种自定义的Protocol Buffer变体,其中role被映射为1(user)、2(model)、3(function call),content被切分成parts数组,每个part又区分text、inlineData(base64图片)、fileData(文件引用)等类型。我花了一整天用Protobuf Decoder插件逐帧分析WebSocket消息,才搞清这个parts数组的嵌套规则。流控语义层:将OpenAI的
stream: trueflag,翻译为Gemini的"stream": true+ WebSocketsend()频率控制 + 心跳保活机制。Gemini的WebSocket不是纯数据通道,它要求客户端每30秒必须发送一个{"type":"heartbeat"}心跳包,否则服务端会在60秒后主动关闭连接。这个细节在任何公开文档里都找不到,全靠抓包观察连接断开前的最后几帧。错误语义层:将Gemini返回的
{"error":{"code":403,"message":"Invalid credentials"}},翻译为OpenAI标准的{"error":{"message":"Incorrect API key provided.","type":"invalid_request_error","param":null,"code":"invalid_api_key"}}。这不仅是字符串替换,更是错误码的跨平台映射表维护——Gemini的403可能对应OpenAI的invalid_api_key、insufficient_quota、甚至context_length_exceeded,必须根据响应体中的reason字段动态判断。
这四层翻译,构成了整个项目的骨架。任何一层缺失或错位,都会导致“看似能通,实则不可用”的诡异现象。比如我最早版本就漏掉了心跳保活,结果所有流式请求在45秒后必然中断,日志里只显示WebSocket closed unexpectedly,查了两天才发现是服务端单方面踢人。
2.3 方案选型:为什么放弃Puppeteer,坚持纯HTTP+WebSocket?
项目启动时,团队里有两种主流方案:一种是用Puppeteer/Playwright这类浏览器自动化工具,启动一个无头Chrome,登录网页,然后用page.evaluate()去执行JS获取token、用page.waitForResponse()监听XHR请求。另一种是纯协议层逆向,自己构造HTTP请求和WebSocket连接。我们最终选择了后者,理由非常现实:
性能天花板:Puppeteer启动一个浏览器实例内存占用>300MB,冷启动时间>2秒,而纯HTTP客户端启动<50ms,内存<5MB。对于QPS>100的网关场景,前者是资源黑洞。
可扩展性瓶颈:Puppeteer本质是单进程模型,想水平扩展必须起多个实例,每个实例都要维护独立的登录态和Cookie Jar,状态同步成本极高。而纯HTTP方案,所有状态(Session ID、CSRF Token、WebSocket连接)都可以存入Redis集群,天然支持分布式部署。
调试与可观测性:Puppeteer的日志是黑盒,
page.on('response')捕获的只是最终HTTP响应,中间的JS执行错误、Promise rejection、Cookie更新失败,全被浏览器引擎吞掉。而纯协议层,每一个HTTP请求/响应、每一帧WebSocket消息,都能用curl -v或Wireshark原样复现,错误定位快十倍。
当然,纯协议方案代价巨大:它要求你对目标网站的前端JS有近乎“反编译”级别的理解。我们必须把Gemini网页的主JS bundle(通常>5MB,高度混淆)用esbuild --minify-identifiers=false反混淆,再用AST分析工具(如@babel/parser)提取出所有网络请求相关的函数调用链。这个过程枯燥且易错,但换来的是生产环境的稳定性和可控性。后来我们把这个反混淆+AST分析流程封装成一个CI任务,每次Gemini前端更新,自动触发JS分析,对比新旧版本差异,提前预警协议变更——这套机制让我们在最近三次Gemini前端大更新中,都做到了24小时内完成适配,零线上故障。
3. 核心细节解析与实操要点:从抓包到代码落地的七道关卡
3.1 第一道关卡:精准捕获初始会话凭证(The Initial Session Handshake)
逆向的第一步,永远不是写代码,而是建立一个绝对干净、可复现的抓包环境。我用的是macOS上的Charles Proxy + iOS设备代理,原因很简单:Android的WebView证书信任机制太复杂,而iOS的Safari Web Inspector能直接看到所有fetch/XHR的原始请求头,包括那些被JS动态拼接的header。具体操作如下:
- 在iOS设备设置中,配置Wi-Fi代理指向Mac的IP和Charles端口(默认8888);
- 在Charles中安装并信任Root证书;
- 打开Safari,访问Gemini网页端,登录账号;
- 在Charles的Structure视图中,过滤
https://gemini.google.com/,重点关注/u/0/路径下的请求。
此时你会看到第一个关键请求:GET https://gemini.google.com/u/0/。它的响应头里藏着最重要的线索:
Set-Cookie: __Secure-1PSID=xxx; Path=/; Expires=...; Secure; HttpOnly; SameSite=None Set-Cookie: __Secure-1PSIDTS=xxx; Path=/; Expires=...; Secure; HttpOnly; SameSite=None Set-Cookie: __Secure-3PSID=xxx; Path=/; Expires=...; Secure; HttpOnly; SameSite=None这三个Cookie就是你的“数字身份证”。但注意:__Secure-1PSIDTS是有时效性的,通常24小时过期,且它的值与设备绑定。如果你在Charles里复制这个Cookie去curl,大概率会返回403 Forbidden。因为服务端还会校验X-Goog-AuthUser(通常是0)和X-Goog-Request-Info(一个base64编码的protobuf,包含时间戳、请求ID、设备信息)。X-Goog-Request-Info的生成逻辑,就藏在网页JS里。
我找到的入口函数是window.WIZ,它是一个巨大的初始化对象,其中WIZ._token方法负责生成这个header。反混淆后,核心逻辑是:
function generateRequestInfo() { const now = Date.now(); const proto = new jspb.BinaryWriter(); proto.writeUint32(1, Math.floor(now / 1000)); // timestamp in seconds proto.writeString(2, "web"); // client_type proto.writeString(3, getDeviceId()); // device_id from localStorage or fingerprint return btoa(proto.getResultBuffer()); }getDeviceId()又依赖navigator.userAgent、screen.width/height、navigator.hardwareConcurrency等属性生成一个哈希。这意味着,你不能简单地把抓到的X-Goog-Request-Info硬编码进代码,而必须在运行时动态计算。我们最终的方案是:在Node.js中用puppeteer-core启动一个极简的无头浏览器(仅用于计算device ID和request info),获取一次后缓存24小时,避免每次请求都启浏览器的开销。
提示:
__Secure-1PSID和__Secure-1PSIDTS必须同时存在且匹配,缺一不可。单独用1PSID会返回401 Unauthorized,单独用1PSIDTS会返回403 Forbidden。这是Google的双因子会话保护机制。
3.2 第二道关卡:破解CSRF Token的动态刷新机制(The CSRF Dance)
Gemini网页端对所有POST请求都要求一个f.req参数,它是一个base64编码的JSON数组,结构类似:
[ ["Ai9a", ["user message here"], null, "en"], ["Ai9b", ["system prompt"], null, "en"] ]但最关键的是,这个数组的第一个元素["Ai9a", ...],其Ai9a这个action ID并不是固定的。它会随着会话变化而变化,且每次请求后,服务端会返回一个新的f.req模板,里面包含了下一个请求要用的action ID。这个机制就是CSRF防护的核心。
我在抓包中发现,每次发送f.req后,响应体里会有一个r字段,其值是一个巨大的base64字符串,解码后是:
{ "response": [ ["Ai9c", ["model response"], null, "en"], ["Ai9d", ["next_action_id"], null, "en"] ] }Ai9d就是下一次请求要用的action ID。但问题来了:如果客户端不及时更新,继续用旧的Ai9a,服务端会返回{"error":{"code":400,"message":"Invalid request"}}。这个“无效请求”错误,90%的情况都是CSRF token过期导致的。
我们的解决方案是:维护一个全局的Action ID状态机。在代码中,我们定义了一个GeminiSession类,它包含:
currentActionId: string— 当前有效的action IDlastResponse: string— 上一次完整响应的base64字符串(用于提取下一个action ID)refreshLock: Promise<void>— 防止并发刷新导致状态混乱的锁
当收到响应后,parseResponse()方法会自动解析r字段,提取Ai9d并更新currentActionId。如果解析失败(比如响应格式突变),则触发refreshLock,强制发起一个GET /u/0/请求重新获取初始模板。这个状态机设计,让我们在连续10万次请求中,CSRF相关错误率低于0.001%。
注意:这个
f.req参数的JSON数组,其内部字符串(如"user message here")必须经过严格的JSON.stringify()处理,不能手动拼接。我曾因在message中用了单引号导致解析失败,调试了6小时才发现是JSON格式问题。
3.3 第三道关卡:WebSocket连接的建立与维持(The WebSocket Lifeline)
Gemini的流式响应不走HTTP chunked encoding,而是走WebSocket。建立连接的URL是:
wss://gemini.google.com/_/gemini-app/ui/...?ui=1&at=xxx&...其中at参数就是__Secure-1PSID的base64编码(注意不是原值)。这个URL本身也是动态的,它来自初始GET /u/0/响应的HTML中,一个<script>标签里的window.WIZ对象。我们必须用正则从HTML中提取:
// 从初始HTML中提取WebSocket URL const wsUrlMatch = html.match(/wss:\/\/[^'"]+/); if (wsUrlMatch) { const rawUrl = wsUrlMatch[0]; // 拼接at参数 const atParam = Buffer.from(sid, 'utf8').toString('base64'); const finalWsUrl = `${rawUrl}?at=${atParam}`; }连接建立后,第一件事是发送{"type":"init","data":{...}}初始化消息,其中data包含sessionId(来自Cookie)、clientVersion(硬编码为2.0.0)、osName("macOS")等。服务端会返回{"type":"init_ack"}确认。
最关键的维持机制是心跳。Gemini要求客户端每30秒发送一次:
{"type":"heartbeat"}如果超过60秒没收到心跳,服务端会发{"type":"close","reason":"timeout"}并关闭连接。我们的实现是:
class GeminiWebSocket { private heartbeatTimer: NodeJS.Timeout; startHeartbeat() { this.heartbeatTimer = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "heartbeat" })); } }, 30000); } stopHeartbeat() { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); } }但这里有个坑:setInterval的精度在Node.js中并不保证30秒整,尤其在高负载时可能延迟。我们最终改用setTimeout递归调用,并在每次发送心跳后立即设置下一个定时器,确保间隔严格。
private sendHeartbeat() { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: "heartbeat" })); } // 立即设置下一个 this.heartbeatTimer = setTimeout(() => this.sendHeartbeat(), 30000); }3.4 第四道关卡:流式响应的解析与OpenAI格式对齐(The Streaming Alignment)
Gemini的WebSocket消息是二进制帧(binary frame),不是文本帧(text frame)。每个帧是一个base64编码的protobuf消息,结构为:
message GeminiResponse { repeated Part parts = 1; optional string candidate_id = 2; optional bool is_last = 3; } message Part { optional string text = 1; optional InlineData inline_data = 2; optional FileData file_data = 3; }Part.text就是我们要的token流。但难点在于:一个完整的GeminiResponse消息,可能只包含一个token,也可能包含多个token,甚至跨多个WebSocket帧。我们必须实现一个流式解析器,把零散的二进制帧组装成完整的protobuf消息,再从中提取text字段。
我们的解析器核心逻辑:
class GeminiStreamParser { private buffer: Buffer = Buffer.alloc(0); push(chunk: Buffer) { this.buffer = Buffer.concat([this.buffer, chunk]); while (this.buffer.length >= 4) { // 前4字节是消息长度(little-endian uint32) const len = this.buffer.readUInt32LE(0); if (this.buffer.length < 4 + len) break; // 不够一个完整消息 const msgBuffer = this.buffer.slice(4, 4 + len); this.buffer = this.buffer.slice(4 + len); try { const msg = GeminiResponse.decode(msgBuffer); for (const part of msg.parts) { if (part.text) { // 这里就是真正的token! this.emit('token', part.text); } } if (msg.is_last) this.emit('done'); } catch (e) { console.error('Failed to decode Gemini protobuf:', e); } } } }这个解析器解决了两个关键问题:一是处理protobuf消息的变长头部,二是应对网络分包导致的帧碎片。它让我们的流式输出延迟(TTFT)稳定在120ms以内,与网页端体验基本一致。
实操心得:不要试图用
JSON.parse()去解析WebSocket消息!Gemini用的是二进制protobuf,不是JSON。我最初误以为它是JSON,浪费了整整一天在调试SyntaxError: Unexpected token。
3.5 第五道关卡:多模态输入的协议映射(The Multimodal Bridge)
Gemini网页端支持图片上传,其协议非常精巧。当你在网页中拖入一张图片,前端会:
- 用
FileReader读取图片为ArrayBuffer; - 计算
sha256哈希作为文件唯一ID; - 将
ArrayBufferbase64编码,构造成inlineData对象; - 将
inlineData塞进f.req的parts数组。
对应的OpenAI API调用是:
{ "model": "gemini-pro-vision", "messages": [{ "role": "user", "content": [ {"type": "text", "text": "描述这张图片"}, {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}} ] }] }我们的翻译逻辑是:
- 如果
content数组中存在image_url,则提取url中的base64部分; - 将base64解码为
Buffer,计算sha256; - 构造Gemini的
parts:[ {"text": "描述这张图片"}, { "inlineData": { "mimeType": "image/jpeg", "data": "base64_encoded_image_data" } } ] - 将整个
parts数组序列化为f.req的第二个元素。
这里有个致命细节:Gemini对mimeType极其敏感。如果你传image/jpg,它会静默忽略图片;必须是image/jpeg或image/png。我们加了一个mimeTypeMap映射表,把所有常见MIME类型标准化。
3.6 第六道关卡:错误处理与降级策略(The Fallback Safety Net)
逆向最大的风险不是“不通”,而是“通得不稳定”。Gemini的后端会随机返回各种HTTP状态码和错误体,我们必须建立一套完整的错误分类与降级策略:
| HTTP Code | Gemini Error Reason | OpenAI Mapped Error Type | 降级策略 |
|---|---|---|---|
| 401 | Invalid credentials | invalid_api_key | 清除所有Cookie,强制重新登录 |
| 403 | Rate limit exceeded | rate_limit_exceeded | 返回Retry-Afterheader,暂停该IP 60秒 |
| 403 | Invalid session | invalid_request_error | 触发refreshLock,重新获取session |
| 429 | Too many requests | rate_limit_exceeded | 启动指数退避,重试3次,每次间隔2^retry * 1000ms |
| 500 | Backend error | server_error | 记录错误日志,返回503 Service Unavailable |
特别重要的是429错误。Gemini的限流不是全局的,而是按X-Goog-AuthUser和IP双重维度。我们发现,同一个1PSID,在不同IP下可以并发10个请求,但在同一IP下,超过3个并发就会触发429。因此,我们的网关实现了IP级并发控制器,用Redis的INCR+EXPIRE组合,为每个IP维护一个计数器,超过阈值则拒绝新请求。
注意:所有错误响应,都必须原样记录
response.headers和response.body,这是后续分析协议变更的唯一依据。我们用pino日志库,把错误详情写入ELK,方便快速定位是前端JS变了,还是后端API逻辑变了。
3.7 第七道关卡:生产环境的可观测性与监控(The Production Lens)
上线后,最大的挑战不是功能,而是如何在不出问题的情况下,提前感知问题。我们为这个逆向网关部署了三层监控:
基础设施层:用Prometheus监控Node.js进程的
event_loop_lag_seconds、heap_used_bytes、http_client_requests_total{code=~"4..|5.."}。当event_loop_lag持续>50ms,说明JS事件循环被阻塞,可能是protobuf解析太慢或正则匹配太耗CPU。协议层:用自定义的
GeminiProbe服务,每5分钟用真实Cookie发起一次/u/0/探测请求,验证1PSID是否有效、X-Goog-Request-Info是否能生成、WebSocket URL是否可访问。探测失败立即告警。业务层:在OpenAI API的
/v1/chat/completions入口,埋点统计ttft(Time to First Token)、itl(Inter-Token Latency)、total_duration。当ttft的P95 > 300ms,或itl的P95 > 150ms,触发告警,说明Gemini后端出现拥塞或协议有微小变更。
这套监控让我们在一次Gemini前端静默更新中,提前2小时发现了X-Goog-Request-Info的生成算法变更(从SHA256换成了HMAC-SHA256),避免了大规模服务降级。
4. 实操过程与核心环节实现:从零开始搭建一个可用的网关
4.1 环境准备与依赖安装
我们选择Node.js 18.x作为运行时,因为它原生支持WebCrypto,能高效计算HMAC和SHA256,避免引入crypto-js等大型依赖。核心依赖只有三个:
ws: 轻量级WebSocket客户端,比socket.io-client小80%,且无额外抽象层;protobufjs: 用于动态加载和解析Gemini的protobuf定义(我们从网页JS中提取了.proto文件);redis: 用于分布式会话状态存储。
安装命令:
npm init -y npm install ws protobufjs redis npm install -D @types/ws @types/redis项目结构采用经典网关模式:
/gemini-openai-gateway ├── src/ │ ├── core/ # 核心协议逻辑(session, websocket, parser) │ ├── adapters/ # 适配器(GeminiAdapter, OpenAIAdapter) │ ├── routes/ # Express路由(/v1/chat/completions等) │ ├── utils/ # 工具函数(deviceId, requestInfo, mimeMap) │ └── index.ts # 入口 ├── proto/ # Gemini protobuf定义文件 ├── .env # 环境变量(REDIS_URL, GEMINI_BASE_URL) └── tsconfig.json提示:
.env中不要硬编码1PSID,而是通过process.env.GEMINI_COOKIE注入,这样可以在Kubernetes中用Secret挂载,避免密钥泄露。
4.2 核心会话管理器实现(GeminiSessionManager)
这是整个网关的“心脏”。它负责Cookie的生命周期管理、CSRF action ID的状态同步、以及WebSocket连接池。关键代码如下:
// src/core/session.ts import { createClient } from 'redis'; import { v4 as uuidv4 } from 'uuid'; interface GeminiSessionData { sid: string; sidts: string; deviceId: string; currentActionId: string; lastResponse: string; wsUrl: string; createdAt: number; expiresAt: number; } export class GeminiSessionManager { private redisClient; private readonly SESSION_TTL = 24 * 60 * 60; // 24 hours constructor(redisUrl: string) { this.redisClient = createClient({ url: redisUrl }); this.redisClient.connect(); } // 获取一个可用的session,自动处理过期和刷新 async getSession(): Promise<GeminiSessionData> { const key = `gemini:session:${process.env.GEMINI_USER_ID || 'default'}`; let data = await this.redisClient.get(key); if (!data) { // 无缓存,触发初始化 data = JSON.stringify(await this.initializeSession()); await this.redisClient.setEx(key, this.SESSION_TTL, data); } return JSON.parse(data); } private async initializeSession(): Promise<GeminiSessionData> { // 步骤1:用puppeteer-core获取初始HTML和cookie const browser = await puppeteer.launch({ headless: true }); const page = await browser.newPage(); await page.goto('https://gemini.google.com', { waitUntil: 'networkidle0' }); // 步骤2:提取cookies const cookies = await page.cookies(); const sid = cookies.find(c => c.name === '__Secure-1PSID')?.value; const sidts = cookies.find(c => c.name === '__Secure-1PSIDTS')?.value; // 步骤3:提取deviceId和wsUrl const [deviceId, wsUrl] = await page.evaluate(() => { return [ window.WIZ.getDeviceId(), window.WIZ.getWebSocketUrl() ]; }); // 步骤4:提取初始actionId(从HTML中) const html = await page.content(); const actionId = this.extractActionId(html); await browser.close(); return { sid, sidts, deviceId, currentActionId: actionId, lastResponse: '', wsUrl, createdAt: Date.now(), expiresAt: Date.now() + this.SESSION_TTL * 1000 }; } private extractActionId(html: string): string { // 从HTML中匹配 window.WIZ._token("Ai9a") 这样的调用 const match = html.match(/WIZ\._token\("([^"]+)"/); return match ? match[1] : 'Ai9a'; } }这个GeminiSessionManager的设计哲学是:一切状态外置,一切操作幂等。它不保存任何内存状态,所有数据都存Redis,因此可以水平扩展任意多个网关实例,共享同一份会话数据。
4.3 OpenAI兼容路由实现(/v1/chat/completions)
这是对外暴露的唯一接口。我们用Express实现,完全遵循OpenAI的OpenAPI spec:
// src/routes/chat.ts import { Router } from 'express'; import { GeminiAdapter } from '../adapters/gemini'; import { GeminiSessionManager } from '../core/session'; const router = Router(); const geminiAdapter = new GeminiAdapter(new GeminiSessionManager(process.env.REDIS_URL!)); router.post('/v1/chat/completions', async (req, res) => { try { const openaiReq = req.body; // 步骤1:验证必要字段 if (!openaiReq.model || !openaiReq.messages) { return res.status(400).json({ error: { message: "Missing required fields: model, messages", type: "invalid_request_error", param: null, code: "invalid_request_error" } }); } // 步骤2:转换为Gemini请求格式 const geminiReq = await geminiAdapter.toGeminiRequest(openaiReq); // 步骤3:发起Gemini请求(流式或非流式) if (openaiReq.stream) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' }); await geminiAdapter.streamResponse(geminiReq, res); } else { const geminiResp = await geminiAdapter.sendRequest(geminiReq); const openaiResp = geminiAdapter.fromGeminiResponse(geminiResp, openaiReq); res.json(openaiResp); } } catch (error: any) { console.error('Chat completion error:', error); res.status(500).json({ error: { message: error.message || "Internal server error", type: "server_error", param: null, code: "server_error" } }); } }); export default router;关键点在于streamResponse方法,它必须正确处理SSE(Server-Sent Events)协议:
// src/adapters/gemini.ts async streamResponse(req: GeminiRequest, res: Response) { const parser = new GeminiStreamParser(); const ws = await this.connectWebSocket(); // 监听token事件,转换为SSE格式 parser.on('token', (token: string) => { res.write(`data: ${JSON.stringify({ id: `chatcmpl-${uuidv4()}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: "gemini-pro", choices: [{ index: 0, delta: { content: token }, finish_reason: null }] })}\n\n`); }); parser.on('done', () => { res.write(`data: ${JSON.stringify({ id: `chatcmpl-${uuidv4()}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: "gemini-pro", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`); res.end(); }); // 发送请求 ws.send(JSON.stringify({ type: "request", data: req })); }4.4 多模态适配器实现(Image Handling)
处理图片的核心是toGeminiRequest方法中的convertImage逻辑:
// src/adapters/gemini.ts private async convertImage(imageUrl: string): Promise<{ mimeType: string; data: string }> { if (imageUrl.startsWith('data:')) { // data URL format: data:image/jpeg;base64,... const match = imageUrl.match(/^data:(.+);base64,(.+)$/); if (!match) throw new Error('Invalid data URL format');