| /** | |
| * Gemini Web2API - Cloudflare Workers 完整并发安全修复版(打字机效果) | |
| * | |
| * ============================================================================ | |
| * 项目说明 | |
| * ============================================================================ | |
| * 本程序将 Google Gemini 的 Web 界面转换为 OpenAI 兼容的 API 接口。 | |
| * 部署于 Cloudflare Workers 边缘计算平台,无需服务器即可运行。 | |
| * 支持流式输出(SSE 打字机效果)、非流式输出、工具调用(Function Calling)。 | |
| * | |
| * ============================================================================ | |
| * 重大修复与增强 (并发安全 & 生产可用): | |
| * ============================================================================ | |
| * | |
| * 1. 彻底消除了全局 CONFIG 被异步请求并发篡改/串扰的严重隐患。 | |
| * 根本原因分析: | |
| * - Cloudflare Workers 使用 Isolate(隔离环境)处理请求 | |
| * - 冷启动时,全局作用域代码会重新执行,CONFIG 回到初始值 | |
| * - 热启动时(Isolate 复用),全局作用域代码不会重新执行 | |
| * - 当 WorkBuddy 等客户端在极短时间内发送多个并发请求时, | |
| * 它们会共享同一个全局 CONFIG 对象(因为复用同一个 Isolate) | |
| * - 请求 A 修改了 CONFIG.cookieString = "cookie_a" | |
| * - 请求 B 紧接着修改了 CONFIG.cookieString = "cookie_b" | |
| * - 请求 A 后续使用的是 cookie_b,导致认证信息串扰 | |
| * - 这在 WorkBuddy 的多模型并发调用场景下尤为严重 | |
| * 解决方案: | |
| * - 每次请求通过 getRequestConfig(env) 创建全新的独立配置副本 | |
| * - 所有函数通过参数接收配置对象,完全不依赖全局可变状态 | |
| * - 使用 Object.freeze() 冻结默认配置模板,防止意外修改 | |
| * | |
| * 2. 实现了基于请求上下文 (Request-scoped) 的不可变配置机制。 | |
| * - DEFAULT_CONFIG 作为只读模板,使用 Object.freeze() 冻结 | |
| * - getRequestConfig(env) 为每个请求创建独立的配置副本 | |
| * - 从 env(环境变量,每个请求独立)加载定制配置 | |
| * - 所有函数签名都包含 config 参数,完全消除全局状态依赖 | |
| * - 支持从 COOKIE_STRING 环境变量自动提取 SAPISID 值 | |
| * | |
| * 3. 修复全局 rateLimitStore 在 Serverless 环境下的隐式内存泄露问题。 | |
| * - Serverless 环境下 Isolate 可能长时间存活 | |
| * - 如果不清理过期记录,Map 会无限增长导致内存泄漏 | |
| * - 使用随机概率清理机制(5% 概率触发全局清理) | |
| * - 每次清理遍历所有键,删除过期或空的记录 | |
| * - 确保长期运行后内存使用保持稳定 | |
| * | |
| * 4. 增加了从 COOKIE_STRING 自动提取 SAPISID 的防御性逻辑。 | |
| * - 用户通常从浏览器复制完整 Cookie 字符串 | |
| * - Cookie 格式: "__Secure-1PSID=xxx; SAPISID=yyy; ..." | |
| * - 如果用户设置了 COOKIE_STRING 但忘记单独设置 SAPISID | |
| * - 程序会自动从 Cookie 字符串中正则提取 SAPISID 值 | |
| * - 提取逻辑:匹配 "SAPISID=" 后跟非分号字符的部分 | |
| * - 提升用户体验,减少配置错误 | |
| * | |
| * 5. OPTIONS 预检优先处理、SSE 打字机增量实时输出、心跳保活完全保留。 | |
| * - OPTIONS 预检在所有其他逻辑之前处理,确保 CORS 正常 | |
| * - SSE 格式严格符合 OpenAI 标准: | |
| * - 首块: delta: { role: 'assistant' }(只含 role,不含 content) | |
| * - 内容块: delta: { content: '增量文本' }(实时增量输出) | |
| * - 结束块: delta: { content: "" }, finish_reason: 'stop' | |
| * - 心跳保活:每 2 秒发送 ": heartbeat\n\n" SSE 注释 | |
| * - 打字机效果:实时计算 Gemini 响应的增量文本并立即推送 | |
| * | |
| * 6. 保留完整功能:工具调用、速率限制、API认证、Google原生API、Responses API。 | |
| * - 工具调用:支持 OpenAI Function Calling 格式 | |
| * - 速率限制:滑动窗口算法,可配置阈值和时间窗口 | |
| * - API 认证:支持 Bearer Token、x-api-key、x-goog-api-key、URL 参数 | |
| * - Google 原生 API:支持 Gemini CLI 的 generateContent 格式 | |
| * - Responses API:支持 OpenAI Codex CLI 的新格式 | |
| * | |
| * ============================================================================ | |
| * 部署说明: | |
| * ============================================================================ | |
| * 1. 登录 Cloudflare Dashboard -> Workers & Pages | |
| * 2. 创建 Worker -> 粘贴此代码 -> 保存并部署 | |
| * 3. 配置环境变量(可选): | |
| * - COOKIE_STRING: 完整的 Cookie 字符串(解决 429 限流) | |
| * 从浏览器 F12 -> Application -> Cookies 中复制 | |
| * - SAPISID: SAPISID 值 | |
| * 如果未设置,会自动从 COOKIE_STRING 中提取 | |
| * - API_KEYS: API 密钥 JSON 数组,如 ["sk-gemini", "sk-my-key"] | |
| * 留空或设为 [] 表示不验证密钥 | |
| * - GEMINI_BL: Gemini 构建标签 | |
| * 遇到 405 错误时需要更新此值 | |
| * 获取方法:浏览器打开 gemini.google.com -> F12 -> Network -> 搜索 "boq_assistant" | |
| * - DEFAULT_MODEL: 默认模型名称,如 "gemini-3.6-flash" | |
| * - AUTH_USER: 多账户索引,0=第一个账户,1=第二个账户 | |
| * - RATE_LIMIT_MAX: 速率限制最大请求数,默认 3000 | |
| * - RATE_LIMIT_WINDOW: 速率限制时间窗口(秒),默认 60 | |
| * - RETRY_ATTEMPTS: 重试次数,默认 3 | |
| * - RETRY_DELAY_SEC: 重试间隔(秒),默认 2 | |
| * - REQUEST_TIMEOUT_SEC: 请求超时(秒),默认 28 | |
| * | |
| * 客户端配置: | |
| * 基础URL: https://你的worker.workers.dev/v1 | |
| * API密钥: sk-gemini (或你在配置中设置的密钥) | |
| * 模型: gemini-3.6-flash | |
| * | |
| * 基于原项目 gemini-web2api v1.1.0 移植 | |
| * 原作者项目: https://github.com/your-repo/gemini-web2api | |
| */ | |
| // ============================================================================ | |
| // 🔒 默认配置 - 仅作为只读模板 | |
| // ============================================================================ | |
| // 这是所有请求配置的"蓝图"(Blueprint),用于生成每个请求的独立配置副本。 | |
| // 这个对象永远不会被修改,所有修改都在请求级的 config 副本中进行。 | |
| // 使用 Object.freeze() 确保不可变性,防止意外修改。 | |
| var DEFAULT_CONFIG = { | |
| // ---- 重试配置 ---- | |
| // 当请求失败时,自动重试的次数 | |
| // 每次重试使用指数退避策略:延迟时间 = retryDelaySec * 2^attempt | |
| retryAttempts: 3, | |
| // 重试间隔的基础时间(秒) | |
| // 第一次重试延迟 2 秒,第二次 4 秒,第三次 8 秒 | |
| retryDelaySec: 2, | |
| // ---- 请求超时 ---- | |
| // 单次 HTTP 请求的超时时间(秒) | |
| // 注意:CF Workers 免费版有 30 秒 CPU 时间限制 | |
| // 流式请求的 CPU 时间在数据到达时重置,所以不受此限制 | |
| requestTimeoutSec: 28, | |
| // ---- Gemini 构建标签 ---- | |
| // Gemini 前端的版本标识,用于 API 请求的 URL 参数 | |
| // 如果遇到 405 Method Not Allowed 错误,说明此值已过期 | |
| // 更新方法:浏览器打开 gemini.google.com,按 F12 -> Network 标签 | |
| // 在任意请求的 URL 中搜索 "boq_assistant",复制最新版本号 | |
| geminiBl: 'boq_assistant-bard-web-server_20260716.08_p0', | |
| // ---- 多账户支持 ---- | |
| // Google 支持在同一个浏览器中登录多个账户 | |
| // null 或 "" 表示使用默认账户(第一个登录的账户) | |
| // "0" 表示第一个账户,"1" 表示第二个账户,以此类推 | |
| authUser: null, | |
| // ---- XSRF 令牌 ---- | |
| // 跨站请求伪造保护令牌 | |
| // Gemini Web 前端会使用此令牌,但 API 调用通常不需要 | |
| // 如果遇到 403 错误,可以尝试从浏览器中提取此值 | |
| xsrfToken: null, | |
| // ---- 默认模型 ---- | |
| // 当客户端请求未指定模型时使用的默认模型 | |
| // 可选值参考上方 MODELS 字典的键名 | |
| defaultModel: 'gemini-3.6-flash', | |
| // ---- API 密钥白名单 ---- | |
| // 用于验证客户端请求的密钥列表 | |
| // 空数组 [] 表示不验证,所有请求都可以访问 | |
| // 设置后,客户端必须在请求头中提供有效的密钥 | |
| // 示例: ["sk-gemini", "sk-my-custom-key"] | |
| apiKeys: ['sk-gemini'], | |
| // ---- Cookie 认证 ---- | |
| // Gemini 对匿名请求有严格的速率限制(容易触发 429) | |
| // 提供有效的 Cookie 可以大幅提升稳定性 | |
| // cookieString: 从浏览器复制的完整 Cookie 字符串 | |
| // 格式: "__Secure-1PSID=xxx; __Secure-3PSID=xxx; SAPISID=xxx; ..." | |
| cookieString: null, | |
| // sapisid: 从 Cookie 中提取的 SAPISID 值 | |
| // 用于生成 Google API 所需的 SAPISIDHASH 认证头 | |
| // 如果设置了 cookieString 但未设置 sapisid,程序会自动提取 | |
| sapisid: null, | |
| // ---- 日志开关 ---- | |
| // 是否在控制台输出请求日志 | |
| // 生产环境建议保持开启,便于排查问题 | |
| logRequests: true, | |
| // ---- 速率限制 ---- | |
| // Cloudflare Workers 级别的请求频率控制 | |
| // 用于防止滥用和保护上游 Gemini API | |
| rateLimit: { | |
| // 是否启用速率限制 | |
| enabled: true, | |
| // 时间窗口内的最大请求数 | |
| // 默认 3000,设置为较高值以避免正常使用被限制 | |
| // 如果遇到滥用,可以调低此值 | |
| maxRequests: 3000, | |
| // 时间窗口大小(秒) | |
| // 60 表示每分钟最多允许 maxRequests 个请求 | |
| windowSec: 60, | |
| }, | |
| }; | |
| // ============================================================================ | |
| // 🤖 模型定义 | |
| // ============================================================================ | |
| // 映射自 Gemini Web 前端 JS 源码中的 MODE_CATEGORY 枚举 | |
| // 枚举值含义: | |
| // 1 = FAST(快速模式)- Gemini Flash 系列 | |
| // 2 = THINKING(深度思考)- 启用深度推理 | |
| // 3 = PRO(专业版)- 需要有效 Cookie 才能正确路由 | |
| // 4 = AUTO(自动选择)- 由 Gemini 自动选择模型 | |
| // 5 = FAST_DYNAMIC_THINKING(动态思考)- 自适应思考深度 | |
| // 6 = FLASH_LITE(轻量快速)- 最轻量的模型 | |
| // | |
| // think 字段含义(思考模式): | |
| // 0 = 启用深度思考 | |
| // 4 = AUTO(自动选择思考深度) | |
| var MODELS = { | |
| 'gemini-3.6-flash': { | |
| mode: 1, // FAST - 快速模式 | |
| think: 4, // AUTO - 自动选择思考深度 | |
| desc: 'Latest all-around model (Gemini 3.6 Flash)', | |
| }, | |
| 'gemini-3.5-flash': { | |
| mode: 1, // FAST | |
| think: 4, // AUTO | |
| desc: 'Alias for gemini-3.6-flash (backend upgraded)', | |
| }, | |
| 'gemini-3.5-flash-thinking': { | |
| mode: 2, // THINKING - 深度思考模式 | |
| think: 0, // 启用深度思考 | |
| desc: 'Deep thinking mode, longest output (~20k chars)', | |
| }, | |
| 'gemini-3.1-pro': { | |
| mode: 3, // PRO - 专业版 | |
| think: 4, // AUTO | |
| desc: 'Pro model (requires cookie for real routing)', | |
| }, | |
| 'gemini-auto': { | |
| mode: 4, // AUTO - 自动模型选择 | |
| think: 4, // AUTO | |
| desc: 'Auto model selection', | |
| }, | |
| 'gemini-3.5-flash-thinking-lite': { | |
| mode: 5, // FAST_DYNAMIC_THINKING - 动态思考 | |
| think: 0, // 启用思考 | |
| desc: 'Dynamic thinking with adaptive depth', | |
| }, | |
| 'gemini-flash-lite': { | |
| mode: 6, // FLASH_LITE - 轻量快速 | |
| think: 4, // AUTO | |
| desc: 'Lightweight fast model', | |
| }, | |
| }; | |
| // ============================================================================ | |
| // 🔑 核心:请求级配置生成器(解决并发串扰的核心函数) | |
| // ============================================================================ | |
| /** | |
| * 为当前请求创建独立的配置副本 | |
| * | |
| * 【为什么需要这个函数?】 | |
| * Cloudflare Workers 在处理请求时使用 Isolate(隔离环境)。 | |
| * 冷启动时全局代码会重新执行,但热启动(Isolate 复用)时不会。 | |
| * 如果多个并发请求复用了同一个 Isolate,它们会共享全局变量(如 CONFIG)。 | |
| * 当 WorkBuddy 等客户端在极短时间内发送 5-20 个并发请求时, | |
| * 这些请求可能被分配到同一个 Isolate,导致配置串扰。 | |
| * | |
| * 【如何解决?】 | |
| * 每次请求调用此函数,从冻结的 DEFAULT_CONFIG 模板创建一个全新的配置对象。 | |
| * 然后用环境变量(env,每个请求独立)覆盖需要定制的字段。 | |
| * 所有后续函数都通过 config 参数接收配置,完全不依赖全局状态。 | |
| * | |
| * 【配置项说明】 | |
| * - 字符串类型(geminiBl, defaultModel):有 env 就用,没有用默认值 | |
| * - 认证类型(cookieString, sapisid 等):可能为 null,必须显式覆盖防止残留 | |
| * - 数字类型(retryAttempts 等):需要 parseInt 转换 | |
| * - 嵌套对象(rateLimit):需要从冻结模板展开创建新的可变对象 | |
| * | |
| * @param {Object} env - Cloudflare Worker 环境变量(每个请求独立) | |
| * @returns {Object} 专属于当前请求的配置副本 | |
| */ | |
| function getRequestConfig(env) { | |
| // 从默认模板创建全新的配置对象 | |
| // 注意:不使用展开运算符 (...DEFAULT_CONFIG),而是逐字段拷贝 | |
| // 这样确保每个字段都是基本类型的独立副本 | |
| var config = { | |
| // ---- 基本配置字段 ---- | |
| retryAttempts: DEFAULT_CONFIG.retryAttempts, | |
| retryDelaySec: DEFAULT_CONFIG.retryDelaySec, | |
| requestTimeoutSec: DEFAULT_CONFIG.requestTimeoutSec, | |
| geminiBl: DEFAULT_CONFIG.geminiBl, | |
| authUser: DEFAULT_CONFIG.authUser, | |
| xsrfToken: DEFAULT_CONFIG.xsrfToken, | |
| defaultModel: DEFAULT_CONFIG.defaultModel, | |
| apiKeys: DEFAULT_CONFIG.apiKeys, | |
| cookieString: DEFAULT_CONFIG.cookieString, | |
| sapisid: DEFAULT_CONFIG.sapisid, | |
| logRequests: DEFAULT_CONFIG.logRequests, | |
| // ---- 嵌套对象:rateLimit 需要深拷贝 ---- | |
| // 因为 rateLimit 本身是一个对象,直接赋值会导致引用共享 | |
| // 这里创建一个新的对象,从 DEFAULT_CONFIG.rateLimit 复制所有属性 | |
| rateLimit: { | |
| enabled: DEFAULT_CONFIG.rateLimit.enabled, | |
| maxRequests: DEFAULT_CONFIG.rateLimit.maxRequests, | |
| windowSec: DEFAULT_CONFIG.rateLimit.windowSec, | |
| }, | |
| }; | |
| // ================================================================ | |
| // 环境变量覆盖(env 是 Cloudflare 为每个请求提供的独立环境变量) | |
| // ================================================================ | |
| // ---- 字符串类型:有值才覆盖 ---- | |
| // Gemini 构建标签 | |
| if (env.GEMINI_BL) { | |
| config.geminiBl = env.GEMINI_BL; | |
| } | |
| // 默认模型 | |
| if (env.DEFAULT_MODEL) { | |
| config.defaultModel = env.DEFAULT_MODEL; | |
| } | |
| // ---- 认证相关字段:使用 || 操作符确保显式覆盖 ---- | |
| // 这些字段可能为 null 或空字符串 | |
| // 使用 || null 确保即使 env 值为 undefined,也会显式设置为 null | |
| // 这防止了 Isolate 复用时,上次请求的值残留到本次请求 | |
| config.cookieString = env.COOKIE_STRING || null; | |
| config.sapisid = env.SAPISID || null; | |
| config.authUser = env.AUTH_USER || null; | |
| config.xsrfToken = env.XSRF_TOKEN || null; | |
| // ================================================================ | |
| // 🛡️ 智能兼容:自动从 COOKIE_STRING 提取 SAPISID | |
| // ================================================================ | |
| // 如果用户设置了完整的 Cookie 字符串但忘记单独设置 SAPISID | |
| // 程序自动从 Cookie 中正则匹配提取 SAPISID 值 | |
| // Cookie 格式示例: | |
| // "__Secure-1PSID=AJDrVf...; __Secure-3PSID=AJDrVf...; SAPISID=abc123/def456; ..." | |
| // 正则 /SAPISID=([^;]+)/ 匹配 "SAPISID=" 后面的非分号字符 | |
| if (!config.sapisid && config.cookieString) { | |
| var match = config.cookieString.match(/SAPISID=([^;]+)/); | |
| if (match) { | |
| // match[1] 是第一个捕获组,即 SAPISID 的值 | |
| // trim() 去除可能的空白字符 | |
| config.sapisid = match[1].trim(); | |
| } | |
| } | |
| // ---- API 密钥:JSON 数组格式,需要特殊解析 ---- | |
| // env.API_KEYS 是字符串,如 '["sk-gemini", "sk-my-key"]' | |
| if (env.API_KEYS) { | |
| try { | |
| config.apiKeys = JSON.parse(env.API_KEYS); | |
| } catch (e) { | |
| // JSON 解析失败时保留默认值,并输出错误日志 | |
| console.error('[ERROR] API_KEYS 解析失败: ' + e.message + ',使用默认值'); | |
| } | |
| } | |
| // ---- 数字类型字段:需要 parseInt 转换 ---- | |
| // env 中的环境变量都是字符串类型,需要转换为数字 | |
| // 使用 parseInt(value, 10) 确保十进制转换 | |
| // 使用 isNaN() 检查转换结果,防止无效值 | |
| // 重试次数 | |
| if (env.RETRY_ATTEMPTS) { | |
| var ra = parseInt(env.RETRY_ATTEMPTS, 10); | |
| if (!isNaN(ra)) config.retryAttempts = ra; | |
| } | |
| // 重试延迟 | |
| if (env.RETRY_DELAY_SEC) { | |
| var rd = parseInt(env.RETRY_DELAY_SEC, 10); | |
| if (!isNaN(rd)) config.retryDelaySec = rd; | |
| } | |
| // 请求超时 | |
| if (env.REQUEST_TIMEOUT_SEC) { | |
| var rt = parseInt(env.REQUEST_TIMEOUT_SEC, 10); | |
| if (!isNaN(rt)) config.requestTimeoutSec = rt; | |
| } | |
| // ---- 速率限制配置 ---- | |
| // 最大请求数 | |
| if (env.RATE_LIMIT_MAX) { | |
| var rlmax = parseInt(env.RATE_LIMIT_MAX, 10); | |
| if (!isNaN(rlmax)) config.rateLimit.maxRequests = rlmax; | |
| } | |
| // 时间窗口 | |
| if (env.RATE_LIMIT_WINDOW) { | |
| var rlwin = parseInt(env.RATE_LIMIT_WINDOW, 10); | |
| if (!isNaN(rlwin)) config.rateLimit.windowSec = rlwin; | |
| } | |
| // 返回请求专属的配置副本 | |
| return config; | |
| } | |
| // ============================================================================ | |
| // 🛠 工具函数 | |
| // ============================================================================ | |
| /** | |
| * 日志记录函数 | |
| * | |
| * 使用请求级配置中的 logRequests 开关控制是否输出日志。 | |
| * 如果没有传入 config 参数,使用默认配置。 | |
| * 日志格式: [HH:MM:SS] [LEVEL] message | |
| * | |
| * @param {string} msg - 要记录的日志消息 | |
| * @param {string} [level] - 日志级别,默认 'INFO'。可选: INFO/WARN/ERROR | |
| * @param {Object} [config] - 请求级配置对象(可选,用于并发安全) | |
| */ | |
| function log(msg, level, config) { | |
| // 如果未指定级别,默认使用 INFO | |
| level = level || 'INFO'; | |
| // 根据 config 参数决定是否输出日志 | |
| // 有 config 时使用 config.logRequests,没有时使用默认配置 | |
| var shouldLog = config ? config.logRequests : DEFAULT_CONFIG.logRequests; | |
| if (shouldLog) { | |
| // 生成时间戳,格式: HH:MM:SS | |
| var ts = new Date().toISOString().split('T')[1].split('.')[0]; | |
| console.log('[' + ts + '] [' + level + '] ' + msg); | |
| } | |
| } | |
| /** | |
| * 生成 UUID v4(通用唯一标识符) | |
| * | |
| * Cloudflare Workers 环境优先使用内置的 crypto.randomUUID() 方法。 | |
| * 如果不可用(老版本或其他环境),使用回退方案手动生成。 | |
| * | |
| * @returns {string} UUID v4 格式的字符串,如 "550e8400-e29b-41d4-a716-446655440000" | |
| */ | |
| function generateUUID() { | |
| // 优先使用 CF Workers 内置方法(性能更好,随机性更强) | |
| if (typeof crypto !== 'undefined' && crypto.randomUUID) { | |
| return crypto.randomUUID(); | |
| } | |
| // 回退方案:手动生成符合 UUID v4 规范的字符串 | |
| // 格式: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx | |
| return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { | |
| // 生成 0-15 的随机整数 | |
| var r = Math.random() * 16 | 0; | |
| // x 位置直接使用随机值 | |
| // y 位置确保高位为 10xx(符合 UUID v4 规范) | |
| var v = c === 'x' ? r : (r & 0x3 | 0x8); | |
| return v.toString(16); | |
| }); | |
| } | |
| /** | |
| * 生成短 ID | |
| * | |
| * 从 UUID 中提取前 length 个十六进制字符(去掉连字符)。 | |
| * 用于生成聊天补全 ID、工具调用 ID 等。 | |
| * | |
| * @param {number} [length] - 需要的 ID 长度,默认 12 | |
| * @returns {string} 短 ID 字符串 | |
| */ | |
| function generateShortId(length) { | |
| var len = length || 12; | |
| return generateUUID().replace(/-/g, '').substring(0, len); | |
| } | |
| /** | |
| * 获取当前 Unix 时间戳(秒) | |
| * | |
| * @returns {number} 从 Unix 纪元(1970-01-01)开始的秒数 | |
| */ | |
| function timestamp() { | |
| return Math.floor(Date.now() / 1000); | |
| } | |
| /** | |
| * 估算文本的 Token 数量 | |
| * | |
| * 使用简单的启发式算法:英文约 4 字符 = 1 token,中文约 1.5 字符 = 1 token。 | |
| * 这里使用统一的 4 字符/token 估算,不是精确计算但足以用于资源预估。 | |
| * | |
| * @param {string} text - 要估算的文本 | |
| * @returns {number} 估算的 token 数量,至少为 1 | |
| */ | |
| function estimateTokens(text) { | |
| if (!text) return 0; | |
| return Math.max(1, Math.ceil(text.length / 4)); | |
| } | |
| /** | |
| * 生成 SAPISID 认证哈希 | |
| * | |
| * Google API 使用基于时间的 SHA1 哈希进行认证。 | |
| * 格式: SAPISIDHASH {timestamp}_{sha1_hex} | |
| * | |
| * 算法步骤: | |
| * 1. 获取当前 Unix 时间戳 | |
| * 2. 构造输入: "{timestamp} {sapisid} https://gemini.google.com" | |
| * 3. 使用 SHA-1 算法对输入进行哈希 | |
| * 4. 返回格式化字符串: "SAPISIDHASH {ts}_{hex}" | |
| * | |
| * @param {string} sapisid - 从 Google Cookie 中提取的 SAPISID 值 | |
| * @returns {Promise<string>} 认证哈希字符串 | |
| */ | |
| async function makeSapisidHash(sapisid) { | |
| // 获取当前时间戳 | |
| var ts = timestamp(); | |
| // 构造哈希输入(与 Google Web 前端完全一致) | |
| var input = ts + ' ' + sapisid + ' https://gemini.google.com'; | |
| // 将输入字符串编码为 UTF-8 字节数组 | |
| var encoder = new TextEncoder(); | |
| var data = encoder.encode(input); | |
| // 使用 Web Crypto API 进行 SHA-1 哈希 | |
| var hashBuffer = await crypto.subtle.digest('SHA-1', data); | |
| // 将哈希结果转换为十六进制字符串 | |
| var hashArray = Array.from(new Uint8Array(hashBuffer)); | |
| var hashHex = hashArray.map(function (b) { | |
| return b.toString(16).padStart(2, '0'); | |
| }).join(''); | |
| // 返回格式化的认证字符串 | |
| return 'SAPISIDHASH ' + ts + '_' + hashHex; | |
| } | |
| /** | |
| * 获取多账户 URL 前缀 | |
| * | |
| * Google 支持在同一个浏览器中登录多个账户。 | |
| * 当使用非默认账户时,Gemini 的 URL 路径会包含账户索引。 | |
| * - 默认账户: https://gemini.google.com/app | |
| * - 第二个账户: https://gemini.google.com/u/1/app | |
| * - 第三个账户: https://gemini.google.com/u/2/app | |
| * | |
| * @param {Object} config - 请求级配置对象 | |
| * @returns {string} URL 前缀,如 "/u/1",默认账户返回空字符串 | |
| */ | |
| function getAccountPrefix(config) { | |
| var authUser = config.authUser; | |
| // 如果 authUser 为 null、undefined 或空字符串,使用默认账户 | |
| if (authUser === null || authUser === undefined || authUser === '') { | |
| return ''; | |
| } | |
| // 返回带前导斜杠的账户前缀 | |
| return '/u/' + authUser; | |
| } | |
| // ============================================================================ | |
| // 📡 Gemini API 请求构建 | |
| // ============================================================================ | |
| // Gemini 的内部 API 使用复杂的嵌套数组结构。 | |
| // 以下函数负责构建与 Gemini Web 前端完全一致的请求负载和请求头。 | |
| /** | |
| * 构建 Gemini API 请求负载 | |
| * | |
| * Gemini 内部使用 80 个元素的嵌套数组作为请求体。 | |
| * 关键字段说明: | |
| * inner[0]: 用户消息和元数据 [prompt, index, image, attachment, metadata, context_id, is_new] | |
| * inner[1]: 语言设置 ["en"] | |
| * inner[2]: 对话上下文 [conv_id, resp_id, option_id, ...] | |
| * inner[6]: 连续对话标志 [0] | |
| * inner[7]: 流式输出标志 1 | |
| * inner[10]: 流式输出标志 1 | |
| * inner[11]: 安全过滤级别 0(基础过滤) | |
| * inner[17]: 思考模式 [[thinkMode]] | |
| * inner[18]: 扩展思考标志 0 | |
| * inner[30]: 输出格式 [4] | |
| * inner[41]: 响应类型 [2] | |
| * inner[59]: 唯一请求 ID(UUID) | |
| * inner[61]: 附件列表 [] | |
| * inner[79]: 模型选择(MODE_CATEGORY 枚举值)⭐ 最关键 | |
| * | |
| * @param {string} prompt - 用户输入的提示文本 | |
| * @param {number} modelId - 模型类别 ID(MODE_CATEGORY 枚举值: 1-6) | |
| * @param {number} thinkMode - 思考模式设置(0=深度思考, 4=自动) | |
| * @param {Object} config - 请求级配置对象 | |
| * @returns {string} URL 编码的请求体字符串,格式为 "f.req=..." | |
| */ | |
| function buildPayload(prompt, modelId, thinkMode, config) { | |
| // 创建 80 个元素的数组,所有元素初始化为 null | |
| // 这是 Gemini Web 前端实际使用的数据结构 | |
| var inner = new Array(80).fill(null); | |
| // --- 用户消息 --- | |
| // [prompt, 0, None, None, None, None, 0] | |
| // prompt: 用户输入的文本 | |
| // 0: 消息索引/序列号 | |
| // None: 图片数据(可选) | |
| // None: 附件信息(可选) | |
| // None: 元数据(可选) | |
| // None: 上下文 ID(可选) | |
| // 0: 是否为新对话的标志 | |
| inner[0] = [prompt, 0, null, null, null, null, 0]; | |
| // --- 语言设置 --- | |
| inner[1] = ['en']; | |
| // --- 对话上下文 --- | |
| // 空字符串表示新对话,null 表示未设置 | |
| inner[2] = ['', '', '', null, null, null, null, null, null, '']; | |
| // --- 连续对话标志 --- | |
| inner[6] = [0]; | |
| // --- 流式输出标志 --- | |
| inner[7] = 1; // 启用流式 | |
| inner[10] = 1; // 流式输出 | |
| // --- 安全过滤级别 --- | |
| // 0 = 基础过滤(推荐) | |
| // 1 = 严格过滤 | |
| // 2 = 最严格过滤 | |
| inner[11] = 0; | |
| // --- 思考模式配置 --- | |
| // 双层嵌套数组: [[thinkMode]] | |
| inner[17] = [[thinkMode]]; | |
| // --- 扩展思考标志 --- | |
| inner[18] = 0; | |
| // --- 各种内部参数 --- | |
| inner[27] = 1; // 未知标志 | |
| inner[30] = [4]; // 输出格式 | |
| inner[41] = [2]; // 响应类型 | |
| inner[53] = 0; // 未知标志 | |
| // --- 唯一请求 ID --- | |
| // 使用 UUID v4 确保每次请求都有唯一标识 | |
| inner[59] = generateUUID(); | |
| // --- 附件列表 --- | |
| // 空数组表示没有附件 | |
| inner[61] = []; | |
| // --- 其他设置 --- | |
| inner[68] = 1; // 未知标志 | |
| // ⭐ 模型选择(最关键字段) | |
| // MODE_CATEGORY 枚举值: | |
| // 1=FAST, 2=THINKING, 3=PRO, 4=AUTO | |
| // 5=FAST_DYNAMIC_THINKING, 6=FLASH_LITE | |
| inner[79] = modelId; | |
| // --- 外层包装 --- | |
| // Gemini 的请求体是双层嵌套 JSON: | |
| // 外层: [null, inner_json_string] | |
| var outer = [null, JSON.stringify(inner)]; | |
| // --- 构建 URL 编码参数 --- | |
| var params = new URLSearchParams(); | |
| // 主要数据放在 f.req 参数中 | |
| params.append('f.req', JSON.stringify(outer)); | |
| // 可选:添加 XSRF 令牌 | |
| // 通常不需要,但某些情况下可能需要 | |
| if (config.xsrfToken) { | |
| params.append('at', config.xsrfToken); | |
| } | |
| // 返回 URL 编码的字符串 | |
| return params.toString(); | |
| } | |
| /** | |
| * 构建 Gemini API 请求 URL | |
| * | |
| * URL 格式: | |
| * https://gemini.google.com{prefix}/_/BardChatUi/data/ | |
| * assistant.lamda.BardFrontendService/StreamGenerate | |
| * ?bl={build_label}&hl=en&_reqid={request_id}&rt=c | |
| * | |
| * 参数说明: | |
| * - bl (build label): Gemini 前端构建版本标识 | |
| * - hl (host language): 界面语言,固定为 en | |
| * - _reqid: 请求 ID,使用时间戳后 6 位 | |
| * - rt: 请求类型,c 表示普通请求 | |
| * | |
| * @param {Object} config - 请求级配置对象 | |
| * @returns {string} 完整的请求 URL | |
| */ | |
| function buildUrl(config) { | |
| // 获取多账户 URL 前缀 | |
| var prefix = getAccountPrefix(config); | |
| // 生成请求 ID(使用时间戳的后 6 位数字) | |
| var reqid = timestamp() % 1000000; | |
| // 拼接完整 URL | |
| return 'https://gemini.google.com' + prefix + | |
| '/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate' + | |
| '?bl=' + config.geminiBl + | |
| '&hl=en' + | |
| '&_reqid=' + reqid + | |
| '&rt=c'; | |
| } | |
| /** | |
| * 构建 Gemini API 请求头 | |
| * | |
| * 包含完整的浏览器伪装头,让请求看起来像从 Gemini 网页内部发出的。 | |
| * 支持 Cookie 认证和 SAPISID 哈希认证。 | |
| * | |
| * 请求头说明: | |
| * - Content-Type: 标准表单提交格式 | |
| * - Origin/Referer: 声明请求来源 | |
| * - X-Same-Domain: 告诉后端这是同域请求 | |
| * - User-Agent: 伪装成 Chrome 浏览器 | |
| * - Sec-* 系列: 浏览器安全策略头 | |
| * - Cookie: 可选的认证 Cookie | |
| * - Authorization: 可选的 SAPISID 认证哈希 | |
| * | |
| * @param {Object} config - 请求级配置对象 | |
| * @returns {Promise<Object>} HTTP 请求头对象 | |
| */ | |
| async function buildHeaders(config) { | |
| // 获取多账户 URL 前缀 | |
| var prefix = getAccountPrefix(config); | |
| // --- 基础请求头 --- | |
| var headers = { | |
| // 标准表单提交格式 | |
| 'Content-Type': 'application/x-www-form-urlencoded', | |
| // 声明请求来源域 | |
| 'Origin': 'https://gemini.google.com', | |
| // 声明引用页面 | |
| 'Referer': 'https://gemini.google.com' + prefix + '/app', | |
| // 同域请求标志 | |
| 'X-Same-Domain': '1', | |
| // 浏览器伪装(Chrome 127) | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36', | |
| // 接受任意响应类型 | |
| 'Accept': '*/*', | |
| // 接受的语言 | |
| 'Accept-Language': 'en-US,en;q=0.9', | |
| // 浏览器安全策略头 | |
| 'Sec-Fetch-Dest': 'empty', | |
| 'Sec-Fetch-Mode': 'cors', | |
| 'Sec-Fetch-Site': 'same-origin', | |
| }; | |
| // --- 多账户支持 --- | |
| // 如果使用了非默认账户,添加认证用户头 | |
| if (prefix) { | |
| headers['X-Goog-AuthUser'] = String(config.authUser); | |
| } | |
| // --- Cookie 认证 --- | |
| // 如果配置了 Cookie 字符串,添加到请求头中 | |
| // Cookie 可以显著提升请求的稳定性和路由质量 | |
| if (config.cookieString) { | |
| headers['Cookie'] = config.cookieString; | |
| } | |
| // --- SAPISID 认证哈希 --- | |
| // 如果配置了 SAPISID,生成基于时间的认证哈希 | |
| // 这个哈希验证请求来自合法的 Google 用户会话 | |
| if (config.sapisid) { | |
| headers['Authorization'] = await makeSapisidHash(config.sapisid); | |
| } | |
| return headers; | |
| } | |
| // ============================================================================ | |
| // 📡 非流式 API 调用 | |
| // ============================================================================ | |
| /** | |
| * 非流式调用 Gemini API | |
| * | |
| * 发送请求到 Gemini StreamGenerate 端点并等待完整响应。 | |
| * 支持自动重试、指数退避、详细的错误处理。 | |
| * | |
| * 重试策略: | |
| * - 使用指数退避算法 | |
| * - 第一次重试: 等待 retryDelaySec 秒 | |
| * - 第二次重试: 等待 retryDelaySec * 2 秒 | |
| * - 第三次重试: 等待 retryDelaySec * 4 秒 | |
| * | |
| * 错误处理: | |
| * - 405: BL 版本过期,需要更新 geminiBl 配置 | |
| * - 429: 请求频率超限,等待 Retry-After 秒后重试 | |
| * - 403: 需要有效的 Cookie 认证 | |
| * - 其他: 记录错误信息并重试 | |
| * | |
| * @param {string} prompt - 用户输入的提示文本 | |
| * @param {number} modelId - 模型类别 ID | |
| * @param {number} thinkMode - 思考模式设置 | |
| * @param {Object} config - 请求级配置对象 | |
| * @returns {Promise<string>} API 原始响应文本(包含嵌套 JSON) | |
| * @throws {Error} 所有重试失败后抛出最后的错误 | |
| */ | |
| async function geminiStreamGenerate(prompt, modelId, thinkMode, config) { | |
| // 构建请求负载 | |
| var body = buildPayload(prompt, modelId, thinkMode, config); | |
| // 构建请求头 | |
| var headers = await buildHeaders(config); | |
| // 构建请求 URL | |
| var url = buildUrl(config); | |
| // 保存最后一次错误,所有重试失败后抛出 | |
| var lastError; | |
| // 重试循环 | |
| for (var attempt = 0; attempt < config.retryAttempts; attempt++) { | |
| try { | |
| // 创建 AbortController 用于超时控制 | |
| var controller = new AbortController(); | |
| // 设置超时定时器 | |
| var timeout = setTimeout(function () { | |
| controller.abort(); // 超时后中止请求 | |
| }, config.requestTimeoutSec * 1000); | |
| // 发送 HTTP POST 请求 | |
| var response = await fetch(url, { | |
| method: 'POST', | |
| headers: headers, | |
| body: body, | |
| signal: controller.signal, // 关联中止信号 | |
| }); | |
| // 请求成功,清除超时定时器 | |
| clearTimeout(timeout); | |
| // ============================================================ | |
| // 错误状态码处理 | |
| // ============================================================ | |
| // 405 Method Not Allowed: BL 版本过期 | |
| // Gemini 更新了前端,需要同步更新 geminiBl 配置 | |
| if (response.status === 405) { | |
| throw new Error('HTTP 405: Method Not Allowed - 可能 BL 版本过期,请更新 geminiBl'); | |
| } | |
| // 429 Too Many Requests: 请求频率超限 | |
| // 等待服务器指定的时间后重试 | |
| if (response.status === 429) { | |
| // 从响应头获取重试等待时间(秒) | |
| var retryAfter = parseInt(response.headers.get('Retry-After') || '5', 10); | |
| log('收到 429 限流,等待 ' + retryAfter + ' 秒后重试...', 'WARN', config); | |
| // 如果还有重试机会,等待后继续 | |
| if (attempt < config.retryAttempts - 1) { | |
| await new Promise(function (resolve) { | |
| setTimeout(resolve, retryAfter * 1000); | |
| }); | |
| continue; // 跳过本次,进入下一次重试 | |
| } | |
| // 没有重试机会了,抛出错误 | |
| throw new Error('HTTP 429: Too Many Requests - 请添加有效的 Cookie 或降低请求频率'); | |
| } | |
| // 403 Forbidden: 需要认证 | |
| if (response.status === 403) { | |
| throw new Error('HTTP 403: Forbidden - 可能需要有效的 Cookie 认证'); | |
| } | |
| // 其他 HTTP 错误 | |
| if (!response.ok) { | |
| var errorText = ''; | |
| try { | |
| errorText = await response.text(); | |
| } catch (e) { | |
| errorText = '无法读取错误信息'; | |
| } | |
| throw new Error('HTTP ' + response.status + ': ' + errorText.substring(0, 200)); | |
| } | |
| // 请求成功,返回响应文本 | |
| return await response.text(); | |
| } catch (error) { | |
| // 保存错误信息 | |
| lastError = error; | |
| // 如果还有重试机会,等待后重试 | |
| if (attempt < config.retryAttempts - 1) { | |
| log('重试 ' + (attempt + 1) + '/' + config.retryAttempts + ': ' + error.message, 'WARN', config); | |
| // 指数退避: 延迟时间 = 基础延迟 * 2^attempt | |
| var delay = config.retryDelaySec * Math.pow(2, attempt) * 1000; | |
| await new Promise(function (resolve) { | |
| setTimeout(resolve, delay); | |
| }); | |
| } | |
| } | |
| } | |
| // 所有重试都失败,抛出最后的错误 | |
| throw lastError; | |
| } | |
| // ============================================================================ | |
| // 📝 文本处理 | |
| // ============================================================================ | |
| /** | |
| * 清理 Gemini 响应中的代码执行痕迹 | |
| * | |
| * Gemini 有时会在响应中包含代码执行参考和输出块,格式如下: | |
| * ```python?code_reference&code_event_index=0 | |
| * ...代码... | |
| * ``` | |
| * ```javascript?code_stdout&code_event_index=1 | |
| * ...输出... | |
| * ``` | |
| * 这些应该被移除以获得干净的响应文本。 | |
| * | |
| * @param {string} text - 原始响应文本 | |
| * @param {boolean} [strip] - 是否去除首尾空白,默认 true | |
| * @returns {string} 清理后的文本 | |
| */ | |
| function cleanGeminiText(text, strip) { | |
| // 如果未指定 strip 参数,默认值为 true | |
| if (strip === undefined) strip = true; | |
| // 移除代码执行块 | |
| // 正则说明: | |
| // - ```(?:python|javascript|text): 匹配代码块开始 | |
| // - \?code_(?:reference|stdout)&code_event_index=\d+: 匹配代码执行参数 | |
| // - \n[\s\S]*?```: 匹配代码块内容(非贪婪)到结束标记 | |
| // - \n?: 匹配可能存在的换行 | |
| text = text.replace( | |
| /```(?:python|javascript|text)\?code_(?:reference|stdout)&code_event_index=\d+\n[\s\S]*?```\n?/g, | |
| '' | |
| ); | |
| // 根据 strip 参数决定是否去除首尾空白 | |
| return strip ? text.trim() : text; | |
| } | |
| /** | |
| * 从 Gemini API 原始响应中提取最终文本 | |
| * | |
| * 解析逻辑: | |
| * 1. 检查是否有 BardErrorInfo 错误 | |
| * 2. 按行分割原始响应 | |
| * 3. 跳过不相关的行(不含 "wrb.fr" 或太短的行) | |
| * 4. 解析每行的 JSON 数据(双层嵌套) | |
| * 5. 从 inner[4] 中提取文本内容 | |
| * 6. 返回最后一个非空文本(通常是最终的完整响应) | |
| * | |
| * 数据结构说明: | |
| * 每行是一个 JSON 数组: [["wrb.fr", "[[...]]", ...], ...] | |
| * 其中第二个元素是内层 JSON 字符串: "[[...]]" | |
| * 内层 JSON 的 inner[4] 包含对话内容 | |
| * inner[4] 的每个元素是 [type, [text1, text2, ...]] | |
| * | |
| * @param {string} raw - API 原始响应文本 | |
| * @returns {string} 提取的最终文本 | |
| * @throws {Error} 如果检测到 BardErrorInfo 错误 | |
| */ | |
| function extractResponseText(raw) { | |
| // 检查 BardErrorInfo 错误 | |
| // 格式: BardErrorInfo [错误代码] | |
| var bardErr = raw.match(/BardErrorInfo\s*\[(\d+)\]/); | |
| if (bardErr) { | |
| throw new Error('Gemini upstream rejected request: BardErrorInfo [' + bardErr[1] + ']'); | |
| } | |
| // 收集所有提取到的文本片段 | |
| var texts = []; | |
| // 按行分割原始响应 | |
| var lines = raw.split('\n'); | |
| for (var i = 0; i < lines.length; i++) { | |
| var line = lines[i]; | |
| // 跳过不包含 "wrb.fr" 的行(不是数据行) | |
| // 跳过长度小于 200 的行(太短,不包含有效数据) | |
| if (line.indexOf('"wrb.fr"') === -1 || line.length < 200) continue; | |
| try { | |
| // 解析外层 JSON | |
| var arr = JSON.parse(line); | |
| // 提取内层 JSON 字符串 | |
| var innerStr = arr[0][2]; | |
| // 跳过空的或太短的内层 JSON | |
| if (!innerStr || innerStr.length < 50) continue; | |
| // 解析内层 JSON | |
| var inner = JSON.parse(innerStr); | |
| // 检查 inner[4] 是否存在且包含内容 | |
| if (Array.isArray(inner) && inner.length > 4 && inner[4]) { | |
| var parts = inner[4]; | |
| // 遍历 inner[4] 的每个部分 | |
| for (var j = 0; j < parts.length; j++) { | |
| var part = parts[j]; | |
| // part[1] 包含文本数据 | |
| if (Array.isArray(part) && part.length > 1 && part[1]) { | |
| if (Array.isArray(part[1])) { | |
| var textItems = part[1]; | |
| // 遍历文本项 | |
| for (var k = 0; k < textItems.length; k++) { | |
| var t = textItems[k]; | |
| // 收集非空字符串 | |
| if (typeof t === 'string' && t.length > 0) { | |
| texts.push(t); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| // JSON 解析错误,可能是响应不完整,继续处理下一行 | |
| } | |
| } | |
| // 获取最后一个非空文本 | |
| // Gemini 的响应是逐步累积的,最后一个通常包含完整文本 | |
| var text = ''; | |
| for (var m = texts.length - 1; m >= 0; m--) { | |
| if (texts[m].trim()) { | |
| text = texts[m]; | |
| break; | |
| } | |
| } | |
| // 清理代码执行痕迹并返回 | |
| return cleanGeminiText(text); | |
| } | |
| // ============================================================================ | |
| // 🔄 OpenAI 格式转换 | |
| // ============================================================================ | |
| /** | |
| * 将 OpenAI 消息列表转换为 Gemini 提示文本 | |
| * | |
| * 转换规则: | |
| * - system 角色 -> "[System instruction]: {content}" | |
| * - assistant 角色 -> "[Assistant]: {content}" | |
| * - tool 角色 -> "[Tool result for {name}]: {content}" | |
| * - user 角色 -> 直接使用 {content} | |
| * - 工具调用 -> ```tool_call\n{json}\n``` 代码块格式 | |
| * | |
| * 多条消息之间使用双换行(\n\n)分隔。 | |
| * | |
| * @param {Array} messages - OpenAI 格式的消息列表 | |
| * 每条消息格式: { role: string, content: string|array } | |
| * @param {Array} [tools] - 可用的工具/函数定义列表 | |
| * 每个工具格式: { type: "function", function: { name, description, parameters } } | |
| * @returns {string} 转换后的提示文本 | |
| */ | |
| function messagesToPrompt(messages, tools) { | |
| // 存储各个消息段的数组 | |
| var parts = []; | |
| // ================================================================ | |
| // 添加工具使用说明 | |
| // ================================================================ | |
| if (tools && tools.length > 0) { | |
| // 标准化工具定义格式 | |
| var toolDefs = []; | |
| for (var ti = 0; ti < tools.length; ti++) { | |
| var tool = tools[ti]; | |
| // 兼容两种格式: { type: "function", function: {...} } 和 { name: "...", ... } | |
| var fn = (tool.type === 'function') ? (tool.function || tool) : tool; | |
| toolDefs.push({ | |
| name: fn.name || tool.name || '', | |
| description: fn.description || tool.description || '', | |
| parameters: fn.parameters || tool.parameters || {}, | |
| }); | |
| } | |
| // 构建工具使用说明 | |
| parts.push( | |
| '[System instruction]: You have access to tools. ' + | |
| 'To call a tool, respond with:\n' + | |
| '```tool_call\n{"name": "func_name", "arguments": {...}}\n```\n' + | |
| 'Only use tool_call blocks when needed.\n\n' + | |
| 'Available tools:\n' + JSON.stringify(toolDefs, null, 2) | |
| ); | |
| } | |
| // ================================================================ | |
| // 处理每条消息 | |
| // ================================================================ | |
| for (var mi = 0; mi < messages.length; mi++) { | |
| var msg = messages[mi]; | |
| var role = msg.role || 'user'; | |
| var content = msg.content || ''; | |
| // 如果内容是数组(多模态消息),提取文本部分 | |
| if (Array.isArray(content)) { | |
| var textParts = []; | |
| for (var ci = 0; ci < content.length; ci++) { | |
| var c = content[ci]; | |
| // 只提取文本类型的内容 | |
| if (c.type === 'text' || c.type === 'input_text') { | |
| textParts.push(c.text || ''); | |
| } | |
| } | |
| content = textParts.join(' '); | |
| } | |
| // 根据角色进行不同的格式化 | |
| if (role === 'system') { | |
| // 系统消息:添加指令前缀 | |
| parts.push('[System instruction]: ' + content); | |
| } else if (role === 'assistant') { | |
| // 助手消息:检查是否包含工具调用 | |
| if (msg.tool_calls && msg.tool_calls.length > 0) { | |
| // 将工具调用转换为代码块格式 | |
| var tcStrs = []; | |
| for (var tci = 0; tci < msg.tool_calls.length; tci++) { | |
| var tc = msg.tool_calls[tci]; | |
| var fn = tc.function || {}; | |
| tcStrs.push( | |
| '```tool_call\n' + | |
| '{"name": "' + fn.name + '", "arguments": ' + (fn.arguments || '{}') + '}\n' + | |
| '```' | |
| ); | |
| } | |
| parts.push('[Assistant]: ' + (content || '') + '\n' + tcStrs.join('\n')); | |
| } else { | |
| parts.push('[Assistant]: ' + content); | |
| } | |
| } else if (role === 'tool') { | |
| // 工具响应:添加结果前缀 | |
| parts.push('[Tool result for ' + (msg.name || 'unknown') + ']: ' + content); | |
| } else { | |
| // 用户消息:直接使用内容 | |
| parts.push(content || ''); | |
| } | |
| } | |
| // 用双换行连接所有部分,过滤空字符串 | |
| return parts.filter(function (p) { return p; }).join('\n\n'); | |
| } | |
| /** | |
| * 从响应文本中解析工具调用 | |
| * | |
| * 工具调用格式: | |
| * ```tool_call | |
| * {"name": "函数名", "arguments": {"参数名": "参数值"}} | |
| * ``` | |
| * | |
| * @param {string} text - 可能包含工具调用的响应文本 | |
| * @returns {Object} { cleanText: string, toolCalls: Array } | |
| * - cleanText: 移除工具调用块后的纯文本 | |
| * - toolCalls: 解析出的工具调用对象数组 | |
| */ | |
| function parseToolCalls(text) { | |
| var toolCalls = []; | |
| // 正则匹配 tool_call 代码块 | |
| // /```tool_call\s*\n(.*?)\n```/gs | |
| // g: 全局匹配(查找所有匹配项) | |
| // s: 允许 . 匹配换行符 | |
| var pattern = /```tool_call\s*\n(.*?)\n```/gs; | |
| var match; | |
| // 循环提取所有工具调用 | |
| while ((match = pattern.exec(text)) !== null) { | |
| try { | |
| // 解析 JSON 数据 | |
| var data = JSON.parse(match[1].trim()); | |
| // 构建 OpenAI 格式的工具调用对象 | |
| toolCalls.push({ | |
| id: 'call_' + generateShortId(8), // 生成唯一调用 ID | |
| type: 'function', | |
| function: { | |
| name: data.name, | |
| arguments: JSON.stringify(data.arguments || {}), | |
| }, | |
| }); | |
| } catch (e) { | |
| // JSON 解析失败,跳过格式有误的块 | |
| } | |
| } | |
| // 从文本中移除所有 tool_call 块 | |
| var cleanText = text.replace(pattern, '').trim(); | |
| return { | |
| cleanText: cleanText, | |
| toolCalls: toolCalls | |
| }; | |
| } | |
| /** | |
| * Google 原生 API 格式转换为提示文本 | |
| * | |
| * 支持 Google Gemini CLI 的原生 API 格式。 | |
| * 格式: | |
| * { | |
| * "systemInstruction": { "parts": [{"text": "..."}] }, | |
| * "contents": [ | |
| * { "role": "user", "parts": [{"text": "..."}] }, | |
| * { "role": "model", "parts": [{"text": "..."}] } | |
| * ] | |
| * } | |
| * | |
| * @param {Object} req - Google API 格式的请求对象 | |
| * @returns {string} 转换后的提示文本 | |
| */ | |
| function googleContentsToPrompt(req) { | |
| var parts = []; | |
| // 处理系统指令 | |
| var sysInst = req.systemInstruction; | |
| if (sysInst && sysInst.parts) { | |
| var sysTextParts = []; | |
| for (var si = 0; si < sysInst.parts.length; si++) { | |
| var sp = sysInst.parts[si]; | |
| if (sp.text) sysTextParts.push(sp.text); | |
| } | |
| var sysText = sysTextParts.join(' '); | |
| if (sysText) { | |
| parts.push('[System instruction]: ' + sysText); | |
| } | |
| } | |
| // 处理对话内容 | |
| var contents = req.contents || []; | |
| for (var ci = 0; ci < contents.length; ci++) { | |
| var content = contents[ci]; | |
| var role = content.role || 'user'; | |
| var textParts = []; | |
| var partsArr = content.parts || []; | |
| for (var pi = 0; pi < partsArr.length; pi++) { | |
| if (partsArr[pi].text) textParts.push(partsArr[pi].text); | |
| } | |
| var text = textParts.join(' '); | |
| // model 角色转换为 Assistant 前缀 | |
| if (role === 'model') { | |
| parts.push('[Assistant]: ' + text); | |
| } else { | |
| parts.push(text); | |
| } | |
| } | |
| return parts.filter(function (p) { return p; }).join('\n\n'); | |
| } | |
| // ============================================================================ | |
| // 🚦 速率限制(Serverless 安全的内存存储) | |
| // ============================================================================ | |
| // 使用 Map 数据结构存储每个 IP 的请求历史 | |
| // Map 支持高效的增删改查操作 | |
| var rateLimitStore = new Map(); | |
| /** | |
| * 检查请求是否超过速率限制 | |
| * | |
| * 使用滑动窗口算法: | |
| * 1. 获取当前时间和该 IP 的历史请求记录 | |
| * 2. 过滤出时间窗口内的请求 | |
| * 3. 如果请求数超过阈值,拒绝 | |
| * 4. 否则记录本次请求并允许 | |
| * | |
| * 内存管理: | |
| * - 每次检查时有 5% 的概率触发全局清理 | |
| * - 清理所有过期或空的记录 | |
| * - 防止长时间运行后内存无限增长 | |
| * | |
| * @param {string} clientIP - 客户端 IP 地址 | |
| * @param {Object} config - 请求级配置对象 | |
| * @returns {boolean} true 表示允许请求,false 表示被限流 | |
| */ | |
| function checkRateLimit(clientIP, config) { | |
| // 如果速率限制未启用,直接允许 | |
| if (!config.rateLimit || !config.rateLimit.enabled) return true; | |
| var now = Date.now(); | |
| // 计算时间窗口的毫秒数 | |
| var windowMs = config.rateLimit.windowSec * 1000; | |
| // 生成存储键 | |
| var key = 'rl:' + clientIP; | |
| // 获取该 IP 的历史记录,并过滤出当前窗口内的请求 | |
| var timestamps = (rateLimitStore.get(key) || []).filter(function (t) { | |
| return now - t < windowMs; | |
| }); | |
| // 如果窗口内的请求数达到或超过阈值,拒绝 | |
| if (timestamps.length >= config.rateLimit.maxRequests) { | |
| return false; | |
| } | |
| // 记录本次请求的时间戳 | |
| timestamps.push(now); | |
| rateLimitStore.set(key, timestamps); | |
| // ================================================================ | |
| // 🛡️ 随机概率清理过期键(5% 概率触发) | |
| // ================================================================ | |
| // 防止长期高并发运行后,大量冷 IP 的记录残留内存 | |
| // 5% 的概率确保不会频繁执行清理操作 | |
| if (Math.random() < 0.05) { | |
| // 遍历所有 IP 的记录 | |
| rateLimitStore.forEach(function (v, k) { | |
| // 过滤出有效的(未过期的)记录 | |
| var valid = v.filter(function (t) { | |
| return now - t < windowMs; | |
| }); | |
| if (valid.length === 0) { | |
| // 如果该 IP 已没有任何有效记录,删除整个条目 | |
| rateLimitStore.delete(k); | |
| } else { | |
| // 更新为只包含有效记录的数组 | |
| rateLimitStore.set(k, valid); | |
| } | |
| }); | |
| } | |
| return true; | |
| } | |
| // ============================================================================ | |
| // 🔐 API 密钥验证 | |
| // ============================================================================ | |
| /** | |
| * 验证 API 密钥 | |
| * | |
| * 支持多种认证方式(按优先级): | |
| * 1. Authorization: Bearer <key> 标准 Bearer Token 认证 | |
| * 2. x-api-key: <key> 自定义请求头 | |
| * 3. x-goog-api-key: <key> Google 风格请求头 | |
| * 4. ?key=<key> URL 查询参数 | |
| * | |
| * 如果 apiKeys 为空数组,表示不验证,所有请求都允许。 | |
| * | |
| * @param {Request} request - HTTP 请求对象 | |
| * @param {Object} config - 请求级配置对象 | |
| * @returns {boolean} true 表示通过认证,false 表示认证失败 | |
| */ | |
| function checkApiKey(request, config) { | |
| // 获取 API 密钥白名单 | |
| var keys = config.apiKeys || []; | |
| // 如果未配置密钥,允许所有请求 | |
| if (keys.length === 0) return true; | |
| // --- 方式 1: Authorization: Bearer <key> --- | |
| var auth = request.headers.get('Authorization') || ''; | |
| // 检查是否以 "Bearer " 开头,且后续的 token 在白名单中 | |
| if (auth.indexOf('Bearer ') === 0) { | |
| var token = auth.slice(7); // 去掉 "Bearer " 前缀 | |
| if (keys.indexOf(token) !== -1) return true; | |
| } | |
| // --- 方式 2 & 3: x-api-key / x-goog-api-key --- | |
| var headerNames = ['x-api-key', 'x-goog-api-key']; | |
| for (var i = 0; i < headerNames.length; i++) { | |
| var value = request.headers.get(headerNames[i]) || ''; | |
| if (keys.indexOf(value) !== -1) return true; | |
| } | |
| // --- 方式 4: URL 查询参数 ?key= --- | |
| var url = new URL(request.url); | |
| var keyParam = url.searchParams.get('key'); | |
| if (keyParam && keys.indexOf(keyParam) !== -1) return true; | |
| // 所有认证方式都失败 | |
| return false; | |
| } | |
| // ============================================================================ | |
| // 📤 HTTP 响应构建 | |
| // ============================================================================ | |
| /** | |
| * 发送 JSON 格式的 HTTP 响应 | |
| * | |
| * 自动设置 CORS 头,允许跨域访问。 | |
| * | |
| * @param {Object} data - 要发送的响应数据 | |
| * @param {number} [status] - HTTP 状态码,默认 200 | |
| * @returns {Response} HTTP 响应对象 | |
| */ | |
| function sendJSON(data, status) { | |
| if (status === undefined) status = 200; | |
| // 将数据序列化为 JSON 字符串 | |
| var body = JSON.stringify(data); | |
| // 构建响应对象 | |
| return new Response(body, { | |
| status: status, | |
| headers: { | |
| 'Content-Type': 'application/json; charset=utf-8', | |
| 'Access-Control-Allow-Origin': '*', // 允许所有域 | |
| 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', // 允许的 HTTP 方法 | |
| 'Access-Control-Allow-Headers': '*', // 允许所有请求头 | |
| }, | |
| }); | |
| } | |
| /** | |
| * 发送 SSE(Server-Sent Events)流式响应 | |
| * | |
| * SSE 是一种服务器向客户端推送实时数据的协议。 | |
| * 格式: "data: {json}\n\n" | |
| * | |
| * @param {ReadableStream} stream - 可读流对象 | |
| * @returns {Response} HTTP 流式响应对象 | |
| */ | |
| function sendSSE(stream) { | |
| return new Response(stream, { | |
| headers: { | |
| 'Content-Type': 'text/event-stream; charset=utf-8', // SSE 内容类型 | |
| 'Cache-Control': 'no-cache', // 禁用缓存 | |
| 'Connection': 'keep-alive', // 保持连接 | |
| 'X-Accel-Buffering': 'no', // 禁用 nginx 缓冲 | |
| 'Access-Control-Allow-Origin': '*', | |
| 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', | |
| 'Access-Control-Allow-Headers': '*', | |
| }, | |
| }); | |
| } | |
| // ============================================================================ | |
| // 🎯 模型解析 | |
| // ============================================================================ | |
| /** | |
| * 解析模型名称,获取对应的配置参数 | |
| * | |
| * 支持 @think= 参数来覆盖默认的思考模式。 | |
| * 例如: "gemini-3.6-flash@think=0" 表示使用 Flash 模型但启用深度思考。 | |
| * | |
| * @param {string} modelName - 模型名称,如 "gemini-3.6-flash" 或 "gemini-3.6-flash@think=0" | |
| * @returns {Object} { modelName, modelId, thinkMode, error } | |
| */ | |
| function resolveModel(modelName) { | |
| var thinkOverride = null; | |
| var actualModelName = modelName; | |
| // 检查是否包含 @think= 参数 | |
| if (modelName.indexOf('@think=') !== -1) { | |
| var parts = modelName.split('@think='); | |
| actualModelName = parts[0]; // 提取真正的模型名称 | |
| thinkOverride = parseInt(parts[1], 10); // 提取思考模式覆盖值 | |
| if (isNaN(thinkOverride)) { | |
| return { error: '无效的 think 参数: ' + parts[1] }; | |
| } | |
| } | |
| // 查找模型配置 | |
| var cfg = MODELS[actualModelName]; | |
| if (!cfg) { | |
| return { error: '未知模型: ' + actualModelName }; | |
| } | |
| // 返回解析结果 | |
| return { | |
| modelName: actualModelName, | |
| modelId: cfg.mode, // 模型类别 ID | |
| thinkMode: thinkOverride !== null ? thinkOverride : cfg.think, // 使用覆盖值或默认值 | |
| error: null, | |
| }; | |
| } | |
| // ============================================================================ | |
| // 📋 核心请求处理 | |
| // ============================================================================ | |
| /** | |
| * 处理 /v1/chat/completions 请求 | |
| * | |
| * 这是 OpenAI 兼容 API 的核心端点,处理聊天补全请求。 | |
| * | |
| * 支持两种模式: | |
| * 1. 非流式(stream=false): 等待完整响应后一次性返回 JSON | |
| * 2. 流式(stream=true): 实时转发 Gemini 的增量数据,实现打字机效果 | |
| * | |
| * 也支持工具调用(Function Calling): 当提供 tools 参数时,自动切换到非流式模式。 | |
| * | |
| * SSE 格式严格符合 OpenAI 标准: | |
| * - 首块: { delta: { role: 'assistant' } }(只含 role,不含 content) | |
| * - 内容块: { delta: { content: '增量文本' } }(实时增量输出) | |
| * - 结束块: { delta: { content: "" }, finish_reason: 'stop' } | |
| * | |
| * @param {Request} request - HTTP 请求对象 | |
| * @param {Object} body - 解析后的请求体 | |
| * @param {Object} config - 请求级配置对象 | |
| * @returns {Promise<Response>} HTTP 响应对象 | |
| */ | |
| async function handleChatCompletions(request, body, config) { | |
| // ---- 解析模型 ---- | |
| var resolved = resolveModel(body.model || config.defaultModel); | |
| if (resolved.error) { | |
| return sendJSON({ error: { message: resolved.error } }, 400); | |
| } | |
| var modelName = resolved.modelName; | |
| var modelId = resolved.modelId; | |
| var thinkMode = resolved.thinkMode; | |
| var tools = body.tools || null; | |
| // ---- 转换消息为提示文本 ---- | |
| var prompt = messagesToPrompt(body.messages || [], tools); | |
| if (!prompt.trim()) { | |
| return sendJSON({ error: { message: 'empty prompt' } }, 400); | |
| } | |
| var stream = body.stream === true; | |
| var chatId = 'chatcmpl-' + generateShortId(12); | |
| log('Chat: model=' + modelName + ', stream=' + stream + ', tokens≈' + estimateTokens(prompt), 'INFO', config); | |
| // ================================================================ | |
| // 非流式或带工具调用处理 | |
| // ================================================================ | |
| if (!stream || tools) { | |
| try { | |
| // 调用 Gemini API 获取原始响应 | |
| var raw = await geminiStreamGenerate(prompt, modelId, thinkMode, config); | |
| // 提取响应文本 | |
| var text = extractResponseText(raw); | |
| var toolCalls = null; | |
| // 如果启用了工具,解析工具调用 | |
| if (tools && text) { | |
| var parsed = parseToolCalls(text); | |
| text = parsed.cleanText; | |
| toolCalls = parsed.toolCalls.length > 0 ? parsed.toolCalls : null; | |
| } | |
| // 构建响应消息 | |
| var msg = { role: 'assistant', content: text || null }; | |
| if (toolCalls) { | |
| msg.tool_calls = toolCalls; | |
| } | |
| var finishReason = toolCalls ? 'tool_calls' : 'stop'; | |
| // 如果要求流式但有工具调用,发送单块的 SSE | |
| if (stream) { | |
| var encoder = new TextEncoder(); | |
| var nonStreamSSE = new ReadableStream({ | |
| start: function (controller) { | |
| var chunk = { | |
| id: chatId, | |
| object: 'chat.completion.chunk', | |
| created: timestamp(), | |
| model: modelName, | |
| choices: [{ index: 0, delta: msg, finish_reason: finishReason }], | |
| }; | |
| controller.enqueue(encoder.encode('data: ' + JSON.stringify(chunk) + '\n\n')); | |
| controller.enqueue(encoder.encode('data: [DONE]\n\n')); | |
| controller.close(); | |
| }, | |
| }); | |
| return sendSSE(nonStreamSSE); | |
| } | |
| // 非流式 JSON 响应 | |
| return sendJSON({ | |
| id: chatId, | |
| object: 'chat.completion', | |
| created: timestamp(), | |
| model: modelName, | |
| choices: [{ index: 0, message: msg, finish_reason: finishReason }], | |
| usage: { | |
| prompt_tokens: estimateTokens(prompt), | |
| completion_tokens: estimateTokens(text), | |
| total_tokens: estimateTokens(prompt + text), | |
| }, | |
| }); | |
| } catch (error) { | |
| log('Upstream error: ' + error.message, 'ERROR', config); | |
| return sendJSON({ error: { message: 'upstream error: ' + error.message } }, 502); | |
| } | |
| } | |
| // ================================================================ | |
| // 🔑 流式打字机响应(实时转发 Gemini 增量数据) | |
| // ================================================================ | |
| var streamEncoder = new TextEncoder(); | |
| var streamBody = new ReadableStream({ | |
| start: function (controller) { | |
| // ---- 状态管理 ---- | |
| var heartbeatTimer = null; // 心跳定时器 | |
| var isFinished = false; // 流是否已结束 | |
| /** | |
| * 清理心跳定时器 | |
| */ | |
| var clearHeartbeat = function () { | |
| if (heartbeatTimer) { | |
| clearInterval(heartbeatTimer); | |
| heartbeatTimer = null; | |
| } | |
| }; | |
| /** | |
| * 安全结束流 | |
| * 确保发送结束块和 [DONE] 标记 | |
| * @param {string} reason - 结束原因 (stop/error) | |
| */ | |
| var finishStream = function (reason) { | |
| // 防止重复结束 | |
| if (isFinished) return; | |
| clearHeartbeat(); | |
| isFinished = true; | |
| try { | |
| // 发送符合 OpenAI 标准的结束块 | |
| // delta.content 必须为 "" 而非空对象 {} | |
| controller.enqueue(streamEncoder.encode('data: ' + JSON.stringify({ | |
| id: chatId, | |
| object: 'chat.completion.chunk', | |
| created: timestamp(), | |
| model: modelName, | |
| choices: [{ | |
| index: 0, | |
| delta: { content: "" }, | |
| finish_reason: reason || 'stop' | |
| }], | |
| }) + '\n\n')); | |
| // 发送 [DONE] 标记 | |
| controller.enqueue(streamEncoder.encode('data: [DONE]\n\n')); | |
| controller.close(); | |
| } catch (e) { | |
| log('Failed to finish stream: ' + e.message, 'ERROR', config); | |
| } | |
| }; | |
| // 使用异步立即执行函数(IIFE)处理流式逻辑 | |
| (async function () { | |
| try { | |
| // ---- 1. 发送 role 声明块 ---- | |
| // 符合 OpenAI 标准:首块只包含 role,不含 content | |
| controller.enqueue(streamEncoder.encode('data: ' + JSON.stringify({ | |
| id: chatId, | |
| object: 'chat.completion.chunk', | |
| created: timestamp(), | |
| model: modelName, | |
| choices: [{ | |
| index: 0, | |
| delta: { role: 'assistant' }, | |
| finish_reason: null | |
| }], | |
| }) + '\n\n')); | |
| // ---- 2. 启动心跳定时器 ---- | |
| // 每 2 秒发送一次心跳注释,防止连接超时 | |
| // SSE 注释格式:以冒号开头,客户端会忽略 | |
| heartbeatTimer = setInterval(function () { | |
| if (!isFinished) { | |
| try { | |
| controller.enqueue(streamEncoder.encode(': heartbeat\n\n')); | |
| } catch (e) { | |
| clearHeartbeat(); // 写入失败,停止心跳 | |
| } | |
| } else { | |
| clearHeartbeat(); | |
| } | |
| }, 2000); | |
| // ---- 3. 构建并发送 Gemini 请求 ---- | |
| var reqBody = buildPayload(prompt, modelId, thinkMode, config); | |
| var headers = await buildHeaders(config); | |
| var url = buildUrl(config); | |
| // 创建独立的 AbortController 用于超时控制 | |
| var fetchController = new AbortController(); | |
| var fetchTimeout = setTimeout(function () { | |
| fetchController.abort(); | |
| }, (config.requestTimeoutSec - 2) * 1000); | |
| try { | |
| // 发送请求到 Gemini | |
| var response = await fetch(url, { | |
| method: 'POST', | |
| headers: headers, | |
| body: reqBody, | |
| signal: fetchController.signal, | |
| }); | |
| clearTimeout(fetchTimeout); | |
| // 检查响应状态 | |
| if (!response.ok) { | |
| var errorText = ''; | |
| try { | |
| errorText = await response.text(); | |
| } catch (e) { | |
| errorText = '无法读取错误信息'; | |
| } | |
| throw new Error('HTTP ' + response.status + ': ' + errorText.substring(0, 200)); | |
| } | |
| // ---- 4. 读取流式响应并实时转发增量数据 ---- | |
| var reader = response.body.getReader(); | |
| var decoder = new TextDecoder(); | |
| var buffer = ''; // 行缓冲 | |
| var prevText = ''; // 记录之前的完整文本,用于计算增量 | |
| while (true) { | |
| var readResult = await reader.read(); | |
| if (readResult.done) break; // 流结束 | |
| // 解码新数据并追加到缓冲区 | |
| buffer += decoder.decode(readResult.value, { stream: true }); | |
| // 检查 Gemini 错误信息 | |
| if (buffer.indexOf('BardErrorInfo') !== -1) { | |
| var match = buffer.match(/BardErrorInfo\s*\[(\d+)\]/); | |
| if (match) { | |
| throw new Error('Gemini upstream rejected request: BardErrorInfo [' + match[1] + ']'); | |
| } | |
| } | |
| // 按行分割处理 | |
| var lines = buffer.split('\n'); | |
| buffer = lines.pop() || ''; // 保留不完整的最后一行 | |
| // 遍历每一行 | |
| for (var li = 0; li < lines.length; li++) { | |
| var line = lines[li]; | |
| // 跳过不包含数据标记的行 | |
| if (line.indexOf('"wrb.fr"') === -1 || line.length < 200) continue; | |
| try { | |
| // 解析 Gemini 的嵌套 JSON 响应 | |
| var arr = JSON.parse(line); | |
| var innerStr = arr[0][2]; | |
| if (!innerStr || innerStr.length < 50) continue; | |
| var inner2 = JSON.parse(innerStr); | |
| // 提取文本内容 | |
| if (Array.isArray(inner2) && inner2.length > 4 && inner2[4]) { | |
| var parts = inner2[4]; | |
| for (var pi = 0; pi < parts.length; pi++) { | |
| var part = parts[pi]; | |
| if (Array.isArray(part) && part.length > 1 && part[1] && Array.isArray(part[1])) { | |
| var textItems = part[1]; | |
| for (var ti = 0; ti < textItems.length; ti++) { | |
| var t = textItems[ti]; | |
| // 检查是否有新内容(文本长度增加了) | |
| if (typeof t === 'string' && t.length > prevText.length) { | |
| // 🔑 计算增量文本(新内容 = 当前全量 - 之前全量) | |
| var delta = t.slice(prevText.length); | |
| // 清理代码执行痕迹 | |
| var cleaned = cleanGeminiText(delta, false); | |
| if (cleaned) { | |
| // 立即发送增量块,实现打字机效果 | |
| controller.enqueue(streamEncoder.encode('data: ' + JSON.stringify({ | |
| id: chatId, | |
| object: 'chat.completion.chunk', | |
| created: timestamp(), | |
| model: modelName, | |
| choices: [{ | |
| index: 0, | |
| delta: { content: cleaned }, | |
| finish_reason: null | |
| }], | |
| }) + '\n\n')); | |
| } | |
| // 更新已发送的文本记录 | |
| prevText = t; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| // JSON 解析错误,继续处理下一行 | |
| } | |
| } | |
| } | |
| } finally { | |
| // 确保清除超时定时器 | |
| clearTimeout(fetchTimeout); | |
| } | |
| // ---- 5. 正常结束流 ---- | |
| finishStream('stop'); | |
| } catch (error) { | |
| // 错误处理:记录错误并尝试通知客户端 | |
| log('Stream error: ' + error.message, 'ERROR', config); | |
| try { | |
| if (!isFinished) { | |
| controller.enqueue(streamEncoder.encode('data: ' + JSON.stringify({ | |
| error: { message: error.message, type: 'upstream_error' } | |
| }) + '\n\n')); | |
| } | |
| } catch (e) { | |
| // 发送错误失败,忽略 | |
| } | |
| finishStream('error'); | |
| } | |
| })(); // 立即执行异步函数 | |
| }, | |
| /** | |
| * 客户端断开连接时的回调 | |
| * 清理资源 | |
| */ | |
| cancel: function () { | |
| log('Client disconnected from stream', 'INFO', config); | |
| }, | |
| }); | |
| return sendSSE(streamBody); | |
| } | |
| /** | |
| * 处理 /v1/responses (OpenAI Responses API) | |
| * | |
| * 用于 Codex CLI 等工具的兼容。 | |
| * | |
| * @param {Request} request - HTTP 请求对象 | |
| * @param {Object} body - 解析后的请求体 | |
| * @param {Object} config - 请求级配置对象 | |
| * @returns {Promise<Response>} HTTP 响应对象 | |
| */ | |
| async function handleResponses(request, body, config) { | |
| var resolved = resolveModel(body.model || config.defaultModel); | |
| if (resolved.error) { | |
| return sendJSON({ error: { message: resolved.error } }, 400); | |
| } | |
| var modelName = resolved.modelName; | |
| var modelId = resolved.modelId; | |
| var thinkMode = resolved.thinkMode; | |
| var messages = []; | |
| // 添加系统指令 | |
| if (body.instructions) { | |
| messages.push({ role: 'system', content: body.instructions }); | |
| } | |
| // 处理输入项 | |
| var inputs = body.input || []; | |
| if (typeof inputs === 'string') { | |
| inputs = [inputs]; | |
| } | |
| for (var i = 0; i < inputs.length; i++) { | |
| var item = inputs[i]; | |
| if (typeof item === 'string') { | |
| messages.push({ role: 'user', content: item }); | |
| } else if (item.type === 'function_call_output') { | |
| messages.push({ | |
| role: 'tool', | |
| tool_call_id: item.call_id, | |
| name: item.name, | |
| content: item.output, | |
| }); | |
| } else { | |
| var content = item.content; | |
| if (Array.isArray(content)) { | |
| var textParts = []; | |
| for (var j = 0; j < content.length; j++) { | |
| var c = content[j]; | |
| if (c.type === 'output_text') textParts.push(c.text || ''); | |
| } | |
| content = textParts.join(' '); | |
| } | |
| messages.push({ role: item.role || 'user', content: content }); | |
| } | |
| } | |
| // 标准化工具定义 | |
| var tools = body.tools; | |
| if (tools) { | |
| var normalizedTools = []; | |
| for (var ti = 0; ti < tools.length; ti++) { | |
| var t = tools[ti]; | |
| if (t.type === 'function' && !t.function) { | |
| normalizedTools.push({ | |
| type: 'function', | |
| function: { name: t.name, description: t.description || '', parameters: t.parameters || {} }, | |
| }); | |
| } else { | |
| normalizedTools.push(t); | |
| } | |
| } | |
| tools = normalizedTools; | |
| } | |
| var prompt = messagesToPrompt(messages, tools); | |
| if (!prompt.trim()) { | |
| return sendJSON({ error: { message: 'empty input' } }, 400); | |
| } | |
| try { | |
| var raw = await geminiStreamGenerate(prompt, modelId, thinkMode, config); | |
| var text = extractResponseText(raw); | |
| var toolCalls = null; | |
| if (tools && text) { | |
| var parsed = parseToolCalls(text); | |
| text = parsed.cleanText; | |
| toolCalls = parsed.toolCalls.length > 0 ? parsed.toolCalls : null; | |
| } | |
| var responseId = 'resp_' + generateShortId(16); | |
| var messageId = 'msg_' + generateShortId(12); | |
| var output = []; | |
| if (toolCalls) { | |
| for (var tci = 0; tci < toolCalls.length; tci++) { | |
| var tc = toolCalls[tci]; | |
| output.push({ | |
| type: 'function_call', | |
| id: tc.id, | |
| call_id: tc.id, | |
| name: tc.function.name, | |
| arguments: tc.function.arguments, | |
| status: 'completed', | |
| }); | |
| } | |
| } | |
| if (text || !toolCalls) { | |
| output.push({ | |
| type: 'message', | |
| id: messageId, | |
| role: 'assistant', | |
| status: 'completed', | |
| content: [{ type: 'output_text', text: text || '', annotations: [] }], | |
| }); | |
| } | |
| return sendJSON({ | |
| id: responseId, | |
| object: 'response', | |
| created_at: timestamp(), | |
| status: 'completed', | |
| model: modelName, | |
| output: output, | |
| usage: { | |
| input_tokens: estimateTokens(prompt), | |
| output_tokens: estimateTokens(text), | |
| total_tokens: estimateTokens(prompt + text), | |
| }, | |
| }); | |
| } catch (error) { | |
| return sendJSON({ error: { message: 'upstream error: ' + error.message } }, 502); | |
| } | |
| } | |
| /** | |
| * 处理 Google 原生 API(Gemini CLI 兼容) | |
| * | |
| * 支持 Google Gemini CLI 的原生格式。 | |
| * | |
| * @param {Request} request - HTTP 请求对象 | |
| * @param {Object} body - 解析后的请求体 | |
| * @param {boolean} stream - 是否使用流式传输 | |
| * @param {Object} config - 请求级配置对象 | |
| * @returns {Promise<Response>} HTTP 响应对象 | |
| */ | |
| async function handleGoogleAPI(request, body, stream, config) { | |
| var requestUrl = new URL(request.url); | |
| var match = requestUrl.pathname.match(/\/v1beta\/models\/([^:]+)/); | |
| var modelName = match ? match[1] : null; | |
| if (!modelName) { | |
| return sendJSON({ error: { message: 'model not specified in path' } }, 400); | |
| } | |
| var resolved = resolveModel(modelName); | |
| if (resolved.error) { | |
| return sendJSON({ error: { message: resolved.error } }, 400); | |
| } | |
| var modelId = resolved.modelId; | |
| var thinkMode = resolved.thinkMode; | |
| var prompt = googleContentsToPrompt(body); | |
| if (!prompt.trim()) { | |
| return sendJSON({ error: { message: 'empty content' } }, 400); | |
| } | |
| try { | |
| var raw = await geminiStreamGenerate(prompt, modelId, thinkMode, config); | |
| var text = extractResponseText(raw); | |
| var response = { | |
| candidates: [{ | |
| content: { parts: [{ text: text || '' }], role: 'model' }, | |
| finishReason: 'STOP', | |
| index: 0, | |
| }], | |
| usageMetadata: { | |
| promptTokenCount: estimateTokens(prompt), | |
| candidatesTokenCount: estimateTokens(text), | |
| totalTokenCount: estimateTokens(prompt + text), | |
| }, | |
| modelVersion: modelName, | |
| }; | |
| if (stream) { | |
| return new Response('data: ' + JSON.stringify(response) + '\n\n', { | |
| headers: { | |
| 'Content-Type': 'text/event-stream', | |
| 'Cache-Control': 'no-cache', | |
| 'Access-Control-Allow-Origin': '*', | |
| }, | |
| }); | |
| } | |
| return sendJSON(response); | |
| } catch (error) { | |
| return sendJSON({ error: { message: 'upstream error: ' + error.message } }, 502); | |
| } | |
| } | |
| // ============================================================================ | |
| // 🚀 主入口 | |
| // ============================================================================ | |
| export default { | |
| /** | |
| * Cloudflare Workers 的 fetch 事件处理器 | |
| * | |
| * 这是整个 Worker 的入口函数,所有 HTTP 请求都会经过这里。 | |
| * 每个请求在独立的 Isolate 中运行(冷启动), | |
| * 或者复用已有 Isolate(热启动)。 | |
| * | |
| * 处理流程: | |
| * 1. OPTIONS 预检 -> 返回 CORS 头 | |
| * 2. 创建请求级配置 -> getRequestConfig(env) | |
| * 3. 速率限制检查 -> checkRateLimit() | |
| * 4. API 密钥验证 -> checkApiKey() | |
| * 5. 路由分发: | |
| * GET /health -> 健康检查 | |
| * GET /v1/models -> 模型列表 | |
| * POST /v1/chat/completions -> 聊天补全 | |
| * POST /v1/responses -> Responses API | |
| * POST ...:generateContent -> Google 原生 API | |
| * POST /v1/* -> 万能兜底 | |
| * | |
| * @param {Request} request - HTTP 请求对象 | |
| * @param {Object} env - 环境变量(每个请求独立) | |
| * @param {Object} ctx - 执行上下文 | |
| * @returns {Promise<Response>} HTTP 响应对象 | |
| */ | |
| async fetch(request, env, ctx) { | |
| // ================================================================ | |
| // 1. OPTIONS CORS 预检请求优先处理 | |
| // ================================================================ | |
| // 浏览器在发送跨域 POST 请求前会先发送 OPTIONS 预检请求 | |
| // 必须返回正确的 CORS 头,否则浏览器会阻止实际请求 | |
| // 必须在所有其他逻辑之前处理 | |
| if (request.method === 'OPTIONS') { | |
| return new Response(null, { | |
| status: 204, // No Content | |
| headers: { | |
| 'Access-Control-Allow-Origin': '*', // 允许所有域 | |
| 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', // 允许的方法 | |
| 'Access-Control-Allow-Headers': '*', // 允许所有请求头 | |
| 'Access-Control-Max-Age': '86400', // 预检结果缓存 24 小时 | |
| }, | |
| }); | |
| } | |
| // ================================================================ | |
| // 2. 🔑 为当前请求创建独立的配置副本(解决并发串扰的核心) | |
| // ================================================================ | |
| // 不修改任何全局变量,每个请求都有自己专属的 config 对象 | |
| // env 参数是 Cloudflare 为每个请求独立提供的环境变量 | |
| var config = getRequestConfig(env); | |
| // 解析请求 URL | |
| var requestUrl = new URL(request.url); | |
| var path = requestUrl.pathname; | |
| var method = request.method; | |
| // ================================================================ | |
| // 3. 速率限制检查 | |
| // ================================================================ | |
| var clientIP = request.headers.get('CF-Connecting-IP') || '0.0.0.0'; | |
| if (!checkRateLimit(clientIP, config)) { | |
| log('Rate limit: ' + clientIP, 'WARN', config); | |
| return sendJSON({ | |
| error: { | |
| message: '请求过于频繁,请稍后再试', | |
| type: 'rate_limit_exceeded', | |
| }, | |
| }, 429); // 429 Too Many Requests | |
| } | |
| // ================================================================ | |
| // 4. API 密钥验证(仅对 /v1 路径生效) | |
| // ================================================================ | |
| if (path.indexOf('/v1') === 0 && !checkApiKey(request, config)) { | |
| return sendJSON({ | |
| error: { message: 'invalid api key' }, | |
| }, 401); // 401 Unauthorized | |
| } | |
| // ================================================================ | |
| // 5. GET 请求处理 | |
| // ================================================================ | |
| if (method === 'GET') { | |
| // ---- 健康检查端点 ---- | |
| if (path === '/' || path === '/health') { | |
| return sendJSON({ | |
| status: 'ok', | |
| version: '1.3.0-cf-threadsafe', | |
| platform: 'Cloudflare Workers', | |
| models: Object.keys(MODELS), | |
| defaultModel: config.defaultModel, | |
| hasCookie: !!config.cookieString, | |
| hasSapisid: !!config.sapisid, | |
| rateLimit: config.rateLimit, | |
| }); | |
| } | |
| // ---- OpenAI 格式模型列表 ---- | |
| if (path === '/v1/models') { | |
| var modelList = []; | |
| var modelKeys = Object.keys(MODELS); | |
| for (var i = 0; i < modelKeys.length; i++) { | |
| var id = modelKeys[i]; | |
| var cfg = MODELS[id]; | |
| modelList.push({ | |
| id: id, | |
| object: 'model', | |
| created: 1700000000, | |
| owned_by: 'google', | |
| description: cfg.desc, | |
| }); | |
| } | |
| return sendJSON({ object: 'list', data: modelList }); | |
| } | |
| // ---- Google 原生格式模型列表 ---- | |
| if (path === '/v1beta/models') { | |
| var googleModels = []; | |
| var gKeys = Object.keys(MODELS); | |
| for (var j = 0; j < gKeys.length; j++) { | |
| var name = gKeys[j]; | |
| var gCfg = MODELS[name]; | |
| googleModels.push({ | |
| name: 'models/' + name, | |
| displayName: name, | |
| description: gCfg.desc, | |
| supportedGenerationMethods: ['generateContent', 'streamGenerateContent'], | |
| }); | |
| } | |
| return sendJSON({ models: googleModels }); | |
| } | |
| // 未匹配的 GET 请求 | |
| return sendJSON({ error: { message: 'not found' } }, 404); | |
| } | |
| // ================================================================ | |
| // 6. POST 请求处理 | |
| // ================================================================ | |
| if (method === 'POST') { | |
| var body; | |
| try { | |
| body = await request.json(); | |
| } catch (e) { | |
| return sendJSON({ error: { message: 'invalid JSON' } }, 400); | |
| } | |
| // ---- OpenAI 聊天补全 ---- | |
| if (path === '/v1/chat/completions') { | |
| return handleChatCompletions(request, body, config); | |
| } | |
| // ---- OpenAI Responses API (Codex CLI) ---- | |
| if (path === '/v1/responses') { | |
| return handleResponses(request, body, config); | |
| } | |
| // ---- Google 原生 generateContent ---- | |
| if (path.indexOf(':generateContent') !== -1 && path.indexOf('stream') === -1) { | |
| return handleGoogleAPI(request, body, false, config); | |
| } | |
| // ---- Google 原生 streamGenerateContent ---- | |
| if (path.indexOf(':streamGenerateContent') !== -1) { | |
| return handleGoogleAPI(request, body, true, config); | |
| } | |
| // ---- 万能兜底:所有 /v1/ 下的 POST 都转为 chat 处理 ---- | |
| // 兼容 NextChat 等客户端可能发送的不同路径 | |
| if (path.indexOf('/v1/') === 0) { | |
| return handleChatCompletions(request, body, config); | |
| } | |
| return sendJSON({ error: { message: 'not found' } }, 404); | |
| } | |
| // ================================================================ | |
| // 7. 未支持的 HTTP 方法 | |
| // ================================================================ | |
| return sendJSON({ error: { message: 'method not allowed' } }, 405); | |
| }, | |
| }; |
Xet Storage Details
- Size:
- 81.2 kB
- Xet hash:
- 5a915776d790283c0302981bba5e59a3beb50b797be07b51a09d0bbf11e68568
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.