diff --git a/.dockerignore b/.dockerignore index 5b921cd9..584c09d2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,11 @@ # VCS .git **/.git +.github + +# Test suites (not needed for the image build) +tests +**/tests # Editor .vscode diff --git a/.env.example b/.env.example index 6ed8f81e..0e77c7a8 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,9 @@ NEXT_PUBLIC_CLOUD_URL=https://9router.com # Currently unused by application runtime (kept as reference) # INSTANCE_NAME=9router + +# Proxy egress geo probe (Proxy Fitness "Geo probe" toggle makes this a runtime +# setting too). These env vars are the hard override / advanced knobs for the +# background scheduler that samples each pool's egress IP+country. +# POOL_GEO_PROBE_DISABLED=1 # 1 = never run the geo probe +# GEO_PROBE_URL= # optional single custom endpoint (replaces the built-in 4-endpoint chain) diff --git a/.github/workflows/gitbook-pages.yml b/.github/workflows/gitbook-pages.yml.disabled similarity index 100% rename from .github/workflows/gitbook-pages.yml rename to .github/workflows/gitbook-pages.yml.disabled diff --git a/.gitignore b/.gitignore index e17d69b7..ef897ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ product # misc .DS_Store *.pem +.vscode # debug npm-debug.log* diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..40aef747 --- /dev/null +++ b/.npmrc @@ -0,0 +1,17 @@ +# MIBP fork — npm behavior pin. +# +# DO NOT DELETE. See AGENTS.md §1. +# +# The Docker image (node:22-alpine, pinned by digest) ships npm 10.9.8 and runs +# `npm ci` against the committed package-lock.json. Regenerating the lockfile +# with npm 11+ drops the top-level @emnapi/core + @emnapi/runtime entries npm 10 +# needs, which breaks the tag-triggered Docker build ("Build and Push Docker +# Image") at `npm ci`. +# +# Regenerate the lockfile with npm 10 only: +# npx -y npm@10.9.8 install --package-lock-only +# node scripts/verify-lockfile-npm10.mjs +# +# Keep install output quiet; never let audit/fund noise hide a lockfile error. +audit=false +fund=false diff --git a/CLAUDE.md b/CLAUDE.md index d7c21345..0121a47c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +> ⚠️ **MIBP fork — read [`AGENTS.md`](AGENTS.md) first.** It lists fixes that are +> easy to silently delete or reintroduce (npm-10 lockfile rule, test DB +> isolation, hidden-provider filtering, codebuddy-intl behavior, fork-only +> features that must survive upstream syncs). Do not remove anything listed +> there without explicit confirmation. + ## What this is 9Router (`9router-app`) — a local AI routing gateway + Next.js dashboard. It exposes one OpenAI-compatible endpoint (`/v1/*`) and routes traffic across 40+ upstream providers with format translation, model-combo fallback, multi-account fallback, OAuth/API-key credential management, token refresh, quota/usage tracking, and optional cloud sync. diff --git a/README.zh-CN.md b/README.zh-CN.md deleted file mode 100644 index 1b2bedd2..00000000 --- a/README.zh-CN.md +++ /dev/null @@ -1,1315 +0,0 @@ - -
- 9Router Dashboard - - # 9Router - 免费 AI 路由器与 Token 节省器 - - **编程永不停歇。使用 RTK + 自动切换到免费/低价 AI 模型,节省 20-40% 的 tokens。** - - **将所有 AI 编程工具(Claude Code、Cursor、Antigravity、Copilot、Codex、Gemini、OpenCode、Cline、OpenClaw...)连接到 40+ AI 提供商和 100+ 模型。** - - [![npm](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router) - [![Downloads](https://img.shields.io/npm/dm/9router.svg)](https://www.npmjs.com/package/9router) - [![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/decolua/9router/blob/main/LICENSE) - - decolua%2F9router | Trendshift - - [🚀 快速开始](#-快速开始) • [💡 功能特点](#-主要功能) • [📖 设置指南](#-设置指南) • [🌐 网站](https://9router.com) - - [🇻🇳 Tiếng Việt](./i18n/README.vi.md) • [🇨🇳 中文](./i18n/README.zh-CN.md) • [🇯🇵 日本語](./i18n/README.ja-JP.md) -
- ---- - -## 🤔 为什么选择 9Router? - -**告别浪费金钱、tokens 和触碰限制的困扰:** - -- ❌ 订阅配额每月到期却未使用 -- ❌ 速率限制在编程中途打断你 -- ❌ 工具输出(git diff、grep、ls...)快速消耗 tokens -- ❌ 昂贵的 API(每个提供商 $20-50/月) -- ❌ 需要手动在提供商之间切换 - -**9Router 解决这一切:** - -- ✅ **RTK Token 节省器** - 自动压缩 tool_result 内容,每次请求节省 20-40% tokens -- ✅ **充分利用订阅** - 追踪配额,在重置前用尽每一分额度 -- ✅ **自动切换** - 订阅 → 低价 → 免费,零停机时间 -- ✅ **多账户支持** - 按提供商在账户之间轮询 -- ✅ **通用兼容** - 支持 Claude Code、Codex、Cursor、Cline 以及任何 CLI 工具 - ---- - -## 🔄 工作原理 - -``` -┌─────────────┐ -│ 你的 CLI │ (Claude Code、Codex、OpenClaw、Cursor、Cline...) -│ 工具 │ -└──────┬──────┘ - │ http://localhost:20128/v1 - ↓ -┌─────────────────────────────────────────────┐ -│ 9Router(智能路由器) │ -│ • RTK Token 节省器(减少 tool_result tokens)│ -│ • 格式转换(OpenAI ↔ Claude) │ -│ • 配额追踪 │ -│ • 自动刷新 token │ -└──────┬──────────────────────────────────────┘ - │ - ├─→ [第一层:订阅] Claude Code、Codex、GitHub Copilot - │ ↓ 配额耗尽 - ├─→ [第二层:低价] GLM ($0.6/1M)、MiniMax ($0.2/1M) - │ ↓ 预算超限 - └─→ [第三层:免费] Kiro、OpenCode Free、Vertex ($300 额度) - -结果:编程永不停歇,最小成本 + 通过 RTK 节省 20-40% tokens -``` - ---- - -## ⚡ 快速开始 - -**1. 全局安装:** - -```bash -npm install -g 9router -9router -``` - -🎉 控制面板在 `http://localhost:20128` 打开 - -**2. 连接免费提供商(无需注册):** - -控制面板 → 提供商 → 连接 **Kiro AI**(约 50 积分/月免费:Claude 4.5 + GLM-5 + MiniMax)或 **OpenCode Free**(无需认证)→ 完成! - -**3. 在 CLI 工具中使用:** - -``` -Claude Code/Codex/OpenClaw/Cursor/Cline 设置: - Endpoint: http://localhost:20128/v1 - API Key: [从控制面板复制] - Model: kr/claude-sonnet-4.5 -``` - -**就这么简单!** 开始使用免费 AI 模型编程。 - -**替代方案:从源码运行(本仓库):** - -本仓库的包是私有的(`9router-app`),所以源码/Docker 执行是预期的本地开发方式。 - -```bash -cp .env.example .env -npm install -PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev -``` - -生产模式: - -```bash -npm run build -PORT=20128 HOSTNAME=0.0.0.0 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run start -``` - -默认 URL: -- 控制面板:`http://localhost:20128/dashboard` -- OpenAI 兼容 API:`http://localhost:20128/v1` - ---- - -## 视频教程 - -
- - - - - - - - - - - - -
- - 9Router Setup Tutorial -
- 🇺🇸 English
- 9Router + Claude Code 免费设置
by Build AI With Hamid
-
- - Tiết kiệm chi phí LLM với 9Router -
- 🇻🇳 Tiếng Việt
- 使用 9Router 节省 OpenClaw 的 LLM 成本
by Mì AI
-
- - Claude Code FREE Forever -
- 🇺🇸 English
- Claude Code 免费永久使用 — 无限模型
by Build AI With Hamid
-
- - Claude CLI Free Setup -
- 🇺🇸 English
- 使用 9Router 免费设置 Claude CLI 🚀
by CodeVerse Soban
-
- - Cài đặt OpenClaw Free A-Z -
- 🇻🇳 Tiếng Việt
- 从零开始安装 OpenClaw 免费版 + 9Router
by Mai Gia
-
- - FREE OpenClaw with Claude Opus -
- 🇺🇸 English
- 免费 OpenClaw + Claude Opus 4.6
by Build AI With Hamid
-
- -
- -> 🎬 **制作了关于 9Router 的视频?** 提交 [Pull Request](https://github.com/decolua/9router/pulls),将你的视频添加到此部分 — 我们会合并它! - ---- - -## 🛠️ 支持的 CLI 工具 - -9Router 与所有主流 AI 编程工具无缝协作: - -
- - - - - - - - - - - - - - - - - -
- Claude Code
- Claude-Code -
- OpenClaw
- OpenClaw -
- Codex
- Codex -
- OpenCode
- OpenCode -
- Cursor
- Cursor -
- Antigravity
- Antigravity -
- Cline
- Cline -
- Continue
- Continue -
- Droid
- Droid -
- Roo
- Roo -
- Copilot
- Copilot -
- Kilo Code
- Kilo Code -
-
- ---- - -## 🌐 支持的提供商 - -### 🔐 OAuth 提供商 - -
- - - - - - - - -
- Claude Code
- Claude-Code -
- Antigravity
- Antigravity -
- Codex
- Codex -
- GitHub
- GitHub -
- Cursor
- Cursor -
-
- -### 🆓 免费提供商 - -
- - - - - - -
- Kiro
- Kiro AI
- Claude 4.5 + GLM-5 + MiniMax
每月 50 积分免费
-
- OpenCode Free
- OpenCode Free
- 无需认证 • 自动获取模型
免费(模型列表会变)
-
- Vertex AI
- Vertex AI
- Gemini 3 Pro + GLM-5 + DeepSeek
$300 免费额度
-
-
- -> **注意:** iFlow、Qwen Code 和 Gemini CLI 的免费等级已于 2026 年停止。请改用 Kiro / OpenCode Free / Vertex。 -> -> **Kiro AI** 于 2025 年 9 月转为付费模式 — 免费等级现在上限为**每月 50 积分**(新账户前 30 天另加 500 试用积分)。付费档位:Pro $20/月(1,000 积分)、Pro+ $40/月(2,000)、Pro Max $100/月(5,000)、Power $200/月(10,000)。 -> **OpenCode Free** 的模型列表会随时间变化(部分模型仅限时免费)— 可能随时变更,恕不另行通知。 -> **Vertex AI**:新 GCP 账户的 $300 免费额度仍然有效,但自 2026 年 3 月起 **Gemini API 端点不再消耗这些额度** — 请改用 **Vertex AI Studio** 端点。 - -### 🔑 API Key 提供商(40+) - -
- - - - - - - - - - - - - - - - - - - - - - - - - -
- OpenRouter
- OpenRouter -
- GLM
- GLM -
- Kimi
- Kimi -
- MiniMax
- MiniMax -
- OpenAI
- OpenAI -
- Anthropic
- Anthropic -
- Gemini
- Gemini -
- DeepSeek
- DeepSeek -
- Groq
- Groq -
- xAI
- xAI -
- Mistral
- Mistral -
- Perplexity
- Perplexity -
- Together
- Together AI -
- Fireworks
- Fireworks -
- Cerebras
- Cerebras -
- Cohere
- Cohere -
- NVIDIA
- NVIDIA -
- SiliconFlow
- SiliconFlow -
-

...以及 20+ 更多提供商,包括 Nebius、Chutes、Hyperbolic 和自定义 OpenAI/Anthropic 兼容端点

-
- ---- - -## 💡 主要功能 - -| 功能 | 作用 | 为什么重要 | -|---------|--------------|----------------| -| 🚀 **RTK Token 节省器**([RTK](https://github.com/rtk-ai/rtk) ⭐40K) | 压缩工具输出(`git diff`、`grep`、`ls`、`tree`...)后再发送给 LLM | 每次请求节省 **20-40% 输入 tokens** | -| 🪨 **Caveman 模式**([Caveman](https://github.com/JuliusBrussee/caveman) ⭐52K) | 注入 caveman 风格提示词 → LLM 回复简洁,保留技术实质 | 节省 **高达 65% 输出 tokens** | -| 🎯 **智能三层切换** | 自动路由:订阅 → 低价 → 免费 | 编程永不停歇,零停机时间 | -| 📊 **实时配额追踪** | 实时 token 计数 + 重置倒计时 | 充分利用订阅价值 | -| 🔄 **格式转换** | OpenAI ↔ Claude ↔ Gemini ↔ Cursor ↔ Kiro ↔ Vertex | 兼容任何 CLI 工具 | -| 👥 **多账户支持** | 每个提供商支持多个账户 | 负载均衡 + 冗余备份 | -| 🔄 **自动 Token 刷新** | OAuth token 自动刷新 | 无需手动重新登录 | -| 🎨 **自定义组合** | 创建无限模型组合 | 自定义适合你的切换策略 | -| 📝 **请求日志** | 调试模式下的完整请求/响应日志 | 轻松排查问题 | -| 💾 **云同步** | 跨设备同步配置 | 处处相同设置 | -| 📊 **使用分析** | 追踪 tokens、成本、趋势 | 优化开支 | -| 🌐 **任意部署** | 本地、VPS、Docker、Cloudflare Workers | 灵活部署选项 | - -
-📖 功能详情 - -### 🚀 RTK Token 节省器 - -工具输出(`git diff`、`grep`、`find`、`ls`、`tree`、日志转储...)通常占用 30-50% 的提示词预算。RTK 在请求到达 LLM 之前检测并应用智能、无损压缩: - -- **过滤器:** `git-diff`、`git-status`、`grep`、`find`、`ls`、`tree`、`dedup-log`、`smart-truncate`、`read-numbered`、`search-list` -- **自动检测:** 无需配置 — RTK 检查每个 `tool_result` 的前 1KB,选择合适的过滤器。 -- **安全设计:** 如果过滤器失败、抛出异常或使输出变大,RTK 会静默保留原始文本。错误永远不会中断你的请求。 -- **通用兼容:** 适用于所有格式(OpenAI、Claude、Gemini、Cursor、Kiro、OpenAI Responses),因为它在任何格式转换**之前**运行。 -- **默认开启:** 可随时在控制面板 → 端点设置中切换。 - -``` -不使用 RTK:47K tokens 发送给 LLM -使用 RTK: 28K tokens 发送给 LLM (节省 40% · 相同上下文 · 相同答案) -``` - -### 🎯 智能三层切换 - -创建具有自动切换功能的组合: - -``` -组合:"my-coding-stack" - 1. cc/claude-opus-4-6 (你的订阅) - 2. glm/glm-4.7 (低价备份,$0.6/1M) - 3. if/kimi-k2-thinking (免费备选) - -→ 配额用完或出错时自动切换 -``` - -### 📊 实时配额追踪 - -- 每个提供商的 token 消耗 -- 重置倒计时(5小时、每日、每周) -- 付费等级的成本估算 -- 月度支出报告 - -### 🔄 格式转换 - -格式间无缝转换: -- **OpenAI** ↔ **Claude** ↔ **Gemini** ↔ **Cursor** ↔ **Kiro** ↔ **Vertex** ↔ **Antigravity** ↔ **Ollama** ↔ **OpenAI Responses** -- 你的 CLI 工具发送 OpenAI 格式 → 9Router 转换 → 提供商接收原生格式 -- 适用于任何支持自定义 OpenAI 端点的工具 - -### 👥 多账户支持 - -- 每个提供商添加多个账户 -- 自动轮询或基于优先级的路由 -- 当一个账户达到配额时切换到下一个 - -### 🔄 自动 Token 刷新 - -- OAuth token 在过期前自动刷新 -- 无需手动重新认证 -- 所有提供商的无缝体验 - -### 🎨 自定义组合 - -- 创建无限模型组合 -- 混合订阅、低价和免费等级 -- 为组合命名以便访问 -- 使用云同步跨设备共享组合 - -### 📝 请求日志 - -- 启用调试模式获取完整请求/响应日志 -- 追踪 API 调用、请求头和载荷 -- 排查集成问题 -- 导出日志进行分析 - -### 💾 云同步 - -- 跨设备同步提供商、组合和设置 -- 自动后台同步 -- 安全加密存储 -- 从任何地方访问你的设置 - -#### 云运行时说明 - -- 生产环境中优先使用服务端云变量: - - `BASE_URL`(云同步调度程序使用的内部回调 URL) - - `CLOUD_URL`(云同步端点基础 URL) -- `NEXT_PUBLIC_BASE_URL` 和 `NEXT_PUBLIC_CLOUD_URL` 仍用于兼容性/UI,但服务端运行时现在优先使用 `BASE_URL`/`CLOUD_URL`。 -- 云同步请求现在使用超时 + 快速失败行为,以避免云 DNS/网络不可用时 UI 挂起。 - -### 📊 使用分析 - -- 追踪每个提供商和模型的 token 使用量 -- 成本估算和支出趋势 -- 月度报告和洞察 -- 优化你的 AI 支出 - -> **💡 重要 - 了解控制面板成本:** -> -> 使用分析中显示的"成本"**仅用于追踪和比较目的**。 -> 9Router 本身**永远不会向你收费**。你只直接向提供商付款(如果使用付费服务)。 -> -> **示例:** 如果你的控制面板显示使用 Kiro 免费模型时"总成本 $290",这代表你如果直接使用付费 API 需要支付的金额。你的实际成本 = **$0**(Kiro 免费等级:约 50 积分/月)。 -> -> 把它想象成一个"节省追踪器",展示你通过使用免费模型或通过 9Router 路由节省了多少钱! - -### 🌐 任意部署 - -- 💻 **本地** - 默认,离线可用 -- ☁️ **VPS/云** - 跨设备共享 -- 🐳 **Docker** - 一键部署 -- 🚀 **Cloudflare Workers** - 全球边缘网络 - -
- ---- - -## 💰 价格一览 - -| 等级 | 提供商 | 成本 | 配额重置 | 适用场景 | -|------|----------|------|-------------|----------| -| **🚀 TOKEN 节省器** | **RTK(内置)** | **免费** | 始终开启 | **每次请求节省 20-40% tokens** | -| **💳 订阅** | Claude Code (Pro/Max) | $20-200/月 | 5小时 + 每周 | 已有订阅的用户 | -| | Codex (Plus/Pro) | $20-200/月 | 5小时 + 每周 | OpenAI 用户 | -| | GitHub Copilot | $10-19/月 | 每月 | GitHub 用户 | -| | Cursor IDE | $20/月 | 每月 | Cursor 用户 | -| **💰 低价** | GLM-5.1 / GLM-4.7 | $0.6/1M | 每日 10AM | 预算备份 | -| | MiniMax M2.7 | $0.2/1M | 5小时滚动 | 最便宜选项 | -| | Kimi K2.5 | $9/月固定 | 10M tokens/月 | 可预测成本 | - | **🆓 免费** | Kiro AI | $0 | 50 积分/月 | Claude 4.5 + GLM-5 + MiniMax 免费(之上为付费档位) | - | | OpenCode Free | $0 | varies* | 无需认证,自动获取模型(列表会变化) | - | | Vertex AI | $300 额度 | 新 GCP 账户 | Gemini 3 Pro + DeepSeek + GLM-5(使用 Vertex AI Studio 端点消耗免费额度) | - -**💡 专业提示:** RTK + Kiro AI + OpenCode Free 组合 = **$0 成本 + 节省 20-40% tokens**! - ---- - -### 📊 理解 9Router 成本与计费 - -**9Router 计费真相:** - -✅ **9Router 软件 = 永久免费**(开源,绝不收费) -✅ **控制面板"成本" = 仅用于显示/追踪**(不是实际账单) -✅ **你直接向提供商付款**(订阅或 API 费用) -✅ **免费提供商保持免费**(Kiro 约 50 积分/月、OpenCode Free、Vertex $300 额度 = 在免费额度内 $0)— 注意 iFlow/Qwen/Gemini CLI 免费等级已于 2026 年停止 -❌ **9Router 永不发送发票** 或扣款 - -**成本显示如何工作:** - -控制面板显示**估算成本**,如同你直接使用付费 API。这**不是计费** — 它是一个比较工具,展示你的节省。 - -**示例场景:** -``` -控制面板显示: -• 总请求数:1,662 -• 总 Tokens:47M -• 显示成本:$290 - -实际检查: -• 提供商:Kiro(免费等级:约 50 积分/月) -• 实际支付:$0.00 -• $290 意味着什么:通过使用免费模型节省的金额! -``` - -**付款规则:** -- **订阅提供商**(Claude Code、Codex):通过他们的网站直接付款 -- **低价提供商**(GLM、MiniMax):直接付款,9Router 只做路由 -- **免费提供商**(Kiro、OpenCode Free、Vertex):真正的免费,在免费额度内无隐藏费用 -- **9Router**:从不收取任何费用,永远不会 - ---- - -## 🎯 使用场景 - -### 场景 1:"我有 Claude Pro 订阅" - -**问题:** 配额到期未用完,繁忙编码时遇到速率限制 - -**解决方案:** -``` -组合:"maximize-claude" - 1. cc/claude-opus-4-7 (充分利用订阅) - 2. glm/glm-5.1 (配额用完时的低价备份) - 3. kr/claude-sonnet-4.5 (免费紧急备选) - -月成本:$20(订阅)+ ~$5(备份)= $25 总计 -对比:$20 + 遇到限制 = 沮丧 -``` - -### 场景 2:"我想零成本" - -**问题:** 负担不起订阅,需要可靠的 AI 编码 - -**解决方案:** -``` -组合:"free-forever" - 1. kr/claude-sonnet-4.5 (通过 Kiro 免费使用 Claude 4.5,约 50 积分/月) - 2. kr/glm-5 (通过 Kiro 免费使用 GLM-5) - 3. oc/ (OpenCode Free,无需认证) - -月成本:$0 -质量:生产级模型 + RTK 节省 20-40% tokens -``` - -### 场景 3:"我需要 24/7 编码,不中断" - -**问题:** 截止日期紧迫,不能承受停机 - -**解决方案:** -``` -组合:"always-on" - 1. cc/claude-opus-4-7 (最佳质量) - 2. cx/gpt-5.5 (第二个订阅) - 3. glm/glm-5.1 (低价,每日重置) - 4. minimax/MiniMax-M2.7 (最便宜,5小时重置) - 5. kr/claude-sonnet-4.5 (通过 Kiro 免费使用,约 50 积分/月) - -结果:5 层切换 = 零停机时间 -月成本:$20-200(订阅)+ $10-20(备份) -``` - -### 场景 4:"我想在 OpenClaw 中使用免费 AI" - -**问题:** 需要在消息应用(WhatsApp、Telegram、Slack...)中使用 AI 助手,完全免费 - -**解决方案:** -``` -组合:"openclaw-free" - 1. kr/claude-sonnet-4.5 (Claude 4.5 免费) - 2. kr/glm-5 (GLM-5 免费) - 3. kr/MiniMax-M2.5 (MiniMax 免费) - -月成本:$0 -访问方式:WhatsApp、Telegram、Slack、Discord、iMessage、Signal... -``` - ---- - -## ❓ 常见问题 - -
-📊 为什么我的控制面板显示高成本? - -控制面板追踪你的 token 使用情况,并显示**估算成本**,如同你直接使用付费 API。这**不是实际计费** — 它是一个参考,展示你通过使用免费模型或通过 9Router 路由现有订阅节省了多少钱。 - -**示例:** -- **控制面板显示:** "$290 总成本" -- **实际情况:** 你在使用 Kiro 免费模型(约 50 积分/月) -- **你的实际成本:** **$0.00** -- **$290 的含义:** 你通过使用免费模型而不是付费 API **节省**的金额! - -成本显示是一个"节省追踪器",帮助你了解使用模式和优化机会。 - -
- -
-💳 9Router 会扣我的钱吗? - -**不会。** 9Router 是在你自己的电脑上运行的开源软件。它永远不会向你收取任何费用。 - -**你只需支付:** -- ✅ **订阅提供商**(Claude Code $20/月、Codex $20-200/月)→ 在他们的网站上直接付款 -- ✅ **低价提供商**(GLM、MiniMax)→ 直接付款,9Router 只是路由你的请求 -- ❌ **9Router 本身** → **永不收费,永远不会** - -9Router 是一个本地代理/路由器。它没有你的信用卡,不能发送发票,也没有计费系统。它是完全免费的软件。 - -
- -
-🆓 免费提供商真的是无限量的吗? - -**基本上是!** 当前的免费提供商(Kiro、OpenCode Free、Vertex)是真正的免费,但免费等级有上限: - -这些是各公司提供的免费服务: -- **Kiro AI**:通过 AWS Builder ID / Google / GitHub OAuth 使用,免费等级约**每月 50 积分**(新账户前 30 天另加 500 试用积分)。之上提供付费档位。 -- **OpenCode Free**:无认证直连代理,模型从 `opencode.ai/zen/v1/models` 自动获取。免费模型列表会随时间变化(部分模型仅限时免费)— 可能随时变更。 -- **Vertex AI**:新 Google Cloud 账户可获得 $300 免费额度(90 天)。自 2026 年 3 月起 Gemini API 端点不再消耗这些额度 — 请改用 **Vertex AI Studio** 端点。 - -9Router 只是路由你的请求到它们 — 没有"陷阱"或未来的计费。它们是真正的免费服务,9Router 让它们易于使用并支持切换。 - -**已停止的免费等级(不再推荐):** -- ❌ **iFlow**:曾是免费无限量,现在改为付费(2026) -- ❌ **Qwen Code**:阿里巴巴于 2026-04-15 完全停止免费 OAuth 等级 -- ❌ **Gemini CLI**:Google 已于 2026-06-18 完全停止服务(由闭源的 Antigravity CLI 取代)。已停止 — 请勿使用。 - -
- -
-💰 如何最小化我的实际 AI 成本? - -**免费优先策略:** - -1. **从 100% 免费组合开始:** - ``` - 1. kr/glm-5 (通过 Kiro 免费使用 GLM-5,约 50 积分/月) - 2. OpenCode Free 模型(无认证,自动获取) - 3. Vertex AI Gemini 3 Pro(使用 Vertex AI Studio 端点 + $300 额度) - ``` - **成本:$0/月**(在 Kiro 免费积分上限内;OpenCode/Vertex 受各自免费等级限制) - -2. **仅在需要时添加低价备份:** - ``` - 4. glm/glm-4.7 ($0.6/1M tokens) - ``` - **额外成本:只为实际使用的部分付费** - -3. **最后使用订阅提供商:** - - 仅当你已有订阅时 - - 9Router 通过配额追踪帮助最大化其价值 - -**结果:** 大多数用户可以仅使用免费等级以 $0/月运行! - -
- -
-📈 如果我的使用量突然激增怎么办? - -9Router 的智能切换可以防止意外费用: - -**场景:** 你正在进行编码冲刺,用尽了配额 - -**没有 9Router:** -- ❌ 达到速率限制 → 工作停止 → 沮丧 -- ❌ 或者:不慎累积大量 API 账单 - -**有 9Router:** -- ✅ 订阅达到限制 → 自动切换到低价等级 -- ✅ 低价等级变得昂贵 → 自动切换到免费等级 -- ✅ 编程永不停歇 → 可预测的成本 - -**你掌控一切:** 在控制面板中设置每个提供商的支出限制,9Router 会遵守它们。 - -
- ---- - -## 📖 设置指南 - -
-🔐 订阅提供商(充分利用价值) - -### Claude Code (Pro/Max) - -```bash -控制面板 → 提供商 → 连接 Claude Code -→ OAuth 登录 → 自动 token 刷新 -→ 5小时 + 每周配额追踪 - -模型: - cc/claude-opus-4-7 - cc/claude-opus-4-6 - cc/claude-sonnet-4-6 - cc/claude-haiku-4-5-20251001 -``` - -**专业提示:** 复杂任务使用 Opus,追求速度使用 Sonnet。9Router 按模型追踪配额! - -### OpenAI Codex (Plus/Pro) - -```bash -控制面板 → 提供商 → 连接 Codex -→ OAuth 登录(端口 1455) -→ 5小时 + 每周重置 - -模型: - cx/gpt-5.5 - cx/gpt-5.4 - cx/gpt-5.3-codex - cx/gpt-5.2-codex -``` - -### GitHub Copilot - -```bash -控制面板 → 提供商 → 连接 GitHub -→ 通过 GitHub 进行 OAuth -→ 每月重置(每月 1 日) - -模型: - gh/gpt-5.4 - gh/claude-opus-4.7 - gh/claude-sonnet-4.6 - gh/gemini-3.1-pro-preview - gh/grok-code-fast-1 -``` - -### Cursor IDE - -```bash -控制面板 → 提供商 → 连接 Cursor -→ OAuth 登录 -→ 每月订阅 - -模型: - cu/claude-4.6-opus-max - cu/claude-4.5-sonnet-thinking - cu/gpt-5.3-codex -``` - -
- -
-💰 低价提供商(备份) - -### GLM-5.1 / GLM-4.7(每日重置,$0.6/1M) - -1. 注册:[Zhipu AI](https://open.bigmodel.cn/) -2. 从编程计划获取 API key -3. 控制面板 → 添加 API Key: - - 提供商:`glm` - - API Key:`your-key` - -**使用:** `glm/glm-5.1`、`glm/glm-5`、`glm/glm-4.7` - -**专业提示:** 编程计划提供 3 倍配额,成本仅为 1/7!每日 10:00 AM 重置。 - -### MiniMax M2.7(5小时重置,$0.20/1M) - -1. 注册:[MiniMax](https://www.minimax.io/) -2. 获取 API key -3. 控制面板 → 添加 API Key - -**使用:** `minimax/MiniMax-M2.7`、`minimax/MiniMax-M2.5` - -**专业提示:** 长上下文(1M tokens)的最便宜选项! - -### Kimi K2.5($9/月固定) - -1. 订阅:[Moonshot AI](https://platform.moonshot.ai/) -2. 获取 API key -3. 控制面板 → 添加 API Key - -**使用:** `kimi/kimi-k2.5`、`kimi/kimi-k2.5-thinking` - -**专业提示:** 每月 $9 固定费用获得 10M tokens = 实际成本 $0.90/1M! - -
- -
-🆓 免费提供商(推荐) - -### Kiro AI(Claude 4.5 + GLM-5 + MiniMax 免费) - -```bash -控制面板 → 连接 Kiro -→ AWS Builder ID、AWS IAM Identity Center、Google 或 GitHub -→ 无限量使用 - -模型: - kr/claude-sonnet-4.5 - kr/claude-haiku-4.5 - kr/glm-5 - kr/MiniMax-M2.5 - kr/qwen3-coder-next - kr/deepseek-3.2 -``` - -**专业提示:** Claude 最佳免费选项。无需 API key,无需付款,完全无限量。 - -### OpenCode Free(无需认证,自动获取模型) - -```bash -控制面板 → 连接 OpenCode Free -→ 无需登录(直连代理) -→ 模型从 opencode.ai/zen/v1/models 自动获取 -``` - -**专业提示:** 最快的设置。连接后即可开始编码。 - -### Vertex AI(新 GCP 账户 $300 免费额度) - -```bash -控制面板 → 连接 Vertex AI -→ 上传 Google Cloud 服务账户 JSON -→ 在你的 GCP 项目中启用 Vertex AI API - -模型: - vertex/gemini-3.1-pro-preview - vertex/gemini-3-flash-preview - vertex/gemini-2.5-flash - -Vertex 合作伙伴(通过 Vertex 提供 Anthropic / DeepSeek / GLM / Qwen): - vertex-partner/glm-5-maas - vertex-partner/deepseek-v3.2-maas - vertex-partner/qwen3-next-80b-a3b-thinking-maas -``` - -**专业提示:** 新 Google Cloud 账户可获得 90 天内 $300 免费额度。足够日常编码使用。 - -
- -
-🎨 创建组合 - -### 示例 1:充分利用订阅 → 低价备份 - -``` -控制面板 → 组合 → 创建新组合 - -名称:premium-coding -模型: - 1. cc/claude-opus-4-7 (订阅主用) - 2. glm/glm-5.1 (低价备份,$0.6/1M) - 3. minimax/MiniMax-M2.7 (最便宜的备选,$0.20/1M) - -在 CLI 中使用:premium-coding - -月度成本示例(100M tokens): - 80M 通过 Claude(订阅):$0 额外费用 - 15M 通过 GLM:$9 - 5M 通过 MiniMax:$1 - 总计:$10 + 你的订阅费用 -``` - -### 示例 2:仅免费(零成本) - -``` -名称:free-combo -模型: - 1. kr/claude-sonnet-4.5 (通过 Kiro 免费使用 Claude 4.5,约 50 积分/月) - 2. kr/glm-5 (通过 Kiro 免费使用 GLM-5) - 3. vertex/gemini-3.1-pro-preview ($300 免费额度) - -成本:通过 RTK 永久 $0(+ 节省 20-40% tokens)! -``` - -
- -
-🔧 CLI 集成 - -### Cursor IDE - -``` -设置 → 模型 → 高级: - OpenAI API Base URL:http://localhost:20128/v1 - OpenAI API Key:[来自 9router 控制面板] - Model:cc/claude-opus-4-7 -``` - -或使用组合:`premium-coding` - -### Claude Code - -编辑 `~/.claude/config.json`: - -```json -{ - "anthropic_api_base": "http://localhost:20128/v1", - "anthropic_api_key": "your-9router-api-key" -} -``` - -### Codex CLI - -```bash -export OPENAI_BASE_URL="http://localhost:20128" -export OPENAI_API_KEY="your-9router-api-key" - -codex "your prompt" -``` - -### OpenClaw - -**选项 1 — 控制面板(推荐):** - -``` -控制面板 → CLI 工具 → OpenClaw → 选择模型 → 应用 -``` - -**选项 2 — 手动:** 编辑 `~/.openclaw/openclaw.json`: - -```json -{ - "agents": { - "defaults": { - "model": { - "primary": "9router/kr/claude-sonnet-4.5" - } - } - }, - "models": { - "providers": { - "9router": { - "baseUrl": "http://127.0.0.1:20128/v1", - "apiKey": "sk_9router", - "api": "openai-completions", - "models": [ - { - "id": "kr/claude-sonnet-4.5", - "name": "Claude Sonnet 4.5 (Kiro Free)" - } - ] - } - } - } -} -``` - -> **注意:** OpenClaw 仅适用于本地 9Router。使用 `127.0.0.1` 而不是 `localhost` 以避免 IPv6 解析问题。 - -### Cline / Continue / RooCode - -``` -Provider:OpenAI 兼容 -Base URL:http://localhost:20128/v1 -API Key:[来自控制面板] -Model:cc/claude-opus-4-7 -``` - -
- -
-🚀 部署 - -### VPS 部署 - -```bash -# 克隆并安装 -git clone https://github.com/decolua/9router.git -cd 9router -npm install -npm run build - -# 配置 -export JWT_SECRET="your-secure-secret-change-this" -export INITIAL_PASSWORD="your-password" -export DATA_DIR="/var/lib/9router" -export PORT="20128" -export HOSTNAME="0.0.0.0" -export NODE_ENV="production" -export NEXT_PUBLIC_BASE_URL="http://localhost:20128" -export NEXT_PUBLIC_CLOUD_URL="https://9router.com" -export API_KEY_SECRET="endpoint-proxy-api-key-secret" -export MACHINE_ID_SALT="endpoint-proxy-salt" - -# 启动 -npm run start - -# 或使用 PM2 -npm install -g pm2 -pm2 start npm --name 9router -- start -pm2 save -pm2 startup -``` - -### Docker - -```bash -# 构建镜像(从仓库根目录) -docker build -t 9router . - -# 运行容器(当前设置使用的命令) -docker run -d \ - --name 9router \ - -p 20128:20128 \ - --env-file /root/dev/9router/.env \ - -v 9router-data:/app/data \ - -v 9router-usage:/root/.9router \ - 9router -``` - -便携命令(如果你已经在仓库根目录): - -```bash -docker run -d \ - --name 9router \ - -p 20128:20128 \ - --env-file ./.env \ - -v 9router-data:/app/data \ - -v 9router-usage:/root/.9router \ - 9router -``` - -容器默认值: -- `PORT=20128` -- `HOSTNAME=0.0.0.0` - -常用命令: - -```bash -docker logs -f 9router -docker restart 9router -docker stop 9router && docker rm 9router -``` - -### 环境变量 - -| 变量 | 默认值 | 描述 | -|----------|---------|-------------| -| `JWT_SECRET` | 自动生成(`~/.9router/jwt-secret`) | 用于控制面板 auth cookie 的 JWT 签名密钥(设置可在多实例间共享) | -| `INITIAL_PASSWORD` | `123456` | 当没有保存的哈希时首次登录的密码 | -| `DATA_DIR` | `~/.9router` | 主应用数据库位置(`db.json`) | -| `PORT` | 框架默认值 | 服务端口(示例中为 `20128`) | -| `HOSTNAME` | 框架默认值 | 绑定主机(Docker 默认为 `0.0.0.0`) | -| `NODE_ENV` | 运行时默认值 | 设置 `production` 用于部署 | -| `BASE_URL` | `http://localhost:20128` | 云同步任务使用的服务端内部基础 URL | -| `CLOUD_URL` | `https://9router.com` | 服务端云同步端点基础 URL | -| `NEXT_PUBLIC_BASE_URL` | `http://localhost:3000` | 向后兼容/公开基础 URL(服务端运行时优先使用 `BASE_URL`) | -| `NEXT_PUBLIC_CLOUD_URL` | `https://9router.com` | 向后兼容/公开云 URL(服务端运行时优先使用 `CLOUD_URL`) | -| `API_KEY_SECRET` | `endpoint-proxy-api-key-secret` | 生成 API key 的 HMAC 密钥 | -| `MACHINE_ID_SALT` | `endpoint-proxy-salt` | 稳定机器 ID 哈希的盐值 | -| `ENABLE_REQUEST_LOGS` | `false` | 在 `logs/` 下启用请求/响应日志 | -| `AUTH_COOKIE_SECURE` | `false` | 强制 `Secure` auth cookie(在 HTTPS 反向代理后面设置为 `true`) | -| `REQUIRE_API_KEY` | `false` | 在 `/v1/*` 路由上强制使用 Bearer API key(面向互联网部署时推荐) | -| `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY` | 空 | 用于上游提供商调用的可选出站代理 | - -注意: -- 也支持小写代理变量:`http_proxy`、`https_proxy`、`all_proxy`、`no_proxy`。 -- `.env` 不会打包到 Docker 镜像中(`.dockerignore`);使用 `--env-file` 或 `-e` 注入运行时配置。 -- 在 Windows 上,`APPDATA` 可用于本地存储路径解析。 -- `INSTANCE_NAME` 出现在较旧的文档/环境变量模板中,但当前运行时未使用。 - -### 运行时文件和存储 - -- 主应用状态:`${DATA_DIR}/db.json`(提供商、组合、别名、密钥、设置),由 `src/lib/localDb.js` 管理。 -- 使用历史和日志:`${DATA_DIR}/usage.json` 和 `${DATA_DIR}/log.txt`,由 `src/lib/usageDb.js` 管理。 -- 可选的请求/翻译器日志:`ENABLE_REQUEST_LOGS=true` 时位于 `/logs/...`。 -- `${DATA_DIR}` 和 `~/.9router` 在 Docker 容器中解析到同一位置 — 符号链接 `/root/.9router -> /app/data` 在构建时创建。 - -
- ---- - -## 📊 可用模型 - -
-查看所有可用模型 - -**Claude Code(`cc/`)** - Pro/Max: -- `cc/claude-opus-4-7` -- `cc/claude-opus-4-6` -- `cc/claude-sonnet-4-6` -- `cc/claude-sonnet-4-5-20250929` -- `cc/claude-haiku-4-5-20251001` - -**Codex(`cx/`)** - Plus/Pro: -- `cx/gpt-5.5` -- `cx/gpt-5.4` -- `cx/gpt-5.3-codex` -- `cx/gpt-5.2-codex` -- `cx/gpt-5.1-codex-max` - -**GitHub Copilot(`gh/`)**: -- `gh/gpt-5.4` -- `gh/claude-opus-4.7` -- `gh/claude-sonnet-4.6` -- `gh/gemini-3.1-pro-preview` -- `gh/grok-code-fast-1` - -**Cursor(`cu/`)** - 订阅: -- `cu/claude-4.6-opus-max` -- `cu/claude-4.5-sonnet-thinking` -- `cu/gpt-5.3-codex` -- `cu/kimi-k2.5` - -**GLM(`glm/`)** - $0.6/1M: -- `glm/glm-5.1` -- `glm/glm-5` -- `glm/glm-4.7` - -**MiniMax(`minimax/`)** - $0.2/1M: -- `minimax/MiniMax-M2.7` -- `minimax/MiniMax-M2.5` - -**Kimi(`kimi/`)** - $9/月固定: -- `kimi/kimi-k2.5` -- `kimi/kimi-k2.5-thinking` - -**Kiro(`kr/`)** - 免费(约 50 积分/月,之上为付费档位): -- `kr/claude-sonnet-4.5` -- `kr/claude-haiku-4.5` -- `kr/glm-5` -- `kr/MiniMax-M2.5` -- `kr/qwen3-coder-next` -- `kr/deepseek-3.2` - -**OpenCode Free(`oc/`)** - 免费无需认证: -- 从 `opencode.ai/zen/v1/models` 自动获取 - -**Vertex AI(`vertex/`)** - $300 免费额度: -- `vertex/gemini-3.1-pro-preview` -- `vertex/gemini-3-flash-preview` -- `vertex/gemini-2.5-flash` -- `vertex-partner/glm-5-maas` -- `vertex-partner/deepseek-v3.2-maas` - -
- ---- - -## 🐛 故障排除 - -**"语言模型未提供消息"** -- 提供商配额耗尽 → 检查控制面板配额追踪器 -- 解决方案:使用组合切换或切换到更便宜的等级 - -**速率限制** -- 订阅配额用完 → 切换到 GLM/MiniMax -- 添加组合:`cc/claude-opus-4-7 → glm/glm-5.1 → kr/claude-sonnet-4.5` - -**OAuth token 已过期** -- 9Router 自动刷新 -- 如果问题持续:控制面板 → 提供商 → 重新连接 - -**高成本** -- 在控制面板 → 端点设置中启用 RTK(默认开启,节省 20-40% tokens) -- 在控制面板中检查使用统计 -- 将主模型切换到 GLM/MiniMax -- 对于非关键任务使用免费等级(Kiro、OpenCode Free、Vertex) - -**控制面板在错误端口打开** -- 设置 `PORT=20128` 和 `NEXT_PUBLIC_BASE_URL=http://localhost:20128` - -**首次登录不工作** -- 检查 `.env` 中的 `INITIAL_PASSWORD` -- 如果未设置,回退密码是 `123456` - -**`logs/` 下没有请求日志** -- 设置 `ENABLE_REQUEST_LOGS=true` - ---- - -## 🛠️ 技术栈 - -- **运行时**:Node.js 20+ -- **框架**:Next.js 16 -- **UI**:React 19 + Tailwind CSS 4 -- **数据库**:LowDB(基于 JSON 文件) -- **流式传输**:Server-Sent Events (SSE) -- **认证**:OAuth 2.0 (PKCE) + JWT + API Keys - ---- - -## 📝 API 参考 - -### 聊天补全 - -```bash -POST http://localhost:20128/v1/chat/completions -Authorization: Bearer your-api-key -Content-Type: application/json - -{ - "model": "cc/claude-opus-4-6", - "messages": [ - {"role": "user", "content": "Write a function to..."} - ], - "stream": true -} -``` - -### 列出模型 - -```bash -GET http://localhost:20128/v1/models -Authorization: Bearer your-api-key - -→ 以 OpenAI 格式返回所有模型和组合 -``` - -## 📧 支持 - -- **网站**:[9router.com](https://9router.com) -- **GitHub**:[github.com/decolua/9router](https://github.com/decolua/9router) -- **问题**:[github.com/decolua/9router/issues](https://github.com/decolua/9router/issues) - ---- - -## 👥 贡献者 - -感谢所有帮助改进 9Router 的贡献者! - -[![Contributors](https://contrib.rocks/image?repo=decolua/9router&max=150&columns=15&anon=1&v=20260309)](https://github.com/decolua/9router/graphs/contributors) - ---- - -## 📊 Star 图表 - -[![Star Chart](https://starchart.cc/decolua/9router.svg?variant=adaptive)](https://starchart.cc/decolua/9router) - - - -## 🔀 分支 - -**[OmniRoute](https://github.com/diegosouzapw/OmniRoute)** — 9Router 的全功能 TypeScript 分支。增加了 36+ 提供商、4 层自动切换、多模态 API(图像、嵌入、音频、TTS)、断路器、语义缓存、LLM 评估和精美的控制面板。368+ 单元测试。可通过 npm 和 Docker 使用。 - ---- - -## 🙏 致谢 - -站在巨人的肩膀上构建: - -- **CLIProxyAPI** — 启发了这个 JavaScript 移植的原始 Go 实现。 -- **[RTK](https://github.com/rtk-ai/rtk)** ![Stars](https://img.shields.io/github/stars/rtk-ai/rtk?style=flat&color=yellow) — Rust token 节省器。9Router 将其压缩管道移植到 JS → 每次请求 **减少 20-40% 输入 tokens**。 -- **[Caveman](https://github.com/JuliusBrussee/caveman)** ![Stars](https://img.shields.io/github/stars/JuliusBrussee/caveman?style=flat&color=yellow) by **[@JuliusBrussee](https://github.com/JuliusBrussee)** — 病毒式传播的 *"为什么用很多 token 当少的 token 就能搞定"*。9Router 适配其提示词 → **减少 65% 输出 tokens**。 - -非常感谢这些作者 — 没有他们的工作,9Router 的 token 节省功能就不会存在。在 GitHub 上给他们加星! - ---- - -## 📄 许可证 - -MIT 许可证 — 详见 [LICENSE](LICENSE)。 - ---- - -
- 用 ❤️ 为 24/7 编程的开发者构建 -
\ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 8331b260..081fab8b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: 9router: - image: decolua/9router:latest + image: mhiqrambhrng/9router-mibp-version:latest container_name: 9router restart: always ports: diff --git a/gitbook/content/en/deployment/cloud.md b/gitbook/content/en/deployment/cloud.md index 80e4b28f..c9518014 100644 --- a/gitbook/content/en/deployment/cloud.md +++ b/gitbook/content/en/deployment/cloud.md @@ -247,6 +247,10 @@ server { ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; + # Allow large request bodies — big context windows (e.g. 1M token prompts) + # produce multi-MB chat payloads; nginx default is 1m and would 413 them. + client_max_body_size 128m; + # Proxy to 9Router location / { proxy_pass http://localhost:3000; diff --git a/gitbook/content/vi/deployment/cloud.md b/gitbook/content/vi/deployment/cloud.md index 06ba2719..2098ae04 100644 --- a/gitbook/content/vi/deployment/cloud.md +++ b/gitbook/content/vi/deployment/cloud.md @@ -247,6 +247,10 @@ server { ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; + # Cho phép body lớn — prompt ngữ cảnh 1M token sinh payload vài MB; + # mặc định nginx là 1m và sẽ trả 413. + client_max_body_size 128m; + # Proxy to 9Router location / { proxy_pass http://localhost:3000; diff --git a/images/9router.png b/images/9router.png index 283210a2..7b0a56cd 100644 Binary files a/images/9router.png and b/images/9router.png differ diff --git a/images/fusion-combo-ui.png b/images/fusion-combo-ui.png deleted file mode 100644 index 8799d720..00000000 Binary files a/images/fusion-combo-ui.png and /dev/null differ diff --git a/images/proxy-fitnes.png b/images/proxy-fitnes.png new file mode 100644 index 00000000..c4487271 Binary files /dev/null and b/images/proxy-fitnes.png differ diff --git a/images/smart-rotation.png b/images/smart-rotation.png new file mode 100644 index 00000000..cbf8de0f Binary files /dev/null and b/images/smart-rotation.png differ diff --git a/jsconfig.json b/jsconfig.json index e8d49426..f1731d92 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "ignoreDeprecations": "6.0", "baseUrl": ".", "paths": { "@/*": ["./src/*"], diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 18a62229..e469e407 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -139,8 +139,6 @@ export class BaseExecutor { try { const bodyStr = JSON.stringify(transformedBody); - const fetchT0 = Date.now(); - dbg("FETCH", `${this.provider.toUpperCase()} → ${url} | body=${bodyStr.length}B | connectTimeout=${timeoutMs}ms`); const response = await proxyAwareFetch(url, { method: "POST", headers, @@ -148,9 +146,6 @@ export class BaseExecutor { signal: mergedSignal }, proxyOptions); clearTimeout(connectTimer); - const ct = response.headers?.get?.("content-type") || ""; - const cl = response.headers?.get?.("content-length") || "?"; - dbg("FETCH", `${this.provider.toUpperCase()} ← ${response.status} | ttft=${Date.now() - fetchT0}ms | ct=${ct} | cl=${cl}`); if (await tryRetry(urlIndex, response.status, `status ${response.status}`, response)) { urlIndex--; continue; } diff --git a/open-sse/executors/default.js b/open-sse/executors/default.js index 64ad46d0..50e08936 100644 --- a/open-sse/executors/default.js +++ b/open-sse/executors/default.js @@ -25,8 +25,13 @@ function setAuth(headers, spec, token) { // Resolve auth onto headers from a descriptor. function applyAuth(headers, desc, credentials) { if (desc.combined) { - // combined providers always set the header (legacy behavior, incl. noAuth → "Bearer undefined") - setAuth(headers, desc, credentials.apiKey || credentials.accessToken); + // combined providers always set the header (legacy behavior, incl. noAuth → "Bearer undefined") — + // unless the descriptor opts into preserveHookAuth: a hook then owns the + // Authorization value (e.g. Cline needs workos:-prefixed OAuth tokens but + // plain API keys, which a single merged token can't express). + if (!(desc.preserveHookAuth && headers[desc.header])) { + setAuth(headers, desc, credentials.apiKey || credentials.accessToken); + } if (desc.anthropicVersion && !headers["anthropic-version"]) headers["anthropic-version"] = ANTHROPIC_API_VERSION; return; } @@ -40,7 +45,7 @@ function applyAuth(headers, desc, credentials) { const HEADER_HOOKS = { // Stable device_id from OAuth connection (CLIProxyAPI KimiTokenStorage.DeviceID) kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)), - clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)), + clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken, {}, { isApiKey: !!c.apiKey })), kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; }, }; diff --git a/open-sse/executors/freebuff.js b/open-sse/executors/freebuff.js new file mode 100644 index 00000000..f801be79 --- /dev/null +++ b/open-sse/executors/freebuff.js @@ -0,0 +1,827 @@ +import crypto from "node:crypto"; +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; +import { proxyAwareFetch } from "../utils/proxyFetch.js"; +import { dbg } from "../utils/debugLog.js"; +import { + FETCH_CONNECT_TIMEOUT_MS, + DEFAULT_RETRY_CONFIG, + resolveRetryEntry, +} from "../config/runtimeConfig.js"; +import { markPoolUnfit, clearPoolUnfit } from "../services/proxyPoolFitness.js"; + +/** + * Freebuff Executor — OpenAI-compatible chat completions on + * https://www.codebuff.com/api/v1/chat/completions (the Codebuff/Freebuff backend). + * + * Wire shape mirrors the official CLI exactly. The CLI (Vercel AI SDK with the + * codebuff openai-compatible provider) builds `providerOptions.codebuff` = + * { codebuff_metadata, provider } and the provider spreads those entries at + * the TOP LEVEL of the request body — i.e. the body is: + * { model, messages, codebuff_metadata: { run_id, client_id, cost_mode, + * freebuff_instance_id? }, provider: { allow_fallbacks } } + * NOT nested under a `codebuff` object (the backend rejects the nested shape + * with 400 "No runId found in request body"). + * + * The run_id is not a free-form uuid: the backend resolves it against its + * agent-run store and rejects unknown ids with 400 "runId Not Found". So every + * chat request first registers a run via POST /api/v1/agent-runs + * ({ action:"START", agentId, ancestorRunIds:[] }) → { runId }, and that id is + * what goes in codebuff_metadata.run_id. The free tier additionally gates on a + * session: POST /api/v1/freebuff/session with an `x-freebuff-model` header + * claims a row (bound to one model, ~1h); its instance id must ride along as + * codebuff_metadata.freebuff_instance_id. + */ +const SESSION_PATH = "/api/v1/freebuff/session"; +const RUN_PATH = "/api/v1/agent-runs"; +const SESSION_DEFAULT_TTL_MS = 60 * 60 * 1000; // active sessions live ~1h + +// Chat statuses that mean our claimed session is stale and must be re-claimed +// before retrying (mirrors the CLI's FreebuffGateErrorKind statuses). +const SESSION_STALE_CODES = new Set([428, 409, 410]); + +// Models the backend runs as a CAPACITY-LIMITED OFFER rather than a standing +// picker row. Claude Fable 5 is not in the client catalog at all: the server +// advertises it per-session-response (`limitedModelOffers`) only while its +// shared wave pool has sessions left, and a request without a live offer is +// refused. A claim must therefore peek at the current offers first instead of +// POSTing blind (mirrors the CLI: the "Claude Fable 5 · N of M left" row only +// renders from that payload). Offer state is per-account and cached briefly — +// the pool can reopen at any time, so a closed offer must NOT set a long +// cooldown. +const OFFER_GATED_MODELS = new Set(["anthropic/claude-fable-5"]); +const OFFER_CACHE_TTL_MS = 45_000; + +// The free tier rejects requests whose first system message doesn't open with +// the canonical Freebuff CLI root prompt (server gate +// requestHasFreebuffSystemMarker → 403 free_mode_cli_required). The check is a +// byte-exact prefix test on position 0, so we prepend the canonical opening. +// Same anti-abuse pattern as mimo-free's MIMO_SYSTEM_MARKER injection. +const FREEBUFF_SYSTEM_MARKER = "You are Buffy, the strategic coding assistant."; + +// Canonical openings accepted by the server gate (mirrors the CLI's +// FREEBUFF_ROOT_SYSTEM_PROMPT_OPENINGS). The check is a byte-exact prefix on +// the first message, so our injected marker must be one of these verbatim. +const FREEBUFF_ROOT_SYSTEM_OPENINGS = [ + "You are Buffy, the strategic coding assistant.", + "You are Buffy, the Freebuff Cloud project planner.", + "You are Buffy, a strategic assistant that orchestrates complex coding tasks through specialized sub-agents.", +]; + +// Ensure messages[0] opens with a canonical Freebuff root prompt (idempotent). +function injectFreebuffMarker(body) { + const messages = body?.messages; + if (!Array.isArray(messages) || messages.length === 0) return body; + const first = messages[0]; + if (first?.role === "system" && typeof first.content === "string") { + const trimmed = first.content.trimStart(); + if (FREEBUFF_ROOT_SYSTEM_OPENINGS.some((opening) => trimmed.startsWith(opening))) return body; // already marked + // Prepend the canonical opening to the existing system prompt so it stays + // the first thing the model reads (keep the rest of the messages intact). + return { + ...body, + messages: [{ ...first, content: `${FREEBUFF_SYSTEM_MARKER}\n\n${first.content}` }, ...messages.slice(1)], + }; + } + // No leading system message — insert one with the canonical opening. + return { ...body, messages: [{ role: "system", content: FREEBUFF_SYSTEM_MARKER }, ...messages] }; +} + +// The backend's foreign_toolset gate rejects any tool-calling request whose +// toolset lacks the CLI's `end_turn` tool with a misleading 404 "No endpoints +// found for {model}" (verified live 2026-08-14; the freebuff-proxy bridge +// works around the same gate by injecting this definition). Every request that +// declares tools must carry it or the router finds no serving endpoint. +const END_TURN_TOOL = { + type: "function", + function: { + name: "end_turn", + description: "Signal the end of the current task.", + parameters: { type: "object", properties: {} }, + }, +}; + +function injectEndTurnTool(body) { + const tools = body?.tools; + if (!Array.isArray(tools) || tools.length === 0) return body; + const hasEndTurn = tools.some( + (t) => t?.function?.name === "end_turn", + ); + if (hasEndTurn) return body; + return { ...body, tools: [...tools, END_TURN_TOOL] }; +} + +// Freebuff root agent id per model (mirrors the CLI's +// FREEBUFF_CLI_BASE3_AGENT_ID_BY_MODEL — the CLI harness moved from base2 to +// base3, and the backend can return 404 "No endpoints found" for the old +// base2 roots during the transition). +// +// Withdrawn upstream models (deepseek-v4-pro, minimax-m3, stealth/ox-alpha, +// google/gemini-3.8-flash, meta/muse-spark-1.3-contributor) are deliberately +// absent: no new session can be admitted on them, so mapping them would only +// hide a dead pick behind a wrong root. z-ai/glm-5.2 stays mapped +// (referral-earned accounts can still run it) even though it is not a +// standing picker row. +const FREE_ROOT_AGENT_BY_MODEL = { + "deepseek/deepseek-v4-flash": "base3-free-deepseek-flash", + "z-ai/glm-5.2": "base3-free-glm", + "z-ai/glm-5.3-flash": "base3-free-glm-5-3-flash", + "mimo/mimo-v2.5": "base3-free-mimo", + "openai/gpt-5.6-luna": "base3-free-luna", + "upstage/solar-pro4": "base3-free-solar-pro4", + "meta/muse-spark-1.2-contributor": "base3-free-muse-spark", + "anthropic/claude-fable-5": "base3-free-fable", +}; + +// Per-token+model session cache (in-memory; keyed so multi-account setups +// don't share one session row). Re-claims are driven by the cache expiring or +// by a 428 from chat — no early re-claim, so we never POST /session while our +// own row is still active (which could come back as a spurious model_locked). +// All state lives on globalThis so Next dev (Turbopack) bundles share ONE copy. +const FB_STATE_KEY = "__9routerFreebuffState__"; +const fbState = (globalThis[FB_STATE_KEY] ??= { + sessionCache: new Map(), // `${token}::${model}` -> { instanceId, expiresAt } + inflight: new Map(), // dedupe concurrent claims for the same key + modelLockCooldowns: new Map(), // `${token}::${model}` -> expiresAt (ms) + poolLimitCooldowns: new Map(), // `${proxyKey}::${model}` -> expiresAt (ms) + offerCache: new Map(), // `${token}` -> { fetchedAt, offers: [] } (limited-offer rows) +}); +const sessionCache = fbState.sessionCache; +const inflight = fbState.inflight; +const modelLockCooldowns = fbState.modelLockCooldowns; +const poolLimitCooldowns = fbState.poolLimitCooldowns; +const offerCache = fbState.offerCache; + +const MODEL_LOCK_COOLDOWN_MS = 10 * 60 * 1000; // session bound to another model (~1h) — re-check every 10 min +const POOL_LIMITED_COOLDOWN_MS = 5 * 60 * 1000; // IP tier refuses this model — try a different pool/relay + +// Cooldown maps need pruning: expired entries are cleared on write (sweep) and +// on read, so long-running servers don't accumulate one entry per (account,model) +// / (proxy,model) forever. +function setCooldown(map, key, until) { + const now = Date.now(); + for (const [k, v] of map) { + if (v <= now) map.delete(k); + } + map.set(key, until); +} + +function getCooldown(map, key) { + const until = map.get(key); + if (until == null) return null; + if (until <= Date.now()) { + map.delete(key); + return null; + } + return until; +} + +function proxyKeyOf(proxyOptions) { + return proxyOptions?.vercelRelayUrl || proxyOptions?.connectionProxyUrl || "direct"; +} + +function sessionGateFromText(text) { + let parsed = {}; + try { parsed = JSON.parse(String(text || "")); } catch { parsed = {}; } + return classifySessionGate(parsed.error || parsed.error_type || "", parsed.message || "", parsed.currentModel || null); +} + +// Parse a 409/428/410 body into { kind, currentModel }. `msg` may be a whole +// error string containing a JSON tail (requestSession errors embed the body). +function sessionGateFromError(error) { + const msg = String(error?.message || ""); + const start = msg.indexOf("{"); + if (start < 0) return null; + try { + const parsed = JSON.parse(msg.slice(start)); + return classifySessionGate(parsed.error || "", parsed.message || "", parsed.currentModel || null); + } catch { + return null; + } +} + +function classifySessionGate(code, message, currentModel) { + if (code === "session_superseded") return { kind: "superseded" }; + if (code === "model_locked") return { kind: "model_locked", currentModel }; + // session_model_mismatch with the limited-tier message is an IP-tier refusal; + // without it (or unknown) treat it as a model lock so we don't reclaim in a loop. + if (code === "session_model_mismatch") { + return /limited/i.test(String(message || "")) + ? { kind: "limited_ip" } + : { kind: "model_locked", currentModel }; + } + return { kind: "stale" }; // 428/410/unknown → reclaim +} + +// Applies cooldowns and throws for non-reclaimable gates. Never returns for them. +function throwSessionGateError(gate, { token, model, proxyKey, poolId, log }) { + if (gate.kind === "model_locked") { + const until = Date.now() + MODEL_LOCK_COOLDOWN_MS; + setCooldown(modelLockCooldowns, `${token}::${model}`, until); + const label = gate.currentModel ? `"${gate.currentModel}"` : "another model"; + const err = new Error( + `Freebuff session is locked to ${label} — it cannot serve ${model}. End the session on freebuff.com or wait for it to expire (~1h).`, + ); + err.status = 409; + err.resetsAtMs = until; + log?.warn?.("AUTH", `Freebuff model_locked (session=${label}, requested=${model}) — model cooldown ${MODEL_LOCK_COOLDOWN_MS / 60000}min`); + throw err; + } + if (gate.kind === "limited_ip") { + const until = Date.now() + POOL_LIMITED_COOLDOWN_MS; + setCooldown(poolLimitCooldowns, `${proxyKey}::${model}`, until); + const scope = `freebuff::${model}`; + if (poolId) markPoolUnfit(poolId, scope, until, "limited_ip"); + // Pool-scoped, not account-scoped: the caller retries via another pool + // instead of locking the account (resetsAtMs intentionally absent). + const err = new Error( + `Freebuff limited-mode IP rejected ${model} — this IP only allows DeepSeek V4 Flash / MiMo 2.5. Use a full-access proxy or a different model.`, + ); + err.status = 409; + err.poolScoped = { poolId, scope, reason: "limited_ip" }; + log?.warn?.("AUTH", `Freebuff limited-IP refused ${model} (proxy=${proxyKey.slice(0, 40)}…) — cooldown ${POOL_LIMITED_COOLDOWN_MS / 60000}min`); + throw err; + } +} + +function sessionOrigin() { + return new URL(PROVIDERS.freebuff.baseUrl).origin; // https://www.codebuff.com +} + +function sessionCacheKey(token, model) { + return `${token}::${model}`; +} + +function rootAgentIdForModel(model) { + return FREE_ROOT_AGENT_BY_MODEL[model] || "base2-free"; +} + +// Retry transient network errors (ECONNRESET, TLS reset, …) on the session/ +// run API calls — mirrors the CLI's fetchWithRetry. Only fetch-level throws +// are retried; HTTP error responses are returned as-is. +// +// The timeout signal is built per attempt: a single shared +// AbortSignal.timeout() would stay aborted forever after it fires, silently +// turning attempts 2..n into instant no-op rejections. +async function fetchWithNetworkRetry(url, options, proxyOptions, attempts = 3, timeoutMs = FETCH_CONNECT_TIMEOUT_MS) { + let lastError; + for (let attempt = 0; attempt < attempts; attempt++) { + try { + const opts = { ...options, signal: AbortSignal.timeout(timeoutMs) }; + return await proxyAwareFetch(url, opts, proxyOptions); + } catch (error) { + lastError = error; + if (attempt + 1 < attempts) { + await new Promise((resolve) => setTimeout(resolve, 750)); + } + } + } + throw lastError; +} + +async function requestSession(token, model, proxyOptions) { + // Offer-gated models (Fable) refuse claims while their wave pool is closed — + // checked before the POST so a closed offer never burns a claim attempt. + await guardOfferClaim(token, model, proxyOptions); + + const response = await fetchWithNetworkRetry(`${sessionOrigin()}${SESSION_PATH}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": "codebuff-cli/0.0.138", + "x-freebuff-model": model, + }, + }, proxyOptions); + + let data = {}; + try { data = await response.json(); } catch { data = {}; } + + if (response.status === 401) { + const err = new Error("Freebuff session auth failed (401) — re-login in the dashboard"); + err.status = 401; + throw err; + } + const status = data?.status; + const GATE_MESSAGES = { + country_blocked: "Freebuff is not available in your region (country blocked).", + banned: "Your Freebuff account has been banned.", + ip_capped: "Freebuff IP cap reached — try again later.", + rate_limited: "Freebuff session limit reached for this model — try again later.", + spend_limited: "Freebuff spend limit reached — add credits or wait for the window to reset.", + model_locked: "Freebuff session is locked to another model — end it in the CLI or wait for it to expire.", + model_unavailable: "This model is not available on Freebuff right now.", + premium_slot_taken: "Freebuff premium slot is taken — try another model.", + }; + // Gate statuses ride BOTH 200 (pre-join refusals) and 4xx — the backend + // sends spend_limited/rate_limited as HTTP 429 with the gate in the body. + // Handle them BEFORE the generic !response.ok throw so exhaustion carries + // resetsAtMs (skip-until-reset) instead of a bare status. + if (GATE_MESSAGES[status]) { + const err = new Error(data?.message ? `${GATE_MESSAGES[status]} ${data.message}` : GATE_MESSAGES[status]); + // Freebucks / session-allowance exhaustion is a hard stop until the daily + // Pacific reset — mark the account unavailable until then so accountFallback + // SKIPS it for the rest of the day instead of retrying every 30s and getting + // refused repeatedly. resetsAtMs is honored by markAccountUnavailable; + // freebuff bypasses the generic 30-min cap (see auth.js). + if (status === "rate_limited" || status === "spend_limited") { + const resetAtMs = Date.parse(data?.resetAt || ""); + if (Number.isFinite(resetAtMs) && resetAtMs > Date.now()) { + err.resetsAtMs = resetAtMs; + err.status = 429; + } else { + const retryAfterMs = Number(data?.retryAfterMs); + if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) { + err.resetsAtMs = Date.now() + retryAfterMs; + err.status = 429; + } + } + } + throw err; + } + + if (!response.ok) { + const err = new Error(`Freebuff session request failed: ${response.status} ${JSON.stringify(data).slice(0, 200)}`); + err.status = response.status; + throw err; + } + + if (status === "active") { + const parsedExp = Date.parse(data.expiresAt || ""); + const entry = { + instanceId: data.instanceId, + expiresAt: Number.isFinite(parsedExp) ? parsedExp : Date.now() + SESSION_DEFAULT_TTL_MS, + }; + sessionCache.set(sessionCacheKey(token, model), entry); + return { instanceId: data.instanceId, status: "active" }; + } + if (status === "none") { + // Not session-gated right now — proceed without an instance id; a 428 on + // chat tells us the admission gate actually requires a session. + return { instanceId: null, status: "none" }; + } + + throw new Error(`Freebuff session rejected (${status || response.status}): ${JSON.stringify(data).slice(0, 200)}`); +} + +// Fetch the account's current limited-model offers (GET — never claims). +// Cached per token for OFFER_CACHE_TTL_MS: the wave pool changes on server +// time, not ours, and a claim only needs to know "is it open right now". +async function fetchSessionOffers(token, proxyOptions) { + const now = Date.now(); + const cached = offerCache.get(token); + if (cached && now - cached.fetchedAt < OFFER_CACHE_TTL_MS) { + return cached.offers; + } + + const response = await fetchWithNetworkRetry(`${sessionOrigin()}${SESSION_PATH}`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "User-Agent": "codebuff-cli/0.0.138", + Accept: "application/json", + }, + }, proxyOptions); + + let data = {}; + try { data = await response.json(); } catch { data = {}; } + + if (response.status === 401) { + const err = new Error("Freebuff session auth failed (401) — re-login in the dashboard"); + err.status = 401; + throw err; + } + if (!response.ok) { + const err = new Error(`Freebuff offer check failed: ${response.status} ${JSON.stringify(data).slice(0, 200)}`); + err.status = response.status; + throw err; + } + + const offers = Array.isArray(data?.limitedModelOffers) + ? data.limitedModelOffers.filter((o) => o && typeof o.model === "string") + : []; + offerCache.set(token, { fetchedAt: now, offers }); + return offers; +} + +// For an offer-gated model (Fable), refuse the claim BEFORE the POST when the +// backend is not currently advertising it. Returns the matching offer when the +// claim may proceed. Throws a plain Error (no JSON tail) so the executor's +// sessionGateFromError stays null and the cooldown maps are never touched — +// a closed offer is availability, not a lock, and the pool can reopen any time. +async function guardOfferClaim(token, model, proxyOptions) { + if (!OFFER_GATED_MODELS.has(model)) return null; + + const offers = await fetchSessionOffers(token, proxyOptions); + const offer = offers.find((o) => o.model === model); + if (!offer || Number(offer.remaining) <= 0) { + const err = new Error( + `Claude Fable 5 is not being offered right now — it is a capacity-limited trial served in waves, and freebuff's shared Fable pool is currently empty. Watch the official freebuff CLI for the "Claude Fable 5 · N of M left" row, or retry later.`, + ); + err.status = 409; + err.code = "offer_closed"; + throw err; + } + const userLeft = Number(offer.userRemaining); + if (Number.isFinite(userLeft) && userLeft <= 0) { + const resetAt = Date.parse(offer.userResetAt || ""); + const err = new Error( + `Your Freebuff account has used its Claude Fable 5 sessions for today (pool: ${offer.remaining} of ${offer.total} left)${Number.isFinite(resetAt) ? ` — next slot ${new Date(resetAt).toLocaleString()}` : ""}.`, + ); + err.status = 409; + err.code = "offer_user_capped"; + if (Number.isFinite(resetAt)) err.resetsAtMs = resetAt; + throw err; + } + return offer; +} + +async function ensureSession(token, model, proxyOptions, force = false) { + const key = sessionCacheKey(token, model); + // Lazy prune: drop stale rows so the cache never accumulates expired entries. + const cached = sessionCache.get(key); + if (cached && cached.expiresAt <= Date.now()) { + sessionCache.delete(key); + } + if (!force && cached && cached.expiresAt > Date.now()) { + return { instanceId: cached.instanceId, status: "active" }; + } + if (force) { + // Drop both the cached row and any in-flight claim so the fresh POST can't + // race a stale one back into the cache. + sessionCache.delete(key); + inflight.delete(key); + return requestSession(token, model, proxyOptions); + } + if (!inflight.has(key)) { + inflight.set(key, requestSession(token, model, proxyOptions).finally(() => inflight.delete(key))); + } + return inflight.get(key); +} + +// Register an agent run so the chat backend can resolve the run_id we send. +async function startRun(token, model, proxyOptions) { + const response = await fetchWithNetworkRetry(`${sessionOrigin()}${RUN_PATH}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": "codebuff-cli/0.0.138", + }, + body: JSON.stringify({ + action: "START", + agentId: rootAgentIdForModel(model), + ancestorRunIds: [], + }), + }, proxyOptions); + + const text = await response.text().catch(() => ""); + let data = {}; + try { data = JSON.parse(text); } catch { data = {}; } + + if (response.status === 401) { + const err = new Error("Freebuff run auth failed (401) — re-login in the dashboard"); + err.status = 401; + throw err; + } + if (!response.ok) { + const err = new Error(`Freebuff run start failed: ${response.status} ${text.slice(0, 200)}`); + err.status = response.status; + throw err; + } + if (!data?.runId) { + throw new Error(`Freebuff run start returned no runId: ${text.slice(0, 200)}`); + } + return data.runId; +} + +// Best-effort run completion — mirrors the CLI's finishAgentRun. Never throws. +async function finishRun(token, runId, status, proxyOptions) { + if (!runId) return; + try { + await proxyAwareFetch(`${sessionOrigin()}${RUN_PATH}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "User-Agent": "codebuff-cli/0.0.138", + }, + body: JSON.stringify({ action: "FINISH", runId, status }), + signal: AbortSignal.timeout(10_000), + }, proxyOptions); + } catch { + // Best-effort only — the server sweeps stale runs. + } +} + +export function resetSessionCache() { + sessionCache.clear(); + inflight.clear(); + offerCache.clear(); +} + +// Snapshot sizes of in-memory freebuff state (for the dashboard memory panel). +export function sessionStateSize() { + return { + sessions: sessionCache.size, + inflight: inflight.size, + modelLocks: modelLockCooldowns.size, + poolLimits: poolLimitCooldowns.size, + offerCaches: offerCache.size, + }; +} + +// Periodic sweeper: drop stale session rows + expired cooldowns so long-running +// servers never accumulate state for accounts/models no longer in use. +// Returns how many entries were removed. +export function pruneSessionState(now = Date.now()) { + let removed = 0; + for (const [key, entry] of sessionCache) { + if (entry?.expiresAt && entry.expiresAt <= now) { + sessionCache.delete(key); + removed += 1; + } + } + for (const [key, entry] of offerCache) { + if (now - entry.fetchedAt >= OFFER_CACHE_TTL_MS) { + offerCache.delete(key); + removed += 1; + } + } + for (const [key, until] of modelLockCooldowns) { + if (until <= now) { + modelLockCooldowns.delete(key); + removed += 1; + } + } + for (const [key, until] of poolLimitCooldowns) { + if (until <= now) { + poolLimitCooldowns.delete(key); + removed += 1; + } + } + return removed; +} + +export class FreebuffExecutor extends BaseExecutor { + constructor() { + super("freebuff", PROVIDERS.freebuff); + } + + buildUrl() { + return this.config.baseUrl; + } + + // The backend's model router answers 404 "No endpoints found for {model}" + // when a tool-calling request's toolset fails its foreign_toolset gate — + // normally prevented by injecting the CLI's `end_turn` tool (see + // injectEndTurnTool). If one still slips through, surface a helpful message + // instead of a bare 404, and let the standard cooldown pace retries. + async parseError(response, bodyText) { + const text = String(bodyText || ""); + if (response?.status === 404 && /No endpoints found/i.test(text)) { + return { + status: 404, + message: `Freebuff upstream rejected the request (404: "${text.trim().slice(0, 90)}"). Tool-calling requests need the CLI's end_turn tool — retry; if it persists the Codebuff backend may be having trouble.`, + resetsAtMs: Date.now() + 120_000, + }; + } + return super.parseError(response, bodyText); + } + + transformRequest(model, body, stream, credentials) { + // Top-level wire shape — see header comment. `run_id` and + // `freebuff_instance_id` are attached by execute() (they need the async + // run/session registration), so this only sets the static parts. + body.codebuff_metadata = { + client_id: + credentials?.providerSpecificData?.fingerprintId || + `9router-${crypto.randomUUID()}`, + cost_mode: "free", + }; + body.provider = { allow_fallbacks: false }; + // Freebuff agents (base3-free-*) own reasoning: the backend applies the + // agent's reasoningOptions.effort server-side, so a client-sent + // reasoning_effort / reasoning.effort collides with that default → + // 400 "both provided with conflicting values". Mirror the CLI: send none. + delete body.reasoning_effort; + delete body.reasoning; + // Free-tier gate: first system message must open with the CLI marker. + body = injectFreebuffMarker(body); + // Foreign-toolset gate: tool-calling requests must declare `end_turn`. + return injectEndTurnTool(body); + } + + async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) { + const token = credentials?.accessToken; + if (!token) { + throw new Error("Freebuff requires a connected Freebuff login (no access token found)"); + } + + // Fail fast while a known-dead (account,model) / (proxy,model) pair is in + // cooldown — no session claim, no run registration, no upstream spam. + const proxyKey = proxyKeyOf(proxyOptions); + const poolId = proxyOptions?.proxyPoolId || null; + const scope = `freebuff::${model}`; + const lockUntil = getCooldown(modelLockCooldowns, `${token}::${model}`); + if (lockUntil) { + const err = new Error(`Freebuff session locked to another model — retry after ${new Date(lockUntil).toLocaleTimeString()}`); + err.status = 409; + err.resetsAtMs = lockUntil; + throw err; + } + const poolUntil = getCooldown(poolLimitCooldowns, `${proxyKey}::${model}`); + if (poolUntil) { + const err = new Error(`Freebuff limited-mode IP rejected ${model} — retry with a full-access proxy after ${new Date(poolUntil).toLocaleTimeString()}`); + err.status = 409; + err.poolScoped = { poolId, scope, reason: "limited_ip" }; + throw err; + } + + let session; + try { + session = await ensureSession(token, model, proxyOptions); + } catch (error) { + const gate = sessionGateFromError(error); + if (gate) throwSessionGateError(gate, { token, model, proxyKey, poolId, log }); + log?.error?.("AUTH", `Freebuff session failed: ${error.message}`); + throw error; + } + + const url = this.buildUrl(); + const headers = this.buildHeaders(credentials, stream); + const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...this.config.retry }; + + // Registered run whose id the backend resolves on chat. Per-request, like + // the CLI's one-run-per-prompt granularity; closure-local so concurrent + // requests never share a runId. trace_session_id mirrors the CLI's + // extraCodebuffMetadata — one per run, stable across retries. + let runId = null; + const traceSessionId = crypto.randomUUID(); + + const buildBody = () => { + const transformed = this.transformRequest(model, body, stream, credentials); + transformed.codebuff_metadata.run_id = runId; + transformed.codebuff_metadata.trace_session_id = traceSessionId; + if (session?.instanceId) { + transformed.codebuff_metadata.freebuff_instance_id = session.instanceId; + } + return transformed; + }; + + // Chat POST with connect timeout + registry 429/502/503 retry + up to 2 + // retries on fetch-level network errors (per attempt the body is rebuilt + // so each retry reuses the same registered run_id). + const doChat = async () => { + let networkAttempts = 0; + const MAX_NETWORK_ATTEMPTS = 2; + for (let attempt = 0; ; attempt++) { + const transformedBody = buildBody(); + const bodyStr = JSON.stringify(transformedBody); + + const connectCtrl = new AbortController(); + const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS; + const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs); + const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal; + let response; + try { + response = await proxyAwareFetch(url, { method: "POST", headers, body: bodyStr, signal: mergedSignal }, proxyOptions); + } catch (error) { + // A caller/stream abort (AbortError) is genuine — never retry it. A + // transient socket/TLS reset (same class as the run-registration + // failure in the field) gets a couple of quick retries so a network + // blip doesn't fail the request and lock the model for 30s. + const aborted = error?.name === "AbortError"; + if (aborted || networkAttempts >= MAX_NETWORK_ATTEMPTS) throw error; + networkAttempts += 1; + log?.debug?.("RETRY", `network error on ${url} (${error.message}), retry ${networkAttempts}/${MAX_NETWORK_ATTEMPTS}`); + await new Promise((resolve) => setTimeout(resolve, 750)); + continue; + } finally { + clearTimeout(connectTimer); + } + + const entry = resolveRetryEntry(retryConfig[response.status]); + if (entry && attempt < entry.attempts) { + log?.debug?.("RETRY", `${response.status} on ${url}, retry ${attempt + 1}/${entry.attempts} after ${entry.delayMs / 1000}s`); + await new Promise((resolve) => setTimeout(resolve, entry.delayMs)); + continue; + } + return { response, transformedBody }; + } + }; + + // The run currently in flight. Only this one is FINISH-able: after a stale + // session (428/409/410) the old run is FINISH'd "cancelled" and cleared, so + // a later failure can never double-FINISH it (the server rejects duplicate + // FINISHes for the same runId). + let activeRunId = null; + const markFinished = (status) => { + if (!activeRunId) return; + const id = activeRunId; + activeRunId = null; + finishRun(token, id, status, proxyOptions); + }; + + try { + try { + runId = await startRun(token, model, proxyOptions); + activeRunId = runId; + } catch (error) { + log?.error?.("AUTH", `Freebuff run start failed: ${error.message}`); + throw error; + } + + let { response, transformedBody } = await doChat(); + + // Session gates that mean our claimed session is stale/absent: + // 428 waiting_room_required — no session row / instance id missing + // 409 session_superseded — another instance took over the session + // 409 session_model_mismatch — session bound to a different model + // 410 session_expired — the active session's expires_at passed + // model_locked / limited-tier mismatches are NOT reclaimable — the server + // keeps refusing until the session expires or the IP tier changes, so we + // set a cooldown and fail fast instead of force re-claiming in a loop. + if (SESSION_STALE_CODES.has(response.status)) { + const text = await response.text().catch(() => ""); + const gate = sessionGateFromText(text); + if (gate.kind === "model_locked" || gate.kind === "limited_ip") { + markFinished("cancelled"); + throwSessionGateError(gate, { token, model, proxyKey, poolId, log }); + } + + log?.debug?.("AUTH", `Freebuff ${response.status} session gate — re-claiming session`); + markFinished("cancelled"); + try { + session = await ensureSession(token, model, proxyOptions, true); + runId = await startRun(token, model, proxyOptions); + activeRunId = runId; + } catch (error) { + const gate2 = sessionGateFromError(error); + if (gate2) throwSessionGateError(gate2, { token, model, proxyKey, poolId, log }); + log?.error?.("AUTH", `Freebuff session re-claim failed: ${error.message}`); + throw error; + } + ({ response, transformedBody } = await doChat()); + + if (SESSION_STALE_CODES.has(response.status)) { + const text2 = await response.text().catch(() => ""); + const gate3 = sessionGateFromText(text2); + if (gate3.kind === "model_locked" || gate3.kind === "limited_ip") { + throwSessionGateError(gate3, { token, model, proxyKey, poolId, log }); + } + const err = new Error( + `Freebuff session gate refused (${response.status}) — another freebuff instance may be holding the session. ${text2.slice(0, 160)}`, + ); + err.status = response.status; + throw err; + } + } + + // A successful chat means the pair is healthy again — lift any cooldowns. + if (response.ok) { + modelLockCooldowns.delete(`${token}::${model}`); + poolLimitCooldowns.delete(`${proxyKey}::${model}`); + if (poolId) clearPoolUnfit(poolId, scope); + } + + // The authToken has no refresh path — when it dies, the user re-logs in. + // Drop the cached session for this token so a re-login starts clean. + if (response.status === 401) { + sessionCache.delete(sessionCacheKey(token, model)); + const text = await response.text().catch(() => ""); + const err = new Error(`Freebuff auth failed (401) — re-login in the dashboard. ${text.slice(0, 120)}`); + err.status = 401; + throw err; + } + + // Best-effort run accounting, mirroring the CLI. + markFinished(response.ok ? "completed" : "failed"); + + return { response, url, headers, transformedBody }; + } finally { + // Never leave the current run dangling on thrown paths (network/abort/gate). + if (activeRunId) { + finishRun(token, activeRunId, "failed", proxyOptions); + } + } + } +} + +export const __test__ = { + ensureSession, + requestSession, + startRun, + resetSessionCache, + rootAgentIdForModel, + injectFreebuffMarker, + injectEndTurnTool, + fetchWithNetworkRetry, + fetchSessionOffers, + guardOfferClaim, + OFFER_GATED_MODELS, + FREEBUFF_SYSTEM_MARKER, + SESSION_STALE_CODES, +}; + +export default FreebuffExecutor; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index e8b06ea6..f4d793cc 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -27,6 +27,7 @@ import ZedExecutor from "./zed.js"; import WindsurfExecutor from "./windsurf.js"; import { DefaultExecutor } from "./default.js"; import { DevinCliExecutor } from "./devin-cli.js"; +import { FreebuffExecutor } from "./freebuff.js"; const executors = { antigravity: new AntigravityExecutor(), @@ -63,6 +64,7 @@ const executors = { zed: new ZedExecutor(), windsurf: new WindsurfExecutor(), "devin-cli": new DevinCliExecutor(), + freebuff: new FreebuffExecutor(), }; const defaultCache = new Map(); diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index 4623dcf6..e2c09941 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -393,6 +393,12 @@ function normalizeOpencodeReasoning(model, body) { delete body.reasoning_effort; } +// OpenCode free tier is limited per egress IP — a 429/403 with a limit-ish +// body means the POOL's IP is exhausted, not the account. Declare it +// pool-scoped so chatCore marks the pool unfit, retries via another pool, and +// it shows up (clearable) on the Proxy Fitness page. +const IP_LIMIT_BODY = /limit|rate|quota|exhausted|capacity|too many|retry/i; + export class OpenCodeExecutor extends BaseExecutor { constructor() { super("opencode", PROVIDERS.opencode); @@ -484,4 +490,17 @@ export class OpenCodeExecutor extends BaseExecutor { if (url.endsWith("/messages")) headers["anthropic-version"] = ANTHROPIC_API_VERSION; return headers; } + + parseError(response, bodyText) { + const status = response?.status || 0; + const text = String(bodyText || ""); + if ((status === 429 || status === 403) && IP_LIMIT_BODY.test(text)) { + return { + status, + message: text.slice(0, 300) || `OpenCode free limit (${status})`, + poolScoped: { reason: "ip-limit" }, + }; + } + return null; // fall through to default parsing + } } diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 0db37ba1..904bf832 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -31,6 +31,12 @@ import { stripUnsupportedModalities } from "../translator/concerns/modality.js"; import { prefetchRemoteImages } from "../translator/concerns/prefetch.js"; import { defaultClaudeToolType, shouldDefaultClaudeToolType } from "../translator/concerns/toolCall.js"; import { resolveSessionId } from "../utils/sessionManager.js"; +import { markPoolUnfit, clearPoolUnfit } from "../services/proxyPoolFitness.js"; + +// Pool-scoped failure retry: when an executor tags an error as belonging to a +// proxy pool (region gate, dead proxy, …), re-resolve the proxy config +// excluding that pool and retry instead of failing the whole account. +const MAX_POOL_RETRIES = 2; /** * Core chat handler - shared between SSE and Worker @@ -59,7 +65,7 @@ export function stripContinuityFields(body) { return body; } -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, resolveProxyConfig }) { const { provider, model } = modelInfo; const requestStartTime = Date.now(); // Stable per-session color so all lines of one CLI conversation share a tag @@ -332,16 +338,44 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log, provider, model, reqTag }); - const proxyOptions = { - connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true, - connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "", - connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "", - vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "", - }; + // Build proxy options from the resolved provider-specific data. `strictProxy` + // is forced for freebuff so a dead/limited pool can never leak the request + // to the caller's real IP (the freebuff session tier is per-egress-IP). + const buildProxyOptions = (psd = {}) => ({ + connectionProxyEnabled: psd?.connectionProxyEnabled === true, + connectionProxyUrl: psd?.connectionProxyUrl || "", + connectionNoProxy: psd?.connectionNoProxy || "", + vercelRelayUrl: psd?.vercelRelayUrl || "", + strictProxy: psd?.strictProxy === true || provider === "freebuff", + proxyPoolId: psd?.proxyPoolId || psd?.connectionProxyPoolId || null, + }); + + const proxyScope = `${provider}::${model}`; + let proxyOptions = buildProxyOptions(credentials?.providerSpecificData || {}); + + if (provider === "freebuff" && credentials?.providerSpecificData?.noFitPool === true) { + const error = new Error(`Freebuff has no healthy proxy pool for ${model}; all assigned pools are cooling down after limited-IP errors.`); + error.status = 503; + error.poolScoped = { poolId: null, scope: proxyScope, reason: "no_fit_pool" }; + trackPendingRequest(model, provider, connectionId, false, true); + return createErrorResult(503, error.message); + } + + if ( + provider === "freebuff" && + !proxyOptions.proxyPoolId && + !proxyOptions.vercelRelayUrl && + !(proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) + ) { + const error = new Error(`Freebuff requires a configured proxy pool for ${model}; direct egress is disabled to prevent limited-IP rate limits.`); + error.status = 503; + trackPendingRequest(model, provider, connectionId, false, true); + return createErrorResult(503, error.message); + } if (proxyOptions.vercelRelayUrl) { const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; - const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none"; + const poolId = proxyOptions.proxyPoolId || "none"; log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | vercel-relay=${proxyOptions.vercelRelayUrl}`); } else if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) { let maskedProxyUrl = proxyOptions.connectionProxyUrl; @@ -355,7 +389,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Keep raw if URL parsing fails } - const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none"; + const poolId = proxyOptions.proxyPoolId || "none"; const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown"; log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | url=${maskedProxyUrl}`); } @@ -365,23 +399,64 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log?.debug?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | no_proxy=${proxyOptions.connectionNoProxy}`); } + // Execute request — with pool-scoped retry: a failed pool (region gate, + // per-IP limit, dead proxy) is marked unfit and the request retried via + // another pool instead of failing the account. Covers both thrown errors + // (executor.execute) and non-ok responses declared poolScoped via + // parseError — poolId/scope are completed here from proxyOptions. + let parsedNonOk = null; + + const tryNextPool = async (poolScoped, reasonMsg) => { + const failed = { + poolId: poolScoped?.poolId || proxyOptions.proxyPoolId || null, + scope: poolScoped?.scope || proxyScope, + reason: poolScoped?.reason || "pool-scoped", + }; + markPoolUnfit(failed.poolId, failed.scope, undefined, failed.reason); + log?.warn?.("PROXY", `${provider.toUpperCase()} | pool ${failed.poolId || "?"} unfit for ${failed.scope} (${failed.reason}) — retry with another pool. ${reasonMsg || ""}`); + try { + const resolved = await resolveProxyConfig(credentials, [failed.poolId]); + if (resolved?.proxyPoolId) { + credentials.providerSpecificData = { ...(credentials.providerSpecificData || {}), ...resolved }; + proxyOptions = buildProxyOptions(credentials.providerSpecificData); + return true; + } + } catch (resolverError) { + // A resolver failure must not mask the original pool error. + log?.warn?.("PROXY", `${provider.toUpperCase()} | pool re-resolve failed: ${resolverError.message}`); + } + return false; + }; + + const executeWithPoolFallback = async (attempt = 0) => { + let result; + try { + result = await executor.execute({ model, body: translatedBody, stream, credentials, providerSessionId: sessionSeed, clientTool, signal: streamController.signal, log, proxyOptions }); + } catch (error) { + if (error?.poolScoped && typeof resolveProxyConfig === "function" && attempt < MAX_POOL_RETRIES) { + if (await tryNextPool(error.poolScoped, error.message)) return executeWithPoolFallback(attempt + 1); + } + throw error; + } + // Non-ok response that the executor declared pool/IP-scoped (e.g. opencode + // free per-IP limit) — parse once, retry via another pool when possible. + if (!result.response.ok) { + const parsed = await parseUpstreamError(result.response, executor); + if (parsed.poolScoped && typeof resolveProxyConfig === "function" && attempt < MAX_POOL_RETRIES) { + if (await tryNextPool(parsed.poolScoped, parsed.message)) return executeWithPoolFallback(attempt + 1); + } + parsedNonOk = parsed; + } + return result; + }; + // Execute request let providerResponse, providerUrl, providerHeaders, finalBody; // Most executors return their registry format. Cursor AgentService is an // exception: it is decoded by the executor into OpenAI-compatible output. let providerResponseFormat = targetFormat; try { - const result = await executor.execute({ - model, - body: translatedBody, - stream, - credentials, - providerSessionId: sessionSeed, - clientTool, - signal: streamController.signal, - log, - proxyOptions, - }); + const result = await executeWithPoolFallback(); providerResponse = result.response; providerUrl = result.url; providerHeaders = result.headers; @@ -412,9 +487,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred } const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY); if (log?.errorLine) { - log.errorLine(reqTag, "✗", `ERROR 502 · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}${error.stack ? `\n ${error.stack}` : ""}`); + log.errorLine(reqTag, "✗", `ERROR ${error?.status || HTTP_STATUS.BAD_GATEWAY} · ${provider}/${model} · ${Date.now() - requestStartTime}ms\n ${errMsg}`); } - return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg); + // Carry the executor's own status (429/409 quota gates) when it threw one; + // fall back to 502 for generic throws. + return createErrorResult(error?.status || HTTP_STATUS.BAD_GATEWAY, errMsg, error?.resetsAtMs || undefined); } // Handle 401/403 - try token refresh (skip for noAuth providers) @@ -467,7 +544,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred // Provider returned error if (!providerResponse.ok) { trackPendingRequest(model, provider, connectionId, false, true); - const { statusCode, message, resetsAtMs } = await parseUpstreamError(providerResponse, executor); + const { statusCode, message, resetsAtMs } = parsedNonOk || await parseUpstreamError(providerResponse, executor); appendRequestLog({ model, provider, connectionId, status: `FAILED ${statusCode}` }).catch(() => { }); saveRequestDetail(buildRequestDetail({ provider, model, connectionId, diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 19018147..8c21daf2 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -140,6 +140,22 @@ function openAICompletionToResponses(responseBody, customToolNames = null) { }; } +/** + * Unwrap gateway envelopes around an OpenAI Chat Completions body. + * Some OpenAI-compatible gateways wrap the body in a `data` envelope + * (Cline: {data, success}) — without this, choices/usage don't resolve at + * top level downstream and surface as "no completion choices". + * Generic guard, no provider hardcode: only fires when the OpenAI body is + * nested under `data`. Callers decide when to apply it — the handler scopes + * it to providers opting in via transport.quirks.clineEnvelope. + */ +export function unwrapDataEnvelope(responseBody) { + if (responseBody && !responseBody.choices && responseBody.data?.choices) { + return responseBody.data; + } + return responseBody; +} + /** * Translate non-streaming response body from provider format → OpenAI format. */ @@ -312,6 +328,8 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m responseBody = unwrapClineEnvelope(responseBody, provider); reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody); + // Unwrap AFTER logging (raw envelope stays in the log for forensics) but + // BEFORE usage extraction/translation so choices/usage resolve downstream. if (onRequestSuccess) { Promise.resolve() .then(onRequestSuccess) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 93486760..8e20a8e5 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -307,6 +307,12 @@ export const PATTERN_CAPABILITIES = [ { pattern: "*gpt-3.5*", caps: { contextWindow: 16385, maxOutput: 4096 } }, { pattern: "*gpt-oss*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } }, + // ── Cline free tier: Upstage Solar Pro + LongCat (agentic coding models; + // windows unverified — 200K/32K conservative, same as laguna:free). + // NOTE: placed before the o-series catch-alls — "solar-pro4" contains "o4". + { pattern: "*solar-pro*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, + { pattern: "*longcat*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, + // ── OpenAI o-series (reasoning, vision) ────────────────────────── { pattern: "*o1-mini*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } }, { pattern: "*o1*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } }, diff --git a/open-sse/providers/registry/cline.js b/open-sse/providers/registry/cline.js index 90b16c4b..340f5de3 100644 --- a/open-sse/providers/registry/cline.js +++ b/open-sse/providers/registry/cline.js @@ -14,8 +14,9 @@ export default { }, }, category: "oauth", - authModes: ["oauth"], + authModes: ["oauth", "apikey"], hasOAuth: true, + authHint: "API key dari app.cline.bot → Settings > API Keys, atau login OAuth via browser.", transport: { baseUrl: "https://api.cline.bot/api/v1/chat/completions", headers: { @@ -30,6 +31,9 @@ export default { combined: true, header: "Authorization", scheme: "bearer", + // Hook owns Authorization: workos:-prefixed OAuth vs plain API key + // (a merged token can't express both) — see applyAuth. + preserveHookAuth: true, hooks: [ "clineHeaders", ], @@ -44,6 +48,16 @@ export default { { id: "google/gemini-3.1-pro-preview", name: "Gemini 3.1 Pro Preview" }, { id: "google/gemini-3.1-flash-lite-preview", name: "Gemini 3.1 Flash Lite Preview" }, { id: "kwaipilot/kat-coder-pro", name: "KAT Coder Pro" }, + // Free tier (verified live via GET /api/v1/ai/cline/recommended-models): + // billed $0 on usage, limited quota separate from ClinePass. The + // cline-free/* aliases require Cline product headers (see shared/clineAuth.js) + // or upstream 403s with "only available via Cline product surfaces". + { id: "cline-free/muse-spark-1.3-contributor", name: "Muse Spark 1.3 (Free)" }, + { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash (Free)" }, + { id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash (Free)" }, + { id: "cline-free/solar-pro4", name: "Solar Pro 4 (Free)" }, + { id: "cline-free/longcat-2.0", name: "LongCat 2.0 (Free)" }, + { id: "poolside/laguna-s-2.1:free", name: "Laguna S 2.1 (Free)" }, ], oauth: { appBaseUrl: "https://app.cline.bot", diff --git a/open-sse/providers/registry/clinepass.js b/open-sse/providers/registry/clinepass.js index fc21590f..4ff9436c 100644 --- a/open-sse/providers/registry/clinepass.js +++ b/open-sse/providers/registry/clinepass.js @@ -31,6 +31,9 @@ export default { combined: true, header: "Authorization", scheme: "bearer", + // Hook owns Authorization: workos:-prefixed OAuth vs plain API key + // (a merged token can't express both) — see applyAuth. + preserveHookAuth: true, hooks: [ "clineHeaders", ], diff --git a/open-sse/providers/registry/freebuff.js b/open-sse/providers/registry/freebuff.js new file mode 100644 index 00000000..fffee205 --- /dev/null +++ b/open-sse/providers/registry/freebuff.js @@ -0,0 +1,106 @@ +/** + * Freebuff — the free, ad-supported coding agent by Codebuff (freebuff.com). + * + * The Freebuff CLI (github.com/CodebuffAI/freebuff) is an interactive TUI that + * talks to the Codebuff/Freebuff backend. Two hosts are involved: + * - login flow (freebuff mode) runs on https://freebuff.com + * POST /api/auth/cli/code {fingerprintId} → { loginUrl, fingerprintHash, expiresAt } + * open loginUrl in browser, then GET /api/auth/cli/status until {user}. + * (The server echoes the request host into loginUrl, so calling + * freebuff.com yields freebuff.com/login?auth_code=… exactly like the + * official CLI — www.codebuff.com would yield the wrong link.) + * - LLM traffic goes to the OpenAI-compatible endpoint on + * https://www.codebuff.com/api/v1/chat/completions + * (freebuff.com does NOT serve /api/v1/* — it 404s with the SPA shell.) + * + * Both hosts share one backend: the authToken obtained via the freebuff.com + * login validates against www.codebuff.com (Bearer auth). The request body + * must carry the CLI's `codebuff` provider block + * (`codebuff_metadata.run_id/client_id/cost_mode`) — injected by + * executors/freebuff.js. cost_mode:"free" is what admits a session on the free + * (country-gated, session-limited) tier instead of billing credits. + */ +export default { + id: "freebuff", + priority: 45, + hasFree: true, + alias: "fb", + uiAlias: "fb", + display: { + name: "Freebuff", + icon: "bolt", + color: "#84CC16", + textIcon: "FB", + website: "https://freebuff.com", + notice: { + signupUrl: "https://freebuff.com", + text: "Free ad-supported coding agent by Codebuff. Sign in with your Freebuff/Codebuff account via browser login. Each model is priced in Freebucks per hour of session, charged once when the session starts. Your daily Freebucks refill at midnight Pacific; the wallet keeps what you buy or earn. Free tier is ad-supported and limited in some regions (limited mode: 6 x 1-hour sessions/day); full mode runs in select countries. ⚠️ One account has ONE active session locked to ONE model — requesting a different model while a session is active returns 'model_locked' (409); use a separate account per model, or wait for the session to expire.", + }, + }, + category: "free", + authType: "oauth", + authModes: ["oauth"], + hasOAuth: true, + transport: { + baseUrl: "https://www.codebuff.com/api/v1/chat/completions", + format: "openai", + headers: { + "User-Agent": "ai-sdk/openai-compatible/1.0/codebuff", + }, + retry: { + 429: { attempts: 2, delayMs: 2000 }, + 503: { attempts: 2, delayMs: 1500 }, + }, + // Session endpoint doubles as the quota API: GET /api/v1/freebuff/session + // returns the shared daily session quota (rateLimitsByModel) without + // claiming anything — POST would burn a session, so quota reads are GET + // only (see services/usage/freebuff.js). + usage: { + url: "https://www.codebuff.com/api/v1/freebuff/session", + }, + }, + features: { + usage: true, + }, + // Mirrors the Freebuff waiting-room picker (upstream FREEBUFF_MODELS) as of + // 2026-09-11. NO per-model prices live here: Freebucks pricing is + // server-authoritative — the session response's `freebucks` block carries + // `prices` (model → Freebucks/hr) plus an announced `priceChanges` schedule + // (promos like Solar Pro 4's Labor Day run expire server-side; see + // services/usage/freebuff.js which folds both in, exactly like the upstream + // CLI which hardcodes no number). Plus the capacity-limited Fable trial. + // deepseek-v4-pro and minimax-m3 were withdrawn upstream (2026-08-26 / + // 2026-08-20) and ox-alpha (2026-08-27) + gemini-3.8-flash (2026-09-03) + // never stuck — none are claimable anymore. + // z-ai/glm-5.2 is a referral reward (not a free pick), luna-es / kimi-k3-eco + // are god-only rows, and the `-max` variants are provisioned per-account — + // all intentionally omitted. Fable is a capacity-limited WAVE trial: sessions + // only claim while the backend advertises it via limitedModelOffers on the + // session status (the executor auto-checks before claiming); the model is + // otherwise refused. + // meta/muse-spark-1.3-contributor was withdrawn upstream 2026-09-07 (Meta + // returns 404 model_not_found on every key) and replaced in the picker by + // meta/muse-spark-1.2-contributor — same Contributor terms/pool. 1.3 is + // intentionally NOT listed: 9Router has no server-side coercion, so a dead + // pick would only fail. + // deepseek/deepseek-v4-flash was RENAMED upstream 2026-09-10 to DeepSeek + // V4.1 Flash (same undated wire id, new build) — display label follows. + models: [ + { id: "z-ai/glm-5.3-flash", name: "GLM 5.3 Flash" }, + { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4.1 Flash" }, + { id: "openai/gpt-5.6-luna", name: "GPT-5.6 Luna" }, + { id: "mimo/mimo-v2.5", name: "MiMo 2.5" }, + { id: "upstage/solar-pro4", name: "Solar Pro 4" }, + { id: "meta/muse-spark-1.2-contributor", name: "Muse Spark 1.2" }, + { id: "anthropic/claude-fable-5", name: "Claude Fable 5 (limited offer)" }, + ], + // Login-flow host — the CLI in freebuff mode logs in via freebuff.com, and + // the server builds loginUrl from the host it was called on, so the link the + // user opens must come from freebuff.com to match the official CLI. + oauth: { + baseUrl: "https://freebuff.com", + loginCodePath: "/api/auth/cli/code", + loginStatusPath: "/api/auth/cli/status", + oauthTimeoutMs: 300000, + }, +}; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 0e8dbe2e..54d51287 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -1,130 +1,127 @@ // Auto-generated: static imports for all registry entries import p0 from "./alicode-intl.js"; import p1 from "./alicode.js"; -import p2 from "./anthropic.js"; -import p3 from "./antigravity.js"; -import p4 from "./assemblyai.js"; -import p5 from "./aws-polly.js"; -import p6 from "./azure.js"; -import p7 from "./black-forest-labs.js"; -import p8 from "./blackbox.js"; -import p9 from "./brave-search.js"; -import p10 from "./byteplus.js"; -import p11 from "./cartesia.js"; -import p12 from "./cerebras.js"; -import p13 from "./chutes.js"; -import p14 from "./claude.js"; -import p15 from "./cline.js"; -import p16 from "./clinepass.js"; -import p17 from "./cloudflare-ai.js"; -import p18 from "./codebuddy-cn.js"; -import p19 from "./codex.js"; -import p20 from "./cohere.js"; -import p21 from "./comfyui.js"; -import p22 from "./commandcode.js"; -import p23 from "./coqui.js"; -import p24 from "./cursor.js"; -import p25 from "./deepgram.js"; -import p26 from "./deepseek.js"; -import p27 from "./edge-tts.js"; -import p28 from "./elevenlabs.js"; -import p29 from "./exa.js"; -import p30 from "./fal-ai.js"; -import p31 from "./featherless.js"; -import p32 from "./firecrawl.js"; -import p33 from "./fireworks.js"; -import p34 from "./gemini-cli.js"; -import p35 from "./gemini.js"; -import p36 from "./github.js"; -import p37 from "./gitlab.js"; -import p38 from "./glm-cn.js"; -import p39 from "./glm.js"; -import p40 from "./google-pse.js"; -import p41 from "./google-tts.js"; -import p42 from "./grok-cli.js"; -import p43 from "./grok-web.js"; -import p44 from "./groq.js"; -import p45 from "./huggingface.js"; -import p46 from "./hyperbolic.js"; -import p47 from "./iflow.js"; -import p48 from "./inworld.js"; -import p49 from "./jina-ai.js"; -import p50 from "./jina-reader.js"; -import p51 from "./kilocode.js"; -import p52 from "./kimchi.js"; -import p53 from "./kimi.js"; -import p54 from "./kiro.js"; -import p55 from "./linkup.js"; -import p56 from "./local-device.js"; -import p57 from "./mimo-free.js"; -import p58 from "./minimax-cn.js"; -import p59 from "./minimax.js"; -import p60 from "./mistral.js"; -import p61 from "./mmf.js"; -import p62 from "./nanobanana.js"; -import p63 from "./nebius.js"; -import p64 from "./nvidia.js"; -import p65 from "./ollama-local.js"; -import p66 from "./ollama.js"; -import p123 from "./ollama-search.js"; -import p67 from "./openai.js"; -import p68 from "./opencode-go.js"; -import p68z from "./opencode-zen.js"; -import p69 from "./opencode.js"; -import p70 from "./openrouter.js"; -import p71 from "./perplexity-web.js"; -import p72 from "./perplexity.js"; -import p73 from "./perplexity-agent.js"; -import p74 from "./playht.js"; -import p75 from "./qoder.js"; -import p124 from "./qoder-cn.js"; -import p77 from "./recraft.js"; -import p78 from "./runwayml.js"; -import p79 from "./sdwebui.js"; -import p80 from "./searchapi.js"; -import p81 from "./searxng.js"; -import p82 from "./serper.js"; -import p83 from "./siliconflow.js"; -import p84 from "./stability-ai.js"; -import p85 from "./tavily.js"; -import p86 from "./together.js"; -import p87 from "./topaz.js"; -import p88 from "./tortoise.js"; -import p89 from "./venice.js"; -import p90 from "./vercel-ai-gateway.js"; -import p91 from "./vertex-partner.js"; -import p92 from "./vertex.js"; -import p93 from "./volcengine-ark.js"; -import p94 from "./voyage-ai.js"; -import p95 from "./xai.js"; -import p96 from "./xiaomi-mimo.js"; -import p97 from "./xiaomi-tokenplan.js"; -import p98 from "./youcom.js"; -import p99 from "./alims-intl.js"; -import p100 from "./codebuddy-intl.js"; -// Temporarily hidden — no tool calling support (trae SOLO agent / windsurf gRPC skip ToolCallChunk). -// Re-enable by uncommenting both the import and the array entry below. -// import p102 from "./trae.js"; -import p103 from "./zed.js"; -import p105 from "./api-airforce.js"; -import p106 from "./baidu.js"; -import p107 from "./bazaarlink.js"; -import p108 from "./bluesminds.js"; -import p109 from "./kilo-gateway.js"; -import p110 from "./llm7.js"; -import p111 from "./sambanova.js"; -import p112 from "./tencent.js"; -import p113 from "./morph.js"; -// import p114 from "./devin-cli.js"; -// import p104 from "./windsurf.js"; -import p115 from "./poolside.js"; -import p116 from "./tokenrouter.js"; -import p117 from "./selfhosted-stt.js"; -import p118 from "./selfhosted-tts.js"; -import p119 from "./selfhosted-embedding.js"; -import p120 from "./fish-audio.js"; -import p121 from "./alitp-intl.js"; -import p122 from "./xquik.js"; +import p2 from "./alims-intl.js"; +import p3 from "./alitp-intl.js"; +import p4 from "./anthropic.js"; +import p5 from "./antigravity.js"; +import p6 from "./api-airforce.js"; +import p7 from "./assemblyai.js"; +import p8 from "./aws-polly.js"; +import p9 from "./azure.js"; +import p10 from "./baidu.js"; +import p11 from "./bazaarlink.js"; +import p12 from "./black-forest-labs.js"; +import p13 from "./blackbox.js"; +import p14 from "./bluesminds.js"; +import p15 from "./brave-search.js"; +import p16 from "./byteplus.js"; +import p17 from "./cartesia.js"; +import p18 from "./cerebras.js"; +import p19 from "./chutes.js"; +import p20 from "./claude.js"; +import p21 from "./cline.js"; +import p22 from "./clinepass.js"; +import p23 from "./cloudflare-ai.js"; +import p24 from "./codebuddy-cn.js"; +import p25 from "./codebuddy-intl.js"; +import p26 from "./codex.js"; +import p27 from "./cohere.js"; +import p28 from "./comfyui.js"; +import p29 from "./commandcode.js"; +import p30 from "./coqui.js"; +import p31 from "./cursor.js"; +import p32 from "./deepgram.js"; +import p33 from "./deepseek.js"; +import p34 from "./edge-tts.js"; +import p35 from "./elevenlabs.js"; +import p36 from "./exa.js"; +import p37 from "./fal-ai.js"; +import p38 from "./featherless.js"; +import p39 from "./firecrawl.js"; +import p40 from "./fireworks.js"; +import p41 from "./fish-audio.js"; +import p42 from "./freebuff.js"; +import p43 from "./gemini-cli.js"; +import p44 from "./gemini.js"; +import p45 from "./github.js"; +import p46 from "./gitlab.js"; +import p47 from "./glm-cn.js"; +import p48 from "./glm.js"; +import p49 from "./google-pse.js"; +import p50 from "./google-tts.js"; +import p51 from "./grok-cli.js"; +import p52 from "./grok-web.js"; +import p53 from "./groq.js"; +import p54 from "./huggingface.js"; +import p55 from "./hyperbolic.js"; +import p56 from "./iflow.js"; +import p57 from "./inworld.js"; +import p58 from "./jina-ai.js"; +import p59 from "./jina-reader.js"; +import p60 from "./kilo-gateway.js"; +import p61 from "./kilocode.js"; +import p62 from "./kimchi.js"; +import p63 from "./kimi.js"; +import p64 from "./kiro.js"; +import p65 from "./linkup.js"; +import p66 from "./llm7.js"; +import p67 from "./local-device.js"; +import p68 from "./mimo-free.js"; +import p69 from "./minimax-cn.js"; +import p70 from "./minimax.js"; +import p71 from "./mistral.js"; +import p72 from "./mmf.js"; +import p73 from "./morph.js"; +import p74 from "./nanobanana.js"; +import p75 from "./nebius.js"; +import p76 from "./nvidia.js"; +import p77 from "./ollama-local.js"; +import p78 from "./ollama-search.js"; +import p79 from "./ollama.js"; +import p80 from "./openai.js"; +import p81 from "./opencode-go.js"; +import p82 from "./opencode-zen.js"; +import p83 from "./opencode.js"; +import p84 from "./openrouter.js"; +import p85 from "./perplexity-agent.js"; +import p86 from "./perplexity-web.js"; +import p87 from "./perplexity.js"; +import p88 from "./playht.js"; +import p89 from "./poolside.js"; +import p90 from "./qoder-cn.js"; +import p91 from "./qoder.js"; +import p92 from "./recraft.js"; +import p93 from "./runwayml.js"; +import p94 from "./sambanova.js"; +import p95 from "./sdwebui.js"; +import p96 from "./searchapi.js"; +import p97 from "./searxng.js"; +import p98 from "./selfhosted-embedding.js"; +import p99 from "./selfhosted-stt.js"; +import p100 from "./selfhosted-tts.js"; +import p101 from "./serper.js"; +import p102 from "./siliconflow.js"; +import p103 from "./stability-ai.js"; +import p104 from "./tavily.js"; +import p105 from "./tencent.js"; +import p106 from "./together.js"; +import p107 from "./tokenrouter.js"; +import p108 from "./topaz.js"; +import p109 from "./tortoise.js"; +import p110 from "./venice.js"; +import p111 from "./vercel-ai-gateway.js"; +import p112 from "./vertex-partner.js"; +import p113 from "./vertex.js"; +import p114 from "./volcengine-ark.js"; +import p115 from "./voyage-ai.js"; +import p116 from "./xai.js"; +import p117 from "./xiaomi-mimo.js"; +import p118 from "./xiaomi-tokenplan.js"; +import p119 from "./xquik.js"; +import p120 from "./youcom.js"; +import p121 from "./zed.js"; + export default [ p0, p1, @@ -193,11 +190,8 @@ export default [ p64, p65, p66, - p123, - p124, p67, p68, - p68z, p69, p70, p71, @@ -205,6 +199,7 @@ export default [ p73, p74, p75, + p76, p77, p78, p79, @@ -229,8 +224,10 @@ export default [ p98, p99, p100, - // p102, // trae — hidden, no tool calling + p101, + p102, p103, + p104, p105, p106, p107, @@ -240,8 +237,7 @@ export default [ p111, p112, p113, - // p114, // devin-cli — hidden, spawns local agent with shell/fs access - // p104, // windsurf — hidden, no tool calling + p114, p115, p116, p117, @@ -249,5 +245,4 @@ export default [ p119, p120, p121, - p122, ]; diff --git a/open-sse/services/clinepassModels.js b/open-sse/services/clinepassModels.js index 0aa96ffe..f656afd1 100644 --- a/open-sse/services/clinepassModels.js +++ b/open-sse/services/clinepassModels.js @@ -5,17 +5,11 @@ const FETCH_TIMEOUT_MS = 5000; /** * Build request headers for the ClinePass /models endpoint (Cline's upstream API). - * - API keys are sent as plain Bearer tokens. - * - OAuth access tokens must carry the WorkOS `workos:` prefix (handled by buildClineHeaders). + * Auth shape lives in shared/clineAuth: API keys ride plain Bearer, OAuth + * access tokens carry the WorkOS `workos:` prefix. */ function buildModelListHeaders(token, isApiKey) { - if (isApiKey) { - return { - Accept: "application/json", - Authorization: `Bearer ${token}`, - }; - } - return buildClineHeaders(token, { Accept: "application/json" }); + return buildClineHeaders(token, { Accept: "application/json" }, { isApiKey }); } /** diff --git a/open-sse/services/poolGeo.js b/open-sse/services/poolGeo.js new file mode 100644 index 00000000..4a43b316 --- /dev/null +++ b/open-sse/services/poolGeo.js @@ -0,0 +1,176 @@ +// Pool egress geo — engine layer, shared by the dashboard UI (Proxy Fitness / +// Proxy Pools egress column) and any provider region policy that wants to +// pre-mark pools unfit by egress region. +// +// The probe transport is provider-agnostic: it fetches a geo service THROUGH +// the pool itself (relay headers for vercel/cloudflare/deno, proxy URL for +// socks/http), so it works for every pool type and every provider. +// +// A chain of free endpoints is tried in order so a rate-limited or broken +// provider drops to the next; ipinfo (the most quota-bound) is last. +// +// State lives on globalThis (same reason as proxyPoolFitness): the background +// probe and the /api/proxy-pools reader must share ONE cache across Next dev +// bundles. + +const GEO_STATE_KEY = "__9routerPoolGeo__"; +const geoCache = (globalThis[GEO_STATE_KEY] ??= new Map()); // poolId -> { ip, country, ..., ts, ipHistory } + +export const POOL_GEO_TTL_MS = 60 * 60 * 1000; +// How many past egress IPs to remember for flapping detection. +export const POOL_GEO_IP_HISTORY_MAX = 8; + +// Test helper: drop all cached geo (module state is globalThis-backed). +export function resetPoolGeo() { + geoCache.clear(); +} + +// Attach stability classification: >=2 distinct egress IPs observed = flapping +// (typical for serverless relays — Vercel/Cloudflare egress varies per colo). +function withStability(entry) { + const ips = new Set([entry?.ip, ...(entry?.ipHistory || []).map((h) => h.ip)]); + ips.delete(""); + const ipCount = ips.size; + return { ...entry, ipCount, isUnstable: ipCount >= 2 }; +} + +export function getPoolGeo(poolId) { + const entry = geoCache.get(poolId); + if (!entry) return null; + if (entry.ts + POOL_GEO_TTL_MS < Date.now()) { + geoCache.delete(poolId); + return null; + } + return withStability(entry); +} + +export function setPoolGeo(poolId, geo) { + if (!poolId || !geo?.ip) return; + const prev = geoCache.get(poolId); + const ipHistory = prev?.ipHistory ? [...prev.ipHistory] : []; + if (prev?.ip && prev.ip !== geo.ip) { + // Record the IP we are leaving — the history tracks past egress IPs. + ipHistory.push({ ip: prev.ip, ts: Date.now() }); + if (ipHistory.length > POOL_GEO_IP_HISTORY_MAX) ipHistory.shift(); + } + geoCache.set(poolId, { ...geo, ts: Date.now(), ipHistory }); +} + +export function poolGeoSnapshot(now = Date.now()) { + const out = {}; + for (const [poolId, entry] of geoCache) { + if (entry.ts + POOL_GEO_TTL_MS <= now) { + geoCache.delete(poolId); + continue; + } + out[poolId] = withStability(entry); + } + return out; +} + +// Sweep geo entries past their TTL (ipHistory rides along with the entry). +// Returns how many entries were removed. +export function pruneStaleGeo(now = Date.now()) { + let removed = 0; + for (const [poolId, entry] of geoCache) { + if (entry.ts + POOL_GEO_TTL_MS <= now) { + geoCache.delete(poolId); + removed += 1; + } + } + return removed; +} + +// Normalize a single provider's geo payload to { ip, country, region, city, org }. +const GEO_PARSE = { + "ipwho.is": (d) => ({ ip: d?.ip, country: d?.country, region: d?.region, city: d?.city, org: d?.org || d?.connection?.org }), + "ip-api": (d) => ({ ip: d?.query, country: d?.country, region: d?.regionName, city: d?.city, org: d?.org }), + "ipapi.co": (d) => ({ ip: d?.ip, country: d?.country_name, region: d?.region, city: d?.city, org: d?.org }), + ipinfo: (d) => ({ ip: d?.ip, country: d?.country, region: d?.region, city: d?.city, org: d?.org }), +}; + +// Tried in order — rate-friendly first, quota-bound ipinfo last. +const GEO_PROBES = [ + { name: "ipwho.is", url: "https://ipwho.is/" }, + { name: "ip-api", url: "https://ip-api.com/json/?fields=status,message,query,country,regionName,city,org" }, + { name: "ipapi.co", url: "https://ipapi.co/json/" }, + { name: "ipinfo", url: "https://ipinfo.io/json" }, +]; + +// Custom single endpoint via env (replaces the chain). Keys mapped generically. +function customGeoParse(d) { + if (!d || typeof d !== "object") return null; + const pick = (...keys) => keys.map((k) => d[k]).find((v) => v != null && v !== ""); + return { + ip: pick("query", "ip"), + country: pick("country", "country_name", "countryName"), + region: pick("regionName", "region", "state", "regionCode"), + city: pick("city"), + org: pick("org", "organization", "isp", "orgName", "connection", "asn"), + }; +} + +function probeSource({ proxyAwareFetch, url, proxyOptions, timeoutMs, name }) { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(new Error("geo probe timeout")), timeoutMs); + return (async () => { + try { + const res = await proxyAwareFetch(url, { signal: ctrl.signal }, proxyOptions); + if (!res.ok) { + const txt = await res.text().catch(() => ""); + const error = res.status === 429 || res.status === 403 ? "rate-limit" + : res.status >= 500 ? "server" + : "network"; + return { ok: false, error, detail: `${res.status} ${txt.slice(0, 80)}` }; + } + const data = await res.json().catch(() => null); + const parsed = name ? GEO_PARSE[name]?.(data) : customGeoParse(data); + if (!parsed?.ip) return { ok: false, error: "no-ip", detail: `${name || "custom"}` }; + return { + ok: true, + geo: { + ip: String(parsed.ip || "").trim(), + country: String(parsed.country || "").trim(), + region: String(parsed.region || "").trim(), + city: String(parsed.city || "").trim(), + org: String(parsed.org || "").trim(), + isDatacenter: /(cloudflare|vercel|amazon|aws|google|microsoft|azure|digitalocean|hetzner|ovh|contabo|leaseweb)/i.test( + String(parsed.org || ""), + ), + }, + }; + } catch (error) { + const timedOut = ctrl.signal?.aborted && error?.name === "AbortError"; + return { ok: false, error: timedOut ? "timeout" : "network", detail: `${error?.name}: ${error?.message}` }; + } finally { + clearTimeout(timer); + } + })(); +} + +// Probe the egress geo of one pool through a chain of geo providers. Fail-open: +// returns { ok:true, geo } on success, { ok:false, error } otherwise with +// `error` one of "rate-limit" | "server" | "no-ip" | "network" | "timeout". +// `pool` shape: { proxyUrl, type }. 15s timeout per endpoint by default. +export async function probePoolGeo(pool, timeoutMs = 15000) { + const proxyUrl = pool?.proxyUrl; + if (!proxyUrl) return { ok: false, error: "network", detail: "no proxy url" }; + const { proxyAwareFetch } = await import("../utils/proxyFetch.js"); + const isRelay = ["vercel", "cloudflare", "deno"].includes(pool?.type); + const proxyOptions = isRelay + ? { vercelRelayUrl: proxyUrl } + : { connectionProxyEnabled: true, connectionProxyUrl: proxyUrl }; + + const custom = process.env.GEO_PROBE_URL ? [{ name: null, url: process.env.GEO_PROBE_URL }] : []; + const probes = custom.length ? custom : GEO_PROBES; + + let last = { ok: false, error: "network", detail: "no provider responded" }; + for (const p of probes) { + const res = await probeSource({ proxyAwareFetch, url: p.url, proxyOptions, timeoutMs, name: p.name }); + if (res.ok) return res; + // Prefer the most specific failure: rate-limit/server over generic network. + if (res.error === "rate-limit" || res.error === "server") last = res; + else if (last.error === "network") last = res; + } + return last; +} diff --git a/open-sse/services/proxyPoolFitness.js b/open-sse/services/proxyPoolFitness.js new file mode 100644 index 00000000..cd43165e --- /dev/null +++ b/open-sse/services/proxyPoolFitness.js @@ -0,0 +1,167 @@ +// Pool fitness registry — shared, in-memory, engine layer. +// +// Rotation strategies can opt into region/provider-aware pool selection: an +// executor that learns a pool's egress is unfit for a provider/model (e.g. +// Freebuff limited-mode IP) marks it here, and the pool picker skips it for +// that scope until the cooldown expires. +// +// Scope format: `provider::model` (e.g. "freebuff::openai/gpt-5.6-luna"). +// All functions are fail-open: unknown pool/scope ⇒ fit. + +// Scope format: `provider::model` (e.g. "freebuff::openai/gpt-5.6-luna"). +// All functions are fail-open: unknown pool/scope ⇒ fit. +// +// State lives on globalThis so Next dev (Turbopack) never splits one Map into +// several per-bundle copies — the executor/chatCore marks and the +// /api/proxy-pools/fitness reader must share the SAME registry. + +const FITNESS_STATE_KEY = "__9routerPoolFitness__"; +const fitness = (globalThis[FITNESS_STATE_KEY] ??= new Map()); // poolId -> Map +let persistTimer = null; +let hydratePromise = null; + +export const POOL_UNFIT_MS = 5 * 60 * 1000; + +function schedulePersist() { + if (persistTimer) return; + persistTimer = setTimeout(async () => { + persistTimer = null; + try { + const { updateSettings } = await import("@/lib/db/repos/settingsRepo.js"); + await updateSettings({ proxyPoolFitness: poolFitnessSnapshot() }); + } catch { + // Fitness is advisory; persistence failures must not block requests. + } + }, 25); + if (persistTimer.unref) persistTimer.unref(); +} + +export function hydratePoolFitness(snapshot = {}) { + for (const [poolId, byScope] of Object.entries(snapshot || {})) { + const entries = Object.entries(byScope || {}).filter(([, entry]) => Number(entry?.until) > Date.now()); + if (entries.length) fitness.set(poolId, new Map(entries)); + } +} + +export async function ensurePoolFitnessHydrated() { + if (!hydratePromise) { + hydratePromise = import("@/lib/db/repos/settingsRepo.js") + .then(({ getSettings }) => getSettings()) + .then((settings) => hydratePoolFitness(settings.proxyPoolFitness || {})) + .catch(() => {}) + .then(() => undefined); + } + return hydratePromise; +} + +export function markPoolUnfit(poolId, scope, until = Date.now() + POOL_UNFIT_MS, reason = "") { + if (!poolId || !scope) return; + const byScope = fitness.get(poolId) || new Map(); + byScope.set(scope, { until, reason }); + fitness.set(poolId, byScope); + schedulePersist(); +} + +export function clearPoolUnfit(poolId, scope) { + const byScope = fitness.get(poolId); + if (!byScope) return; + byScope.delete(scope); + if (byScope.size === 0) fitness.delete(poolId); + schedulePersist(); +} + +// "provider::model" -> "provider::*" (null when the scope has no provider part) +function providerWildcardScope(scope) { + const sep = String(scope || "").indexOf("::"); + if (sep < 0) return null; + return `${scope.slice(0, sep)}::*`; +} + +export function isPoolFit(poolId, scope, now = Date.now()) { + if (!poolId) return true; + const byScope = fitness.get(poolId); + if (!byScope) return true; + // A provider-wide mark ("provider::*", e.g. manual blocks) also covers any + // model lookup for that provider. + const candidates = [scope, providerWildcardScope(scope)]; + for (const key of candidates) { + if (!key) continue; + const entry = byScope.get(key); + if (!entry) continue; + if (entry.until <= now) { + byScope.delete(key); + if (byScope.size === 0) fitness.delete(poolId); + continue; + } + return false; + } + return true; +} + +// Keep only pool ids that are not in cooldown for the scope. +export function fitPoolIds(poolIds, scope, now = Date.now()) { + return (poolIds || []).filter((id) => isPoolFit(id, scope, now)); +} + +// Clear every mark — or only scopes belonging to one provider (`provider::*`). +export function clearAllPoolUnfit(provider = null) { + if (provider) { + const prefix = `${provider}::`; + for (const [poolId, byScope] of fitness) { + for (const scope of [...byScope.keys()]) { + if (scope.startsWith(prefix)) byScope.delete(scope); + } + if (byScope.size === 0) fitness.delete(poolId); + } + schedulePersist(); + return; + } + fitness.clear(); + schedulePersist(); +} + +// Snapshot of live (non-expired) marks — expired entries are pruned here so +// consumers never see stale data and memory stays bounded. +// Test helper: drop all marks (module state is globalThis-backed). +export function resetPoolFitness() { + fitness.clear(); + hydratePromise = Promise.resolve(); + schedulePersist(); +} + +// Sweep all expired marks. Returns how many scope entries were removed. +export function pruneExpired(now = Date.now()) { + let removed = 0; + for (const [poolId, byScope] of fitness) { + for (const [scope, entry] of byScope) { + if (entry.until <= now) { + byScope.delete(scope); + removed += 1; + } + } + if (byScope.size === 0) fitness.delete(poolId); + } + if (removed) schedulePersist(); + return removed; +} + +export function poolFitnessSnapshot(now = Date.now()) { + const out = {}; + for (const [poolId, byScope] of fitness) { + let pruned = false; + for (const [scope, entry] of byScope) { + if (entry.until <= now) { + byScope.delete(scope); + pruned = true; + } + } + if (byScope.size === 0) { + fitness.delete(poolId); + continue; + } + if (pruned || byScope.size > 0) { + out[poolId] = Object.fromEntries(byScope); + } + } + return out; +} diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index b431e21a..e8f9f8a0 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -14,6 +14,7 @@ import { getCodeBuddyCnUsage, getCodeBuddyIntlUsage } from "./usage/codebuddy-cn import { getGrokCliUsage } from "./usage/grok-cli.js"; import { getKimiUsage } from "./usage/kimi.js"; import { getDeepseekUsage } from "./usage/deepseek.js"; +import { getFreebuffUsage } from "./usage/freebuff.js"; import { getOpenCodeGoUsage } from "./usage/opencode-go.js"; import { getOpenCodeZenUsage } from "./usage/opencode-zen.js"; import { getGroqUsage } from "./usage/groq.js"; @@ -58,6 +59,7 @@ const USAGE_HANDLERS = { "opencode-go": (c) => getOpenCodeGoUsage(c.apiKey, c.proxyOptions), "opencode-zen": (c) => getOpenCodeZenUsage(c.apiKey, c.proxyOptions), deepseek: (c) => getDeepseekUsage(c.apiKey, c.proxyOptions), + freebuff: (c) => getFreebuffUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), groq: (c) => getGroqUsage(c.apiKey, c.proxyOptions), zed: (c) => getZedUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), "xiaomi-mimo": (c) => getXiaomiMimoUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), diff --git a/open-sse/services/usage/freebuff.js b/open-sse/services/usage/freebuff.js new file mode 100644 index 00000000..e8e5e333 --- /dev/null +++ b/open-sse/services/usage/freebuff.js @@ -0,0 +1,201 @@ +/** + * Freebuff usage handler + * + * Freebuff has no separate billing/quota API — the daily free/premium session + * quota lives on the session endpoint itself. Reading it MUST use + * GET /api/v1/freebuff/session (the CLI's status poll): POST would CLAIM a + * session and burn 1.0 unit of the daily quota, which a quota tracker must + * never do. + * + * The GET response carries the shared session quota as `rateLimitsByModel`, + * keyed by model id, on the pre-join (`none`), `active`, and `ended` states: + * { limit, recentCount, resetAt, period: 'pacific_day'|'pacific_week', + * resetTimeZone, entitlementBreakdown? } + * `recentCount` is fractional — a long agent run can consume 1.3 units — and + * includes the active session's own 1.0-unit reservation. `limit` can be + * raised by referral/streak rewards (entitlementBreakdown.base + referral + + * streak). + * + * Freebucks-metered accounts (2026-09-02+) get NO pool rows: the server sends + * a `freebucks` block instead — { balance, daily:{limit,spent,remaining,resetAt}, + * wallet:{balance,...}, monthly?:{limitUsd,spentUsd,remainingUsd,resetAt}, + * planId, prices:{model→Freebucks/hr}, priceNotices?, priceChanges? }. The + * daily pool replaces rateLimitsByModel, so the tracker folds it in by + * replicating the pool snapshot under every priced model id, exactly as the + * official picker does. + * + * Pricing is SERVER-AUTHORITATIVE and nothing here hardcodes a number: each + * row carries the live `prices[model]` (plus its `priceNotices` tagline), and + * `applyFreebucksPriceChanges` folds the server's announced `priceChanges` + * schedule into the map — a promo (e.g. Solar Pro 4's Labor Day 0-Freebucks + * offer) expires and reverts on the server's timeline with no client release, + * mirroring upstream common/src/util/freebuff-price-changes.ts. A compact + * account summary (`freebucks`) rides the response for the card header. + */ + +import REGISTRY from "../../providers/registry/index.js"; +import { U, fetchWithTimeout } from "./shared.js"; + +// Friendly labels from the registry model list (mirrors the CLI picker). +const freebuffRegistry = REGISTRY.find((r) => r.id === "freebuff") || {}; +const MODEL_LABELS = Object.fromEntries( + (freebuffRegistry.models || []).map((m) => [m.id, m.name]), +); + +// Fold the server's announced price schedule into the live price map — +// mirrors upstream applyFreebucksPriceChanges: due changes override the price +// AND its picker tagline, and drop off the pending list. +function applyFreebucksPriceChanges(info) { + const now = Date.now(); + const due = (info?.priceChanges || []).filter((c) => Date.parse(c.at) <= now); + if (due.length === 0) return info; + const prices = { ...(info.prices || {}) }; + const priceNotices = { ...(info.priceNotices || {}) }; + for (const change of due.sort((a, b) => Date.parse(a.at) - Date.parse(b.at))) { + if (prices[change.modelId] === undefined) continue; + prices[change.modelId] = change.price; + priceNotices[change.modelId] = change.tagline; + } + return { + ...info, + prices, + priceNotices, + priceChanges: (info.priceChanges || []).filter((c) => Date.parse(c.at) > now), + }; +} + +function sessionUrl() { + return U("freebuff").url; +} + +export async function getFreebuffUsage(accessToken, providerSpecificData, proxyOptions = null) { + if (!accessToken) { + return { message: "Freebuff credential not available — connect a Freebuff login first." }; + } + + try { + const response = await fetchWithTimeout( + sessionUrl(), + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "User-Agent": "codebuff-cli/0.0.138", + Accept: "application/json", + }, + }, + 15000, + proxyOptions, + ); + + if (response.status === 401) { + return { message: "Freebuff credential invalid or expired — re-login in the dashboard." }; + } + if (response.status === 403) { + // A 403 from the session endpoint is usually a server-side gate status + // (country_blocked / banned), not a credential problem — telling the + // user to re-login would be misleading (mirrors the CLI's + // callFreebuffSession 403 branch). + const body = await response.json().catch(() => ({})); + if (body?.status === "country_blocked") { + return { message: "Freebuff is not available in your region." }; + } + if (body?.status === "banned") { + return { message: "Your Freebuff account has been banned." }; + } + return { + message: `Freebuff quota access denied (403)${body?.message ? `: ${body.message}` : ""}.`, + }; + } + // 404 = no session row at all → pre-join state, no quota to report. + if (response.status === 404) { + return { plan: "Freebuff", message: "Freebuff connected. No session quota to report right now." }; + } + if (!response.ok) { + return { message: `Freebuff quota API error (${response.status}).` }; + } + + const data = await response.json().catch(() => ({})); + const rateLimits = { ...(data.rateLimitsByModel || {}) }; + // An active session carries its own `rateLimit` row — fold it in when the + // shared map omits the model (older servers). + if (data.status === "active" && data.rateLimit && !rateLimits[data.model]) { + rateLimits[data.model] = data.rateLimit; + } + + const quotas = {}; + for (const [model, rl] of Object.entries(rateLimits)) { + if (!rl || typeof rl !== "object") continue; + const used = Number(rl.recentCount); + const total = Number(rl.limit); + quotas[model] = { + used: Number.isFinite(used) ? used : 0, + total: Number.isFinite(total) ? total : 0, + resetAt: rl.resetAt || null, + unlimited: false, + // Daily/weekly Pacific session allowance replenishes at resetAt — the + // UI must say "Resets in", not "Expires in". + recurring: true, + ...(MODEL_LABELS[model] ? { displayName: MODEL_LABELS[model] } : {}), + }; + } + + // Freebucks meter: the daily Freebucks pool is the quota for metered + // accounts, replicated under each priced model like the picker's rings. + // Prices come off the wire (prices + priceChanges folded in); each row + // carries its own price/tagline, and a compact summary rides the response + // for the account header in the UI. + let freebucksSummary = null; + const rawFreebucks = data.freebucks; + if (rawFreebucks && rawFreebucks.daily && typeof rawFreebucks.daily === "object") { + const freebucks = applyFreebucksPriceChanges(rawFreebucks); + const spent = Number(freebucks.daily.spent); + const limit = Number(freebucks.daily.limit); + for (const [model, price] of Object.entries(freebucks.prices || {})) { + quotas[model] = { + used: Number.isFinite(spent) ? spent : 0, + total: Number.isFinite(limit) ? limit : 0, + resetAt: freebucks.daily.resetAt || null, + unlimited: false, + recurring: true, + price: Number.isFinite(Number(price)) ? Number(price) : undefined, + ...(freebucks.priceNotices?.[model] ? { priceNote: freebucks.priceNotices[model] } : {}), + ...(MODEL_LABELS[model] ? { displayName: MODEL_LABELS[model] } : {}), + }; + } + freebucksSummary = { + balance: Number.isFinite(Number(freebucks.balance)) ? Number(freebucks.balance) : null, + daily: { + limit: Number.isFinite(limit) ? limit : 0, + spent: Number.isFinite(spent) ? spent : 0, + remaining: Number.isFinite(Number(freebucks.daily.remaining)) ? Number(freebucks.daily.remaining) : 0, + resetAt: freebucks.daily.resetAt || null, + }, + wallet: { + balance: Number.isFinite(Number(freebucks.wallet?.balance)) ? Number(freebucks.wallet.balance) : 0, + }, + ...(freebucks.monthly && Number.isFinite(Number(freebucks.monthly.remainingUsd)) + ? { + monthly: { + remainingUsd: Number(freebucks.monthly.remainingUsd), + limitUsd: Number.isFinite(Number(freebucks.monthly.limitUsd)) ? Number(freebucks.monthly.limitUsd) : null, + resetAt: freebucks.monthly.resetAt || null, + }, + } + : {}), + }; + } + + const plan = data.accessTier === "limited" ? "Freebuff (Limited)" : "Freebuff"; + if (Object.keys(quotas).length === 0) { + return { plan, message: "Freebuff connected. No session quota to report right now." }; + } + const out = { plan, quotas }; + if (freebucksSummary) out.freebucks = freebucksSummary; + return out; + } catch (error) { + return { message: `Freebuff usage error: ${error.message}` }; + } +} + +export default getFreebuffUsage; diff --git a/open-sse/shared/clineAuth.js b/open-sse/shared/clineAuth.js index 541b060d..c79f69d0 100644 --- a/open-sse/shared/clineAuth.js +++ b/open-sse/shared/clineAuth.js @@ -1,6 +1,10 @@ -import pkg from "../../package.json" with { type: "json" }; - -const APP_VERSION = pkg.version || "0.0.0"; +// Cline CLI identity (mirrors apps/cli + sdk/packages/llms request-headers.ts +// in cline/cline). Upstream gates the cline-free/* model aliases to Cline +// product surfaces + recent client versions — requests sent as +// X-CLIENT-TYPE 9router are 403'd with "only available via Cline product +// surfaces". Verified live 2026-09-11: cline-cli/3.0.61 passes the gate. +const CLINE_CLIENT_TYPE = "cline-cli"; +const CLINE_CLIENT_VERSION = "3.0.61"; export function getClineAccessToken(token) { if (typeof token !== "string") return ""; @@ -21,17 +25,26 @@ export function getClineAuthorizationHeader(token) { return accessToken ? `Bearer ${accessToken}` : ""; } -export function buildClineHeaders(token, extraHeaders = {}) { - const authorization = getClineAuthorizationHeader(token); +export function buildClineHeaders(token, extraHeaders = {}, opts = {}) { + // API keys ride plain Bearer; OAuth access tokens must carry the WorkOS + // `workos:` prefix so the backend routes verification to WorkOS + // (cline/cline: "Prefixed with 'workos:'..."). Verified live 2026-09-11: + // plain sk_* works, workos:sk_* → 401. + const trimmed = typeof token === "string" ? token.trim() : ""; + const authorization = !trimmed + ? "" + : opts.isApiKey + ? `Bearer ${trimmed}` + : getClineAuthorizationHeader(trimmed); const headers = { "HTTP-Referer": "https://cline.bot", "X-Title": "Cline", - "User-Agent": `9Router/${APP_VERSION}`, - "X-PLATFORM": process.platform || "unknown", - "X-PLATFORM-VERSION": process.version || "unknown", - "X-CLIENT-TYPE": "9router", - "X-CLIENT-VERSION": APP_VERSION, - "X-CORE-VERSION": APP_VERSION, + "User-Agent": `Cline/${CLINE_CLIENT_VERSION}`, + "X-PLATFORM": "cli", + "X-PLATFORM-VERSION": CLINE_CLIENT_VERSION, + "X-CLIENT-TYPE": CLINE_CLIENT_TYPE, + "X-CLIENT-VERSION": CLINE_CLIENT_VERSION, + "X-CORE-VERSION": CLINE_CLIENT_VERSION, "X-IS-MULTIROOT": "false", ...extraHeaders, }; diff --git a/open-sse/utils/error.js b/open-sse/utils/error.js index 315723e3..d44a8344 100644 --- a/open-sse/utils/error.js +++ b/open-sse/utils/error.js @@ -69,7 +69,14 @@ export async function parseUpstreamError(response, executor = null) { const parsed = executor.parseError(response, bodyText); if (parsed && typeof parsed === "object") { const msg = parsed.message || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`; - return { statusCode: parsed.status || response.status, message: msg, resetsAtMs: parsed.resetsAtMs }; + return { + statusCode: parsed.status || response.status, + message: msg, + resetsAtMs: parsed.resetsAtMs, + // Executors declare IP/pool-scoped failures (e.g. per-IP rate limits) + // here; chatCore completes poolId/scope and retries via another pool. + poolScoped: parsed.poolScoped, + }; } } catch { /* fall through to default parsing */ } } diff --git a/open-sse/utils/stream.js b/open-sse/utils/stream.js index cc0c6e30..e6f2491d 100644 --- a/open-sse/utils/stream.js +++ b/open-sse/utils/stream.js @@ -381,8 +381,6 @@ export function createSSEStream(options = {}) { }, flush(controller) { - const evtSummary = Object.entries(eventTypeCounts).map(([k, v]) => `${k}=${v}`).join(",") || "none"; - dbg("SSE", `flush | provider=${provider} | model=${model} | recvLines=${sseLineCount} | emitted=${sseEmittedCount} | events=[${evtSummary}]`); trackPendingRequest(model, provider, connectionId, false); try { const remaining = decoder.decode(); diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index fce01706..bac14a99 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -1,6 +1,6 @@ // Stream handler with disconnect detection - shared for all providers import { STREAM_STALL_TIMEOUT_MS } from "../config/runtimeConfig.js"; -import { dbg, isDebugEnabled } from "./debugLog.js"; +import { dbg } from "./debugLog.js"; // Get HH:MM:SS timestamp function getTimeString() { @@ -79,7 +79,7 @@ export function createStreamController({ onDisconnect, onError, log, provider, m return; } - logStream("✗", `ERROR: ${error.message}${error.stack ? `\n ${error.stack}` : ""}`, true); + logStream("✗", `ERROR: ${error.message}`, true); onError?.(error); }, @@ -194,12 +194,7 @@ export function createDisconnectAwareStream(transformStream, streamController, o */ export function pipeWithDisconnect(providerResponse, transformStream, streamController, onAbortTerminal = null, stallTimeoutMs = STREAM_STALL_TIMEOUT_MS) { let stallTimer = null; - let chunkCount = 0; - let totalBytes = 0; - let lastChunkAt = Date.now(); let abortMessage = "upstream connection lost"; - const t0 = Date.now(); - const tag = "STREAM"; const clearStall = () => { if (stallTimer) { clearTimeout(stallTimer); stallTimer = null; } }; @@ -208,7 +203,6 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont stallTimer = setTimeout(() => { stallTimer = null; abortMessage = "stream stall timeout"; - dbg(tag, `STALL TIMEOUT ${stallTimeoutMs}ms | chunks=${chunkCount} | bytes=${totalBytes} | sinceLast=${Date.now() - lastChunkAt}ms`); streamController.handleError?.(new Error("stream stall timeout")); streamController.abort?.(); }, stallTimeoutMs); @@ -221,30 +215,20 @@ export function pipeWithDisconnect(providerResponse, transformStream, streamCont signal: streamController.signal, startTime: streamController.startTime, isConnected: () => streamController.isConnected(), - handleComplete: () => { dbg(tag, `complete | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleComplete(); }, - handleError: (e) => { dbg(tag, `error: ${e?.message} | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleError(e); }, - handleDisconnect: (r) => { dbg(tag, `disconnect: ${r} | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); streamController.handleDisconnect(r); }, + handleComplete: () => { clearStall(); streamController.handleComplete(); }, + handleError: (e) => { clearStall(); streamController.handleError(e); }, + handleDisconnect: (r) => { clearStall(); streamController.handleDisconnect(r); }, abort: () => { clearStall(); streamController.abort(); } }; armStall(); - dbg(tag, `pipe start | stallTimeout=${stallTimeoutMs}ms`); const upstreamTap = new TransformStream({ transform(chunk, controller) { - chunkCount++; - const sz = chunk?.byteLength || chunk?.length || 0; - totalBytes += sz; - const now = Date.now(); - const gap = now - lastChunkAt; - lastChunkAt = now; - if (isDebugEnabled && (chunkCount <= 5 || chunkCount % 20 === 0 || gap > 5000)) { - dbg(tag, `chunk #${chunkCount} | size=${sz}B | gap=${gap}ms | total=${totalBytes}B`); - } armStall(); controller.enqueue(chunk); }, - flush() { dbg(tag, `upstream EOF | chunks=${chunkCount} | bytes=${totalBytes} | dur=${Date.now() - t0}ms`); clearStall(); } + flush() { clearStall(); } }); const transformedBody = providerResponse.body diff --git a/package.json b/package.json index 4dbc5247..6adab7d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.86", +"version": "0.5.86", "description": "9Router web dashboard", "private": true, "scripts": { @@ -28,12 +28,13 @@ "bcryptjs": "^3.0.3", "chalk": "^5.6.2", "confbox": "^0.2.4", + "dompurify": "^3.4.13", "express": "^5.2.1", "http-proxy-middleware": "^3.0.5", "jose": "^6.1.3", "marked": "^18.0.1", "material-symbols": "^0.44.6", - "monaco-editor": "^0.55.1", + "monaco-editor": "^0.56.0", "next": "^16.1.6", "node-forge": "^1.3.3", "node-machine-id": "^1.1.12", @@ -61,5 +62,8 @@ "eslint-config-next": "16.1.6", "postcss": "^8.5.6", "tailwindcss": "^4" + }, + "overrides": { + "dompurify": "^3.4.13" } } diff --git a/public/providers/freebuff.png b/public/providers/freebuff.png new file mode 100644 index 00000000..4dc21151 Binary files /dev/null and b/public/providers/freebuff.png differ diff --git a/scripts/test-combo-autoswitch.mjs b/scripts/test-combo-autoswitch.mjs index 358ebcf9..e01402d6 100644 --- a/scripts/test-combo-autoswitch.mjs +++ b/scripts/test-combo-autoswitch.mjs @@ -2,9 +2,14 @@ // Sends text / image / search requests to a combo and reports which member ran. // node scripts/test-combo-autoswitch.mjs const BASE = process.env.BASE_URL || "http://localhost:20127"; -const KEY = process.env.API_KEY || "sk-6581be4f05a82b6b-uxy6jn-c8190ea8"; +const KEY = process.env.API_KEY; const COMBO = process.env.COMBO || "haha"; +if (!KEY) { + console.error("API_KEY environment variable is required"); + process.exit(1); +} + // 16x16 PNG (valid image so vision providers accept it). const PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAFklEQVR4nGO4I2JDEmIY1TCqYfhqAAAeBCwQ8YdREQAAAABJRU5ErkJggg=="; diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js index 685d053a..3628eb4c 100644 --- a/src/app/(dashboard)/dashboard/profile/page.js +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -10,6 +10,16 @@ import { APP_CONFIG } from "@/shared/constants/config"; import { LOCALE_COOKIE, normalizeLocale } from "@/i18n/config"; import { LOCALE_FLAGS } from "@/shared/constants/locales"; +function memoryLine(m) { + if (!m) return ""; + const fb = m.inMemory?.freebuff || {}; + return [ + `DB: ${m.dbPath || "-"} (${m.dbSizeMB || "-"})`, + `Data dir: ${m.dataDirSizeMB || "-"}`, + `In-memory: fitness=${m.inMemory?.fitnessPools ?? 0} pool(s) · geo=${m.inMemory?.geoPools ?? 0} pool(s) · freebuff sessions=${fb.sessions ?? 0}, locks=${fb.modelLocks ?? 0}, pool-limits=${fb.poolLimits ?? 0}`, + ].join("\n"); +} + function getLocaleFromCookie() { if (typeof document === "undefined") return "en"; const cookie = document.cookie @@ -72,6 +82,8 @@ export default function ProfilePage() { const certFileRef = useRef(null); const importFileRef = useRef(null); + const [memoryInfo, setMemoryInfo] = useState(null); + const [memoryLoading, setMemoryLoading] = useState(false); const [proxyForm, setProxyForm] = useState({ outboundProxyEnabled: false, outboundProxyUrl: "", @@ -752,6 +764,19 @@ export default function ProfilePage() { setShutdownOpen(false); }; + const handleMemoryCheck = async () => { + setMemoryLoading(true); + try { + const res = await fetch("/api/system/memory", { cache: "no-store" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setMemoryInfo(await res.json()); + } catch (err) { + setMemoryInfo({ error: err.message }); + } finally { + setMemoryLoading(false); + } + }; + const handleLogout = async () => { try { const res = await fetch("/api/auth/logout", { method: "POST" }); @@ -825,6 +850,15 @@ export default function ProfilePage() { > Import Backup + )} + {memoryInfo && ( +
+                {memoryInfo.error
+                  ? `Error: ${memoryInfo.error}`
+                  : memoryLine(memoryInfo)}
+              
+ )} @@ -1646,6 +1687,14 @@ export default function ProfilePage() {

{APP_CONFIG.name} v{APP_CONFIG.version}

{isRemoteHost ? "Remote Mode" : "Local Mode - All data stored on your machine"}

+ + MIBP Edition +
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js index 0b5bea19..af7f02f5 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js @@ -6,30 +6,76 @@ import PropTypes from "prop-types"; import { Badge, Toggle, Tooltip } from "@/shared/components"; import CooldownTimer from "./CooldownTimer"; -export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) { +export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null, modelAssignmentOptions = null, onModelAssignmentChange = null, strictModelAssignment = false }) { const [showProxyDropdown, setShowProxyDropdown] = useState(false); const [updatingProxy, setUpdatingProxy] = useState(false); + const [selectedProxyIds, setSelectedProxyIds] = useState([]); + const [rotationStrategy, setRotationStrategy] = useState("none"); const proxyDropdownRef = useRef(null); + // Initialize proxy state from connection + useEffect(() => { + const proxyPoolIds = connection.providerSpecificData?.proxyPoolIds || []; + const legacyProxyPoolId = connection.providerSpecificData?.proxyPoolId; + + // Migrate legacy single proxy to array format + queueMicrotask(() => { + if (legacyProxyPoolId && proxyPoolIds.length === 0) { + setSelectedProxyIds([legacyProxyPoolId]); + } else { + setSelectedProxyIds(proxyPoolIds); + } + setRotationStrategy(connection.providerSpecificData?.proxyRotationStrategy || "none"); + }); + }, [connection]); + const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool])); - const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null; - const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null; + + // Display logic - support both new (multi-proxy) and legacy (single proxy) formats const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl; - const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy; - const proxyDisplayText = boundProxyPool - ? `Pool: ${boundProxyPool.name}` - : boundProxyPoolId - ? `Pool: ${boundProxyPoolId} (inactive/missing)` - : hasLegacyProxy - ? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}` - : ""; + const hasAnyProxy = selectedProxyIds.length > 0 || hasLegacyProxy; + + const getProxyDisplayText = () => { + if (selectedProxyIds.length === 0 && !hasLegacyProxy) return ""; + + if (selectedProxyIds.length === 1) { + const pool = proxyPoolMap.get(selectedProxyIds[0]); + return pool ? `Pool: ${pool.name}` : `Pool: ${selectedProxyIds[0]} (inactive/missing)`; + } + + if (selectedProxyIds.length > 1) { + const strategyLabel = rotationStrategy === "random" ? "Random" : rotationStrategy === "round-robin" ? "Round Robin" : rotationStrategy === "failover" ? "Failover" : rotationStrategy === "smart" ? "Smart" : "Multiple"; + return `${selectedProxyIds.length} pools (${strategyLabel})`; + } + + if (hasLegacyProxy) { + return `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}`; + } + + return ""; + }; + + const proxyDisplayText = getProxyDisplayText(); const autoPingTooltip = autoPing?.provider === "codex" ? "Auto-starts the next 5h Codex window after reset by sending a tiny gpt-5.5 request. Consumes a small amount of quota." : "When your 5h quota runs out, auto-sends a request the moment it resets so a new window starts right away."; let maskedProxyUrl = ""; - if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) { - const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl; + if (selectedProxyIds.length > 0) { + const selectedPools = selectedProxyIds.map(id => proxyPoolMap.get(id)).filter(Boolean); + if (selectedPools.length > 0) { + try { + const parsed = new URL(selectedPools[0].proxyUrl); + maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`; + if (selectedPools.length > 1) { + maskedProxyUrl += ` (+${selectedPools.length - 1} more)`; + } + } catch { + maskedProxyUrl = selectedPools[0].proxyUrl; + } + } + } else if (connection.providerSpecificData?.connectionProxyUrl) { + const rawProxyUrl = connection.providerSpecificData?.connectionProxyUrl; try { const parsed = new URL(rawProxyUrl); maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`; @@ -38,12 +84,15 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst } } - const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || ""; + const noProxyText = selectedProxyIds.length > 0 + ? proxyPoolMap.get(selectedProxyIds[0])?.noProxy || "" + : connection.providerSpecificData?.connectionNoProxy || ""; let proxyBadgeVariant = "default"; - if (boundProxyPool?.isActive === true) { - proxyBadgeVariant = "success"; - } else if (boundProxyPoolId || hasLegacyProxy) { + if (selectedProxyIds.length > 0) { + const allActive = selectedProxyIds.every(id => proxyPoolMap.get(id)?.isActive === true); + proxyBadgeVariant = allActive ? "success" : "error"; + } else if (hasLegacyProxy) { proxyBadgeVariant = "error"; } @@ -59,10 +108,54 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst return () => document.removeEventListener("mousedown", handler); }, [showProxyDropdown]); - const handleSelectProxy = async (poolId) => { + const handleToggleProxySelection = (poolId) => { + setSelectedProxyIds(prev => { + if (prev.includes(poolId)) { + return prev.filter(id => id !== poolId); + } else { + return [...prev, poolId]; + } + }); + }; + + const handleStrategyChange = (strategy) => { + setRotationStrategy(strategy); + + // Auto-select all active proxy pools when switching to rotation strategies + if (strategy !== "none" && selectedProxyIds.length === 0) { + const activePoolIds = (proxyPools || []) + .filter(pool => pool.isActive === true) + .map(pool => pool.id); + setSelectedProxyIds(activePoolIds); + } + + // Reset to single proxy when switching to "none" + if (strategy === "none" && selectedProxyIds.length > 1) { + setSelectedProxyIds(selectedProxyIds.slice(0, 1)); + } + }; + + const handleApplyProxyChanges = async () => { setUpdatingProxy(true); try { - await onUpdateProxy(poolId === "__none__" ? null : poolId); + await onUpdateProxy({ + proxyPoolIds: selectedProxyIds, + proxyRotationStrategy: rotationStrategy, + }); + setShowProxyDropdown(false); + } finally { + setUpdatingProxy(false); + } + }; + + const handleSelectProxy = async (poolId) => { + // Legacy single-proxy mode (backwards compatibility) + setUpdatingProxy(true); + try { + await onUpdateProxy({ + proxyPoolIds: poolId === "__none__" ? [] : [poolId], + proxyRotationStrategy: "none", + }); } finally { setUpdatingProxy(false); setShowProxyDropdown(false); @@ -109,7 +202,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst return () => { if (interval) clearInterval(interval); }; - }, [modelLockUntil]); + }, [connection, modelLockUntil]); // Determine effective status (override unavailable if cooldown expired) const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown) @@ -191,6 +284,20 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst )} + {modelAssignmentOptions && onModelAssignmentChange && ( + + )} {hasAnyProxy && (
@@ -226,22 +333,119 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst Proxy {showProxyDropdown && ( -
- - {(proxyPools || []).map((pool) => ( - - ))} + + {/* Select All Button (only for rotation strategies) */} + {rotationStrategy !== "none" && ( + + )} + + {(proxyPools || []).map((pool) => { + const isSelected = selectedProxyIds.includes(pool.id); + const isActive = pool.isActive === true; + return ( + + ); + })} +
+ + {/* Apply Button */} +
+ +
)} @@ -306,6 +510,9 @@ ConnectionRow.propTypes = { onUpdateProxy: PropTypes.func, onEdit: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, + modelAssignmentOptions: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string })), + onModelAssignmentChange: PropTypes.func, + strictModelAssignment: PropTypes.bool, oneByOneStatus: PropTypes.shape({ state: PropTypes.string, error: PropTypes.string, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index a9722d7f..5c9eca63 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -67,6 +67,7 @@ export default function ProviderDetailPage() { const [bulkUpdatingProxy, setBulkUpdatingProxy] = useState(false); const [providerStrategy, setProviderStrategy] = useState(null); const [providerStickyLimit, setProviderStickyLimit] = useState(""); + const [strictModelAssignment, setStrictModelAssignment] = useState(false); const [thinkingMode, setThinkingMode] = useState("auto"); const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} }); const [suggestedModels, setSuggestedModels] = useState([]); @@ -181,6 +182,21 @@ export default function ProviderDetailPage() { return levels && levels.includes(thinkingMode) ? thinkingMode : null; }; const providerStorageAlias = isCompatible ? providerId : providerAlias; + const assignmentModels = (() => { + const byId = new Map(); + const add = (model) => { + if (model?.id && !byId.has(model.id)) byId.set(model.id, model); + }; + models.forEach(add); + kiloFreeModels.forEach(add); + customModels.forEach((model) => { + if (model.providerAlias === providerStorageAlias && (model.kind || model.type || "llm") === "llm") { + add(model); + } + }); + const disabled = new Set(disabledModelIds); + return [...byId.values()].filter((model) => !disabled.has(model.id)); + })(); // Union of levels across this provider's reasoning models — drives the level picker options. // Include custom models too (e.g. manually added gpt-5.6-sol → max). const providerThinkingLevels = (() => { @@ -324,6 +340,7 @@ export default function ProviderDetailPage() { const override = (settingsData.providerStrategies || {})[providerId] || {}; setProviderStrategy(override.fallbackStrategy || null); setProviderStickyLimit(override.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : "1"); + setStrictModelAssignment(override.strictModelAssignment === true); // Load per-provider thinking config const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {}; setThinkingMode(thinkingCfg.mode || "auto"); @@ -379,9 +396,13 @@ export default function ProviderDetailPage() { const settingsData = settingsRes.ok ? await settingsRes.json() : {}; const current = settingsData.providerStrategies || {}; - // Build override: null strategy means remove override, use global - const override = {}; + // Preserve Freebuff-only settings while changing the shared strategy. + const override = { ...(current[providerId] || {}) }; if (strategy) override.fallbackStrategy = strategy; + else { + delete override.fallbackStrategy; + delete override.stickyRoundRobinLimit; + } if (strategy === "round-robin" && stickyLimit !== "") { override.stickyRoundRobinLimit = Number(stickyLimit) || 3; } @@ -403,6 +424,44 @@ export default function ProviderDetailPage() { } }; + const handleStrictAssignmentToggle = async (enabled) => { + setStrictModelAssignment(enabled); + try { + const settingsRes = await fetch("/api/settings", { cache: "no-store" }); + const settingsData = settingsRes.ok ? await settingsRes.json() : {}; + const current = settingsData.providerStrategies || {}; + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providerStrategies: { + ...current, + [providerId]: { ...(current[providerId] || {}), strictModelAssignment: enabled }, + }, + }), + }); + } catch (error) { + console.log("Error saving Freebuff strict assignment:", error); + } + }; + + const handleModelAssignment = async (connectionId, assignedModel) => { + try { + const res = await fetch(`/api/providers/${connectionId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ providerSpecificData: { assignedModel: assignedModel || null } }), + }); + if (res.ok) { + setConnections((prev) => prev.map((c) => c.id === connectionId + ? { ...c, providerSpecificData: { ...(c.providerSpecificData || {}), assignedModel: assignedModel || null } } + : c)); + } + } catch (error) { + console.log("Error saving Freebuff model assignment:", error); + } + }; + const handleRoundRobinToggle = (enabled) => { const strategy = enabled ? "round-robin" : null; const sticky = enabled ? (providerStickyLimit || "1") : providerStickyLimit; @@ -463,10 +522,12 @@ export default function ProviderDetailPage() { }; useEffect(() => { - fetchConnections(); - fetchAliases(); - fetchCustomModels(); - fetchDisabledModels(); + Promise.resolve().then(() => { + fetchConnections(); + fetchAliases(); + fetchCustomModels(); + fetchDisabledModels(); + }); }, [fetchConnections, fetchAliases, fetchCustomModels, fetchDisabledModels]); // Live per-connection catalogs (cursor, zed): the static registry carries @@ -476,13 +537,13 @@ export default function ProviderDetailPage() { useEffect(() => { const isLiveCatalog = providerId === "cursor" || providerId === "zed"; if (!isLiveCatalog) { - setLiveModels([]); + queueMicrotask(() => setLiveModels([])); return; } const connection = connections.find((item) => item.isActive !== false); if (!connection?.id) { - setLiveModels([]); + queueMicrotask(() => setLiveModels([])); if (providerId === "zed") setLiveModelsError(null); return; } @@ -942,7 +1003,9 @@ export default function ProviderDetailPage() { }; useEffect(() => { - setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id))); + queueMicrotask(() => { + setSelectedConnectionIds((prev) => prev.filter((id) => connections.some((conn) => conn.id === id))); + }); }, [connections]); const selectedProxySummary = (() => { @@ -1043,17 +1106,39 @@ export default function ProviderDetailPage() { onToggle: (on) => handleAutoPingConnection(conn.id, on), provider: providerId, } : null} - onUpdateProxy={async (proxyPoolId) => { + onUpdateProxy={async (proxyConfig) => { try { + // Support both new format (object) and legacy format (string/null) + const updatePayload = typeof proxyConfig === 'object' && proxyConfig !== null + ? { + proxyPoolIds: proxyConfig.proxyPoolIds || [], + proxyRotationStrategy: proxyConfig.proxyRotationStrategy || "none", + } + : { + // Legacy single-proxy format + proxyPoolId: proxyConfig || null, + }; + const res = await fetch(`/api/providers/${conn.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ proxyPoolId: proxyPoolId || null }), + body: JSON.stringify(updatePayload), }); if (res.ok) { setConnections(prev => prev.map(c => c.id === conn.id - ? { ...c, providerSpecificData: { ...c.providerSpecificData, proxyPoolId: proxyPoolId || null } } + ? { + ...c, + providerSpecificData: { + ...c.providerSpecificData, + ...(updatePayload.proxyPoolIds !== undefined ? { + proxyPoolIds: updatePayload.proxyPoolIds, + proxyRotationStrategy: updatePayload.proxyRotationStrategy, + } : { + proxyPoolId: updatePayload.proxyPoolId, + }) + } + } : c )); } @@ -1067,6 +1152,9 @@ export default function ProviderDetailPage() { }} onDelete={() => handleDelete(conn.id)} oneByOneStatus={oneByOneResults[conn.id] || null} + modelAssignmentOptions={assignmentModels} + onModelAssignmentChange={(model) => handleModelAssignment(conn.id, model)} + strictModelAssignment={strictModelAssignment} /> @@ -1578,6 +1666,13 @@ export default function ProviderDetailPage() { )} +
+
+ Strict Model Assignment +

Only assigned accounts can serve each model for this provider.

+
+ +
diff --git a/src/app/(dashboard)/dashboard/proxy-fitness/page.js b/src/app/(dashboard)/dashboard/proxy-fitness/page.js new file mode 100644 index 00000000..cfe4a6e8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/proxy-fitness/page.js @@ -0,0 +1,424 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Badge, Button, Card, CardSkeleton, Input, ConfirmModal, Toggle } from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; +import { useNotificationStore } from "@/store/notificationStore"; + +function fmtTime(value) { + if (!value) return "-"; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? "-" : d.toLocaleTimeString(); +} + +// "freebuff::openai/gpt-5.6-luna" -> { provider: "freebuff", model: "openai/gpt-5.6-luna" } +// "freebuff::*" -> { provider: "freebuff", model: null } +function parseScope(scope) { + const sep = String(scope || "").indexOf("::"); + if (sep < 0) return { provider: String(scope || ""), model: null }; + const provider = scope.slice(0, sep); + const model = scope.slice(sep + 2); + return { provider, model: model === "*" || model === "" ? null : model }; +} + +function maskProxyUrl(url) { + try { + const parsed = new URL(url); + const host = parsed.hostname || ""; + const port = parsed.port ? `:${parsed.port}` : ""; + return `${parsed.protocol}//${host}${port}`; + } catch { + return String(url || ""); + } +} + +// Flatten fitness snapshot into one record per (pool, scope); drops expired marks. +function buildRecords(fitness, pools, now = Date.now()) { + const poolById = new Map((pools || []).map((p) => [p.id, p])); + const records = []; + for (const [poolId, byScope] of Object.entries(fitness || {})) { + const pool = poolById.get(poolId); + for (const [scope, info] of Object.entries(byScope || {})) { + const until = info?.until ? new Date(info.until).getTime() : 0; + if (!until || until <= now) continue; + const { provider, model } = parseScope(scope); + records.push({ + poolId, + scope, + provider, + model, + until, + reason: info?.reason || "blocked", + poolName: pool?.name || poolId.slice(0, 8), + proxyUrl: pool?.proxyUrl || "", + egress: pool?.egress || null, + }); + } + } + return records; +} + +export default function ProxyFitnessPage() { + const [pools, setPools] = useState([]); + const [fitness, setFitness] = useState({}); + const [loading, setLoading] = useState(true); + const [providerFilter, setProviderFilter] = useState("all"); + const [search, setSearch] = useState(""); + const [providerMenuOpen, setProviderMenuOpen] = useState(false); + const [providerMenuDropUp, setProviderMenuDropUp] = useState(false); + const [clearingScope, setClearingScope] = useState(null); // poolId::scope while clearing + const [confirmClearAll, setConfirmClearAll] = useState(false); + const [clearingAll, setClearingAll] = useState(false); + const [geoEnabled, setGeoEnabled] = useState(true); + const [geoUpdating, setGeoUpdating] = useState(false); + const providerMenuRef = useRef(null); + const notify = useNotificationStore(); + + useEffect(() => { + const handleClickOutside = (e) => { + if (providerMenuRef.current && !providerMenuRef.current.contains(e.target)) { + setProviderMenuOpen(false); + } + }; + if (providerMenuOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [providerMenuOpen]); + + const fetchAll = useCallback(async () => { + try { + const [poolRes, fitRes] = await Promise.all([ + fetch("/api/proxy-pools?includeUsage=true", { cache: "no-store" }), + fetch("/api/proxy-pools/fitness", { cache: "no-store" }), + ]); + const poolData = await poolRes.json().catch(() => ({ proxyPools: [] })); + setPools(poolData.proxyPools || []); + if (fitRes.ok) { + const fitData = await fitRes.json().catch(() => ({})); + setFitness(fitData.pools || fitData.fitness || {}); + } else { + setFitness({}); + } + } catch (error) { + console.log("Error fetching proxy fitness:", error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchAll(); + }, [fetchAll]); + + useEffect(() => { + fetch("/api/settings", { cache: "no-store" }) + .then((r) => (r.ok ? r.json() : {})) + .then((s) => { + if (typeof s.poolGeoProbeEnabled === "boolean") setGeoEnabled(s.poolGeoProbeEnabled); + }) + .catch(() => {}); + }, []); + + const handleGeoToggle = async (next) => { + setGeoUpdating(true); + setGeoEnabled(next); + try { + const res = await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ poolGeoProbeEnabled: next }), + }); + if (!res.ok) { + setGeoEnabled(!next); + throw new Error(`HTTP ${res.status}`); + } + notify.success(next ? "Geo probe enabled" : "Geo probe disabled"); + } catch (err) { + notify.error(`Update failed: ${err.message}`); + } finally { + setGeoUpdating(false); + } + }; + + const records = useMemo(() => buildRecords(fitness, pools), [fitness, pools]); + + // Providers present in the fitness data (filter options) + const providerOptions = useMemo(() => { + const set = new Set(); + for (const rec of records) { + if (rec.provider) set.add(rec.provider); + } + return Array.from(set).sort(); + }, [records]); + + const filteredRecords = useMemo(() => { + const q = search.trim().toLowerCase(); + return records.filter((rec) => { + if (providerFilter !== "all" && rec.provider !== providerFilter) return false; + if (q) { + const hay = [rec.proxyUrl, rec.egress?.ip, rec.egress?.country, rec.egress?.region, rec.poolName] + .filter(Boolean) + .join(" ") + .toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); + }, [records, providerFilter, search]); + + const handleClear = async (rec) => { + setClearingScope(rec.scope); + try { + const res = await fetch(`/api/proxy-pools/${rec.poolId}/fitness/clear`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scope: rec.scope }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + notify.success(`Cleared ${rec.scope}`); + fetchAll(); + } catch (err) { + notify.error(`Clear failed: ${err.message}`); + } finally { + setClearingScope(null); + } + }; + + const handleClearAll = async () => { + setClearingAll(true); + try { + const res = await fetch("/api/proxy-pools/fitness/clear-all", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(providerFilter !== "all" ? { provider: providerFilter } : {}), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + notify.success(providerFilter !== "all" ? `Cleared all ${providerFilter} blocks` : "Cleared all blocks"); + setConfirmClearAll(false); + fetchAll(); + } catch (err) { + notify.error(`Clear all failed: ${err.message}`); + } finally { + setClearingAll(false); + } + }; + + const selectedProviderLabel = providerFilter === "all" ? "All providers" : providerFilter; + + // Open the provider menu as an overlay anchored to its container. Flips to + // drop-up when there isn't enough viewport space below (menu max-h ~288px). + const toggleProviderMenu = () => { + if (providerMenuOpen) { + setProviderMenuOpen(false); + return; + } + const el = providerMenuRef.current; + if (el) { + const rect = el.getBoundingClientRect(); + setProviderMenuDropUp(window.innerHeight - rect.bottom < 300); + } + setProviderMenuOpen(true); + }; + + return ( +
+ {loading ? ( + + ) : ( + <> + {/* Header */} +
+
+

Proxy Fitness

+ 0 ? "error" : "default"} size="sm"> + {records.length} active block{records.length === 1 ? "" : "s"} + +
+
+ {records.length > 0 && ( + + )} + + +
+
+ +

+ Shows which provider is blocked on which proxy IP (from provider region gates, per-IP + limits, or manual marks). Smart rotation skips these pools while the block is active. +

+ + {/* Filters */} +
+
+ + + + {providerMenuOpen && ( +
+ +
+ {providerOptions.map((provider) => ( + + ))} +
+ )} +
+ +
+ + setSearch(e.target.value)} + placeholder="e.g. 104.28, vercel-relay…" + icon="search" + /> +
+
+ + {/* Records table */} + +
+ + + + + + + + + + + + + + {filteredRecords.length === 0 ? ( + + + + ) : ( + filteredRecords.map((rec) => ( + + + + + + + + + + )) + )} + +
ProviderModelIP / ProxyPoolReasonUntilActions
+ {records.length === 0 + ? "No active blocks. Blocks appear here when a provider region-gates a proxy IP." + : "No blocks match the current filters."} +
+ + block + {rec.provider} + + + {rec.model || all models} + +
+ {maskProxyUrl(rec.proxyUrl)} + {rec.egress?.ip && ( + + egress {rec.egress.ip}{rec.egress.country ? ` · ${rec.egress.country}` : ""} + {rec.egress.isUnstable && ( + = 2 ? `Egress IP changed ${rec.egress.ipCount}× — relay egress is not stable` : "Egress is unstable"} + > + unstable + + )} + + )} +
+
{rec.poolName}{rec.reason}{fmtTime(rec.until)} +
+ +
+
+
+
+ + )} + + setConfirmClearAll(false)} + onConfirm={handleClearAll} + title="Clear all blocks" + message={providerFilter !== "all" + ? `Clear all active blocks for provider "${providerFilter}"? This lets those pools be selected again immediately.` + : "Clear all active proxy blocks? This lets all pools be selected again immediately."} + confirmText={clearingAll ? "Clearing..." : "Clear All"} + cancelText="Cancel" + variant="danger" + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index fef34f6c..15979650 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { formatResetTime, getRemainingPercentage } from "./utils"; +import { formatFreebucksPrice, formatResetTime, getRemainingPercentage } from "./utils"; const PAGE_SIZE = 10; @@ -171,9 +171,20 @@ export default function QuotaTable({ {/* Name */}
{colors.emoji} - - {quota.name} - +
+
+ {quota.name} +
+ {quota.price !== undefined && ( +
+ {formatFreebucksPrice(quota.price)} + {quota.priceNote ? ` · ${quota.priceNote}` : ""} +
+ )} +
{/* Progress + used/total */} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index 0a508265..80678396 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -9,6 +9,7 @@ import { parseQuotaData, calculatePercentage, filterQuotasByVisibility, + formatFreebucksHeader, getHiddenQuotaRows, getQuotaVisibilityKey, getConnectionLabel, @@ -1263,6 +1264,11 @@ export default function ProviderLimits() {
+ {quota?.raw?.freebucks && !error && !isLoading && ( +
+ {formatFreebucksHeader(quota.raw.freebucks)} +
+ )} {isLoading ? (
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 769e6e82..f43d8ec7 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -258,6 +258,49 @@ export function formatResetTime(date) { } } +/** + * Freebucks account header line — mirrors upstream freebucksHeaderLine: + * "10/25 Freebucks daily · resets in 4h 12m · 20 in wallet · $6.50 monthly usage left". + * Wallet shown only when non-empty; monthly only when the server sent it + * (older servers omit it — showing $0 would read as "nothing left"). + * @param {{balance: number|null, daily: {limit:number,spent:number,remaining:number,resetAt:string|null}, wallet:{balance:number}, monthly?:{remainingUsd:number,limitUsd:number|null,resetAt:string|null}}} freebucks + * @returns {string|null} + */ +export function formatFreebucksHeader(freebucks) { + if (!freebucks?.daily) return null; + const parts = [ + `${Math.max(0, Math.round(freebucks.daily.remaining))}/${Math.max(0, Math.round(freebucks.daily.limit))} Freebucks daily`, + ]; + const countdown = formatResetTime(freebucks.daily.resetAt); + if (countdown !== "-") parts.push(`resets in ${countdown}`); + if (Number(freebucks.wallet?.balance) > 0) { + parts.push(`${Math.max(0, Math.round(freebucks.wallet.balance))} in wallet`); + } + if (freebucks.monthly && Number.isFinite(Number(freebucks.monthly.remainingUsd))) { + parts.push(`${formatFreebucksUsd(freebucks.monthly.remainingUsd)} monthly usage left`); + } + return parts.join(" · "); +} + +/** + * "$25", "$4.20", "$0" — whole dollars until the figure is small enough that + * the cents are the story. Mirrors upstream formatAllowanceUsd. + */ +export function formatFreebucksUsd(usd) { + const safe = Math.max(0, Number(usd)); + if (safe >= 10) return `$${Math.round(safe)}`; + if (safe >= 1) return `$${safe.toFixed(1).replace(/\.0$/, "")}`; + return `$${safe.toFixed(2)}`; +} + +/** + * "15 Freebucks/hr" — a bare number would read as dollars; the unit is the + * hour, not the message. + */ +export function formatFreebucksPrice(price) { + return `${Math.max(0, Math.round(Number(price) || 0))} Freebucks/hr`; +} + /** * Get Tailwind color class based on percentage * @param {number} percentage - Remaining percentage (0-100) @@ -685,6 +728,27 @@ export function parseQuotaData(provider, data) { } break; + case "freebuff": + // Session quotas keyed by model id — label rows with the friendly + // displayName (from the registry) and keep modelKey for ordering. + // Metered rows carry the live Freebucks price (price) + promo tagline + // (priceNote), both server-authoritative. + if (data.quotas) { + Object.entries(data.quotas).forEach(([modelKey, quota]) => { + normalizedQuotas.push({ + name: quota.displayName || modelKey, + modelKey, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + recurring: quota.recurring !== false, + price: quota.price, + priceNote: quota.priceNote, + }); + }); + } + break; + case "groq": // Requests/Tokens rate-limit windows from response headers — absolute // used/total (calculatePercentage derives the bar), like Codex/Kiro. diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js index a40dd49f..3439cbe1 100644 --- a/src/app/api/oauth/[provider]/[action]/route.js +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -265,6 +265,7 @@ export async function GET(request, { params }) { "qoder", "qoder-cn", "grok-cli", + "freebuff", ]; let deviceData; if (noPkceDeviceProviders.includes(provider)) { diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index d209a30f..6900d1ba 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -471,7 +471,7 @@ const PROVIDER_MODELS_CONFIG = { }, "grok-cli": { customResolver: async (connection) => { - const proxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}); + const proxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}, connection.id); const result = await resolveGrokCliModels({ ...connection, connectionId: connection.id, diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js index 6ab51797..77e9a5a4 100644 --- a/src/app/api/providers/[id]/route.js +++ b/src/app/api/providers/[id]/route.js @@ -33,7 +33,31 @@ function normalizeProxyConfig(body = {}) { }; } -async function normalizeProxyPoolUpdate(proxyPoolIdInput) { +async function normalizeProxyPoolUpdate(body) { + // Handle new multi-proxy format + if (body.proxyPoolIds !== undefined) { + const proxyPoolIds = Array.isArray(body.proxyPoolIds) ? body.proxyPoolIds : []; + const proxyRotationStrategy = body.proxyRotationStrategy || "none"; + + // Validate all proxy pool IDs exist + for (const poolId of proxyPoolIds) { + if (poolId) { + const pool = await getProxyPoolById(poolId); + if (!pool) { + return { hasProxyPoolField: true, error: `Proxy pool ${poolId} not found` }; + } + } + } + + return { + hasProxyPoolField: true, + proxyPoolIds: proxyPoolIds.filter(Boolean), + proxyRotationStrategy, + }; + } + + // Handle legacy single proxy format + const proxyPoolIdInput = body.proxyPoolId; if (proxyPoolIdInput === undefined) { return { hasProxyPoolField: false, proxyPoolId: null }; } @@ -111,7 +135,7 @@ export async function PUT(request, { params }) { return NextResponse.json({ error: proxyConfig.error }, { status: 400 }); } - const proxyPoolResult = await normalizeProxyPoolUpdate(body.proxyPoolId); + const proxyPoolResult = await normalizeProxyPoolUpdate(body); if (proxyPoolResult.error) { return NextResponse.json({ error: proxyPoolResult.error }, { status: 400 }); } @@ -147,10 +171,19 @@ export async function PUT(request, { params }) { } if (proxyPoolResult.hasProxyPoolField) { - if (proxyPoolResult.proxyPoolId === null) { + // Handle new multi-proxy format + if (proxyPoolResult.proxyPoolIds !== undefined) { + updateData.providerSpecificData.proxyPoolIds = proxyPoolResult.proxyPoolIds; + updateData.providerSpecificData.proxyRotationStrategy = proxyPoolResult.proxyRotationStrategy; + // Clear legacy field delete updateData.providerSpecificData.proxyPoolId; } else { - updateData.providerSpecificData.proxyPoolId = proxyPoolResult.proxyPoolId; + // Handle legacy single-proxy format + if (proxyPoolResult.proxyPoolId === null) { + delete updateData.providerSpecificData.proxyPoolId; + } else { + updateData.providerSpecificData.proxyPoolId = proxyPoolResult.proxyPoolId; + } } } } diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index b81294d4..15e072a8 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -19,6 +19,7 @@ import { KIMCHI_CONFIG, } from "@/lib/oauth/constants/oauth"; import { buildClineHeaders } from "@/shared/utils/clineAuth"; +import { decodeJwtPayload } from "@/lib/oauth/providerHelpers"; // OAuth provider test endpoints const OAUTH_TEST_CONFIG = { @@ -100,6 +101,26 @@ const OAUTH_TEST_CONFIG = { authPrefix: "Bearer ", }, "codebuddy-cn": { tokenExists: true }, + // GUARD — DO NOT REMOVE. See AGENTS.md §4. Without this entry, Test Connection + // returns "Provider test not supported". codebuddy-intl access tokens are + // Keycloak JWTs (iss .../auth/realms/copilot); probe the realm's userinfo + // endpoint so a revoked/expired token is caught. Derive the realm URL from the + // token's `iss` claim, falling back to the known copilot realm. + // 200 = valid, 401 = invalid/revoked. + // Covered by tests/unit/codebuddy-intl-connection.test.js. + "codebuddy-intl": { + buildUrl: (token) => { + const iss = decodeJwtPayload(token)?.iss; + const base = typeof iss === "string" && iss.startsWith("https://") + ? iss.replace(/\/$/, "") + : "https://www.codebuddy.ai/auth/realms/copilot"; + return `${base}/protocol/openid-connect/userinfo`; + }, + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + refreshable: true, + }, kimchi: { url: KIMCHI_CONFIG.validationUrl || "https://api.cast.ai/v1/llm/openai/supported-providers", method: "GET", @@ -111,9 +132,24 @@ const OAUTH_TEST_CONFIG = { }, refreshable: false, }, + freebuff: { + // The session endpoint doubles as the auth probe: GET never claims a + // session (POST would burn 1.0 unit of the daily quota). Mirrors the usage + // handler: 401 = bad token, 403 = region/account gate (token still valid), + // 404 = no session row yet (pre-join, token valid). No refresh path — + // when the authToken dies the user re-logs in. + url: "https://www.codebuff.com/api/v1/freebuff/session", + method: "GET", + authHeader: "Authorization", + authPrefix: "Bearer ", + extraHeaders: { Accept: "application/json", "User-Agent": "codebuff-cli/0.0.138" }, + acceptStatuses: [403, 404], + softFailMessage: { + 403: "Connected, but Freebuff is gated (403) — country blocked or account banned.", + }, + }, // Grok CLI / Grok Build — probe /v1/user (no inference quota). Headers mirror official CLI. - "grok-cli": { - url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user", + "grok-cli": { url: PROVIDERS["grok-cli"]?.userUrl || "https://cli-chat-proxy.grok.com/v1/user", method: "GET", authHeader: "Authorization", authPrefix: "Bearer ", @@ -247,7 +283,7 @@ async function refreshOAuthToken(connection) { return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken }; } - if (provider === "codex" || provider === "grok-cli" || provider === "xai") { + if (provider === "codex" || provider === "grok-cli" || provider === "xai" || provider === "codebuddy-intl") { return await refreshProviderCredentials(provider, connection, console); } @@ -849,7 +885,7 @@ export async function testSingleConnection(id) { const connection = await getProviderConnectionById(id); if (!connection) return { valid: false, error: "Connection not found", latencyMs: 0, testedAt: new Date().toISOString() }; - const effectiveProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}); + const effectiveProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}, connection.id); if (effectiveProxy.connectionProxyEnabled && effectiveProxy.connectionProxyUrl && !effectiveProxy.vercelRelayUrl) { const proxyResult = await testProxyUrl({ proxyUrl: effectiveProxy.connectionProxyUrl }); diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js index be5342c1..9aff702f 100644 --- a/src/app/api/providers/client/route.js +++ b/src/app/api/providers/client/route.js @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { getProviderConnections } from "@/lib/localDb"; -import { backfillCodexEmails } from "@/lib/oauth/providers"; +import { backfillCodexEmails, backfillCodeBuddyIntlIdentity } from "@/lib/oauth/providers"; import { USAGE_APIKEY_PROVIDERS, USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers"; const SAFE_FIELDS = [ @@ -77,6 +77,7 @@ function sortConnections(connections, sort) { export async function GET(request) { try { await backfillCodexEmails(); + await backfillCodeBuddyIntlIdentity(); const { searchParams } = new URL(request.url); const provider = searchParams.get("provider") || "all"; diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 5885472b..76b7438a 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -9,6 +9,7 @@ import { import { APIKEY_PROVIDERS } from "@/shared/constants/config"; import { AI_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers"; import { normalizeProviderId, normalizeProviderSpecificData } from "@/lib/providerNormalization"; +import { backfillCodeBuddyIntlIdentity } from "@/lib/oauth/providers"; export const dynamic = "force-dynamic"; @@ -49,6 +50,9 @@ async function normalizeProxyPoolId(proxyPoolId) { // GET /api/providers - List all connections export async function GET() { try { + // Self-heal legacy CodeBuddy Intl OAuth rows that predate identity capture + // (they show as "Account N" with no email). Runs once per process. + await backfillCodeBuddyIntlIdentity(); const connections = await getProviderConnections(); // Build nodeNameMap for compatible providers (id → name) diff --git a/src/app/api/proxy-pools/[id]/fitness/clear/route.js b/src/app/api/proxy-pools/[id]/fitness/clear/route.js new file mode 100644 index 00000000..947258c6 --- /dev/null +++ b/src/app/api/proxy-pools/[id]/fitness/clear/route.js @@ -0,0 +1,26 @@ +import { NextResponse } from "next/server"; +import { clearPoolUnfit, ensurePoolFitnessHydrated } from "open-sse/services/proxyPoolFitness.js"; + +// POST /api/proxy-pools/[id]/fitness/clear +// Body: { scope: "provider::model" } — clears the mark for this pool + scope. +export async function POST(request, { params }) { + try { + await ensurePoolFitnessHydrated(); + const { id } = await params; + let body = {}; + try { + body = await request.json(); + } catch { + body = {}; + } + const scope = typeof body?.scope === "string" ? body.scope.trim() : ""; + if (!id || !scope) { + return NextResponse.json({ error: "pool id and scope are required" }, { status: 400 }); + } + clearPoolUnfit(id, scope); + return NextResponse.json({ ok: true, poolId: id, scope }); + } catch (error) { + console.log("Error clearing pool fitness:", error); + return NextResponse.json({ error: "Failed to clear pool fitness" }, { status: 500 }); + } +} diff --git a/src/app/api/proxy-pools/fitness/clear-all/route.js b/src/app/api/proxy-pools/fitness/clear-all/route.js new file mode 100644 index 00000000..5d89bc9c --- /dev/null +++ b/src/app/api/proxy-pools/fitness/clear-all/route.js @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { clearAllPoolUnfit, ensurePoolFitnessHydrated } from "open-sse/services/proxyPoolFitness.js"; + +// POST /api/proxy-pools/fitness/clear-all +// Body: { provider?: string } — clears every mark, or only marks scoped to the +// given provider ("provider::*") when provided. +export async function POST(request) { + try { + await ensurePoolFitnessHydrated(); + let body = {}; + try { + body = await request.json(); + } catch { + body = {}; + } + const provider = typeof body?.provider === "string" && body.provider.trim() ? body.provider.trim() : null; + clearAllPoolUnfit(provider); + return NextResponse.json({ ok: true, provider: provider || null }); + } catch (error) { + console.log("Error clearing proxy fitness:", error); + return NextResponse.json({ error: "Failed to clear proxy fitness" }, { status: 500 }); + } +} diff --git a/src/app/api/proxy-pools/fitness/route.js b/src/app/api/proxy-pools/fitness/route.js new file mode 100644 index 00000000..4f178b1c --- /dev/null +++ b/src/app/api/proxy-pools/fitness/route.js @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { ensurePoolFitnessHydrated, poolFitnessSnapshot } from "open-sse/services/proxyPoolFitness.js"; + +// GET /api/proxy-pools/fitness — in-memory snapshot of pool fitness marks. +// Returns { pools: { [poolId]: { [scope]: { until, reason } } } }. +export async function GET() { + try { + await ensurePoolFitnessHydrated(); + return NextResponse.json({ pools: poolFitnessSnapshot() }); + } catch (error) { + console.log("Error reading proxy fitness:", error); + return NextResponse.json({ error: "Failed to read proxy fitness" }, { status: 500 }); + } +} diff --git a/src/app/api/proxy-pools/route.js b/src/app/api/proxy-pools/route.js index 3dcfa06f..3d05aa3e 100644 --- a/src/app/api/proxy-pools/route.js +++ b/src/app/api/proxy-pools/route.js @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { createProxyPool, getProviderConnections, getProxyPools } from "@/models"; +import { getPoolGeo } from "open-sse/services/poolGeo.js"; function toBoolean(value) { if (value === "true") return true; @@ -65,6 +66,8 @@ export async function GET(request) { const enrichedProxyPools = proxyPools.map((pool) => ({ ...pool, boundConnectionCount: usageMap.get(pool.id) || 0, + // Egress geo from the background probe cache (null until first probe). + egress: getPoolGeo(pool.id) || null, })); return NextResponse.json({ proxyPools: enrichedProxyPools }); diff --git a/src/app/api/system/memory/route.js b/src/app/api/system/memory/route.js new file mode 100644 index 00000000..41101d7c --- /dev/null +++ b/src/app/api/system/memory/route.js @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import fs from "node:fs"; +import path from "node:path"; +import { getDataDir } from "@/lib/dataDir"; +import { DATA_DIR, DATA_FILE } from "@/lib/db/paths"; +import { poolFitnessSnapshot } from "open-sse/services/proxyPoolFitness.js"; +import { poolGeoSnapshot } from "open-sse/services/poolGeo.js"; + +function formatMB(bytes) { + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +function dirSizeMB(dir, maxDepth = 4) { + let total = 0; + const walk = (p, depth) => { + if (depth > maxDepth) return; + let entries = []; + try { + entries = fs.readdirSync(p, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = path.join(p, e.name); + try { + if (e.isDirectory()) walk(full, depth + 1); + else if (e.isFile()) total += fs.statSync(full).size; + } catch { /* skip */ } + } + }; + walk(dir, 0); + return total; +} + +// GET /api/system/memory — data + in-memory state sizes. +export async function GET() { + try { + const dataDir = getDataDir(); + const dbPath = DATA_FILE; + let dbFile = 0; + try { dbFile = fs.statSync(dbPath).size; } catch { dbFile = 0; } + + const fitness = poolFitnessSnapshot(); + const geo = poolGeoSnapshot(); + const { sessionStateSize } = await import("open-sse/executors/freebuff.js"); + + return NextResponse.json({ + dataDir, + dbPath, + dbSizeMB: formatMB(dbFile), + dataDirSizeMB: formatMB(dirSizeMB(dataDir)), + inMemory: { + fitnessPools: Object.keys(fitness).length, + geoPools: Object.keys(geo).length, + freebuff: sessionStateSize(), + }, + }); + } catch (error) { + console.log("Error reading memory sizes:", error); + return NextResponse.json({ error: "Failed to read memory sizes" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 84dd7674..c7469e64 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -146,7 +146,7 @@ export async function GET(request, { params }) { } // Resolve connection proxy config; force strictProxy=false so quota/refresh fall back to direct on failure - const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData); + const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData, connection.id); const proxyOptions = { connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true, connectionProxyUrl: proxyConfig.connectionProxyUrl || "", diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index 78abfc90..28b419be 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -8,6 +8,7 @@ const OPTIONAL_FIELDS = [ "scope", "projectId", "apiKey", "testStatus", "lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "errorCode", "consecutiveUseCount", "idToken", "lastRefreshAt", + "proxyRotationStrategy", "proxyPoolIds", ]; const MODEL_LOCK_PREFIX = "modelLock_"; diff --git a/src/lib/network/connectionProxy.js b/src/lib/network/connectionProxy.js index 9ecd2535..393382ec 100644 --- a/src/lib/network/connectionProxy.js +++ b/src/lib/network/connectionProxy.js @@ -1,4 +1,5 @@ import { getProxyPoolById } from "@/models"; +import { ensurePoolFitnessHydrated, fitPoolIds } from "open-sse/services/proxyPoolFitness.js"; // Safely normalize any value into a trimmed string. function normalizeString(value) { @@ -13,24 +14,43 @@ const rotateState = new Map(); // providerId → { index } * Pick one proxy pool ID from a list based on strategy. * round-robin: cycle sequentially (in-memory, resets on restart) * random: uniform random pick + * smart: region-aware — skip pools unfit for `scope`, round-robin on the fit subset * none/single: return first entry + * @param {string[]} poolIds + * @param {string} strategy + * @param {string} providerId + * @param {{ scope?: string, excludeIds?: string[] }} [opts] */ -export function pickProxyPoolId(poolIds, strategy, providerId) { +export function pickProxyPoolId(poolIds, strategy, providerId, opts = {}) { if (!poolIds || poolIds.length === 0) return null; - if (poolIds.length === 1) return poolIds[0]; + const { scope = null, excludeIds = [] } = opts || {}; - if (strategy === "round-robin") { + let eligible = poolIds.filter((id) => !(excludeIds || []).includes(id)); + // Region-aware filtering is opt-in via the "smart" strategy. + if (strategy === "smart" && scope) eligible = fitPoolIds(eligible, scope); + + if (eligible.length === 0) { + // Freebuff must never reuse a limited-IP egress. Other providers retain + // the previous fail-open behavior when every smart candidate is marked + // unfit; their executors may have their own pool fallback semantics. + if (providerId === "freebuff" && strategy === "smart") return null; + eligible = poolIds.filter((id) => !(excludeIds || []).includes(id)); + if (eligible.length === 0) return null; + } + if (eligible.length === 1) return eligible[0]; + + if (strategy === "round-robin" || strategy === "smart") { const state = rotateState.get(providerId) || { index: -1 }; - state.index = (state.index + 1) % poolIds.length; + state.index = (state.index + 1) % eligible.length; rotateState.set(providerId, state); - return poolIds[state.index]; + return eligible[state.index]; } if (strategy === "random") { - return poolIds[Math.floor(Math.random() * poolIds.length)]; + return eligible[Math.floor(Math.random() * eligible.length)]; } - return poolIds[0]; // "none" or unknown + return eligible[0]; // "none" or unknown } /** @@ -59,75 +79,127 @@ function normalizeLegacyProxy(providerSpecificData = {}) { * Resolve final proxy configuration. * * Priority: - * 1. Proxy Pool - * 2. Legacy Proxy - * 3. No Proxy + * 1. Multi-Proxy Pool (new format with rotation) + * 2. Single Proxy Pool (legacy) + * 3. Legacy Proxy + * 4. No Proxy */ export async function resolveConnectionProxyConfig( - providerSpecificData = {} + providerSpecificData = {}, + connectionId = null, + excludePoolIds = null ) { try { - const proxyPoolIdRaw = normalizeString( - providerSpecificData?.proxyPoolId - ); - - // "__none__" means explicitly disabled - const proxyPoolId = - proxyPoolIdRaw === "__none__" ? "" : proxyPoolIdRaw; + await ensurePoolFitnessHydrated(); + // Handle new multi-proxy format + const proxyPoolIds = providerSpecificData?.proxyPoolIds || []; + const proxyRotationStrategy = providerSpecificData?.proxyRotationStrategy || "none"; + + // Handle legacy single-proxy format + const legacyProxyPoolId = normalizeString(providerSpecificData?.proxyPoolId); + const proxyPoolIdRaw = legacyProxyPoolId === "__none__" ? "" : legacyProxyPoolId; const legacy = normalizeLegacyProxy(providerSpecificData); + const multiPoolScope = providerSpecificData?.proxyPoolScope || null; + let selectedPoolId = null; /** * ----------------------------- - * Proxy Pool Resolution + * Multi-Proxy Pool Resolution (NEW) * ----------------------------- */ - if (proxyPoolId) { - const proxyPool = await getProxyPoolById(proxyPoolId); + if (proxyPoolIds.length > 0) { + selectedPoolId = pickProxyPoolId(proxyPoolIds, proxyRotationStrategy, connectionId, { scope: multiPoolScope, excludeIds: excludePoolIds }); + + if (selectedPoolId) { + const proxyPool = await getProxyPoolById(selectedPoolId); + const proxyUrl = normalizeString(proxyPool?.proxyUrl); + const noProxy = normalizeString(proxyPool?.noProxy); + const isValidPool = proxyPool && proxyPool.isActive === true && proxyUrl; + + if (isValidPool) { + /** + * Vercel/Cloudflare relay proxies use base URL rewriting + * instead of HTTP_PROXY environment variables. + */ + if (proxyPool.type === "vercel" || proxyPool.type === "cloudflare" || proxyPool.type === "deno") { + return { + source: proxyPool.type, + proxyPoolId: selectedPoolId, + proxyPool, + connectionProxyEnabled: false, + connectionProxyUrl: "", + connectionNoProxy: noProxy, + strictProxy: proxyPool.strictProxy === true, + vercelRelayUrl: proxyUrl, + }; + } + + /** + * Standard proxy pool + */ + return { + source: "pool", + proxyPoolId: selectedPoolId, + proxyPool, + connectionProxyEnabled: true, + connectionProxyUrl: proxyUrl, + connectionNoProxy: noProxy, + strictProxy: proxyPool.strictProxy === true, + }; + } + } + } + if ( + !selectedPoolId && + proxyRotationStrategy === "smart" && + multiPoolScope?.startsWith("freebuff::") + ) { + return { + source: "pool", + proxyPoolId: null, + proxyPool: null, + noFitPool: true, + connectionProxyEnabled: false, + connectionProxyUrl: "", + connectionNoProxy: "", + strictProxy: true, + }; + } + + /** + * ----------------------------- + * Single Proxy Pool Resolution (LEGACY) + * ----------------------------- + */ + if (proxyPoolIdRaw) { + const proxyPool = await getProxyPoolById(proxyPoolIdRaw); const proxyUrl = normalizeString(proxyPool?.proxyUrl); const noProxy = normalizeString(proxyPool?.noProxy); - - const isValidPool = - proxyPool && - proxyPool.isActive === true && - proxyUrl; + const isValidPool = proxyPool && proxyPool.isActive === true && proxyUrl; if (isValidPool) { - /** - * Vercel/Cloudflare relay proxies use base URL rewriting - * instead of HTTP_PROXY environment variables. - */ if (proxyPool.type === "vercel" || proxyPool.type === "cloudflare" || proxyPool.type === "deno") { return { source: proxyPool.type, - - proxyPoolId, + proxyPoolId: proxyPoolIdRaw, proxyPool, - connectionProxyEnabled: false, connectionProxyUrl: "", connectionNoProxy: noProxy, - strictProxy: proxyPool.strictProxy === true, - - vercelRelayUrl: proxyUrl, // Still mapped to vercelRelayUrl in the unified payload since they use the exact same header spec + vercelRelayUrl: proxyUrl, }; } - /** - * Standard proxy pool - */ return { source: "pool", - - proxyPoolId, + proxyPoolId: proxyPoolIdRaw, proxyPool, - connectionProxyEnabled: true, connectionProxyUrl: proxyUrl, connectionNoProxy: noProxy, - strictProxy: proxyPool.strictProxy === true, }; } @@ -145,7 +217,7 @@ export async function resolveConnectionProxyConfig( return { source: "legacy", - proxyPoolId: proxyPoolId || null, + proxyPoolId: proxyPoolIdRaw || null, proxyPool: null, ...legacy, @@ -160,7 +232,7 @@ export async function resolveConnectionProxyConfig( return { source: "none", - proxyPoolId: proxyPoolId || null, + proxyPoolId: proxyPoolIdRaw || null, proxyPool: null, ...legacy, diff --git a/src/lib/network/poolEgressProbe.js b/src/lib/network/poolEgressProbe.js new file mode 100644 index 00000000..61b1e4b7 --- /dev/null +++ b/src/lib/network/poolEgressProbe.js @@ -0,0 +1,115 @@ +// Background pool egress geo probe — fills the poolGeo cache so the Proxy +// Fitness / Proxy Pools UI can show each pool's egress IP + country, and +// future provider region policies can pre-mark pools unfit. +// Fail-open everywhere; never blocks startup or requests. +// +// Rate safety: each probe hits ipinfo.io through the pool; quotas are small, +// so failing pools get a negative backoff instead of being re-probed every +// pass, and per-pass output is a single aggregated summary line. + +import { getProxyPools } from "@/models"; +import { probePoolGeo, setPoolGeo, getPoolGeo } from "open-sse/services/poolGeo.js"; +import { isNonServerRuntime } from "@/sse/services/backgroundTokenRefresh.js"; +import { getSettings } from "@/lib/localDb"; + +const PROBE_INTERVAL_MS = 30 * 60 * 1000; +const INITIAL_DELAY_MS = 15 * 1000; +const CONCURRENCY = 3; +// Re-probe a pool when its last sample is older than this — multiple samples +// per pool are what let us flag flapping (changing egress) relays. +const GEO_REPROBE_MS = 30 * 60 * 1000; + +// Backoff windows per failure family: server/rate problems are likely to +// persist (broken relay, exhausted quota), network blips recover sooner. +const BACKOFF = { + "rate-limit": 2 * 60 * 60 * 1000, + server: 2 * 60 * 60 * 1000, + network: 30 * 60 * 1000, + timeout: 30 * 60 * 1000, + "no-ip": 30 * 60 * 1000, +}; + +let started = false; +let intervalHandle = null; +let initialTimeoutHandle = null; +let probing = false; +const probeBackoff = new Map(); // poolId -> retryAfter (ms epoch) + +function isTruthyEnv(v) { + if (v == null || v === "") return false; + return ["1", "true", "yes", "on"].includes(String(v).trim().toLowerCase()); +} + +// probe with bounded concurrency; skips pools in backoff or with fresh samples. +async function probeAll() { + if (probing) return; + probing = true; + try { + // UI toggle (settings.poolGeoProbeEnabled) gates the whole feature; the + // env var remains a hard override for headless setups. + try { + const settings = await getSettings(); + if (settings?.poolGeoProbeEnabled === false) return; + } catch { /* DB hiccup — keep probing (fail-open) */ } + + const pools = await getProxyPools({ isActive: true }); + const now = Date.now(); + const active = (pools || []).filter((p) => !!p?.proxyUrl); + const targets = active.filter((p) => { + const until = probeBackoff.get(p.id); + if (until && until > now) return false; // waiting out a failure + const geo = getPoolGeo(p.id); + return !geo || now - geo.ts >= GEO_REPROBE_MS; + }); + if (targets.length === 0) return; + + const failTally = {}; + let next = 0; + const workers = Array.from({ length: Math.min(CONCURRENCY, Math.max(targets.length, 1)) }, async () => { + while (next < targets.length) { + const pool = targets[next++]; + const res = await probePoolGeo(pool); + if (res.ok) { + setPoolGeo(pool.id, res.geo); + if (probeBackoff.has(pool.id)) probeBackoff.delete(pool.id); + } else { + failTally[res.error] = (failTally[res.error] || 0) + 1; + probeBackoff.set(pool.id, now + (BACKOFF[res.error] || BACKOFF.network)); + } + } + }); + await Promise.allSettled(workers); + + const filled = active.filter((p) => getPoolGeo(p.id)).length; + const failSummary = Object.entries(failTally).map(([k, n]) => `${k}×${n}`).join(", ") || "none"; + console.log(`[PoolEgressProbe] geo ${filled}/${active.length} · fail: ${failSummary}`); + } catch (e) { + console.log(`[PoolEgressProbe] pass failed: ${e?.message || e}`); + } finally { + probing = false; + } +} + +export function startPoolEgressProbe({ intervalMs } = {}) { + if (started) return false; + if (isNonServerRuntime()) return false; + if (isTruthyEnv(process.env.POOL_GEO_PROBE_DISABLED)) return false; + started = true; + const period = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : PROBE_INTERVAL_MS; + console.log("[PoolEgressProbe] Scheduler started", { intervalMs: period, initialDelayMs: INITIAL_DELAY_MS }); + initialTimeoutHandle = setTimeout(() => { probeAll().catch(() => {}); }, INITIAL_DELAY_MS); + if (initialTimeoutHandle.unref) initialTimeoutHandle.unref(); + intervalHandle = setInterval(() => { probeAll().catch(() => {}); }, period); + if (intervalHandle.unref) intervalHandle.unref(); + return true; +} + +export function stopPoolEgressProbe() { + if (initialTimeoutHandle) clearTimeout(initialTimeoutHandle); + if (intervalHandle) clearInterval(intervalHandle); + initialTimeoutHandle = null; + intervalHandle = null; + started = false; +} + +export const __test__ = { probeAll }; \ No newline at end of file diff --git a/src/lib/network/stateSweeper.js b/src/lib/network/stateSweeper.js new file mode 100644 index 00000000..df33c673 --- /dev/null +++ b/src/lib/network/stateSweeper.js @@ -0,0 +1,47 @@ +// Periodic in-memory state sweeper — a cron-like job that prunes expired data +// so here long-running servers never accumulate stale entries: +// • pool fitness marks (proxyPoolFitness.pruneExpired) +// • pool egress geo cache incl. ipHistory (poolGeo.pruneStaleGeo) +// • freebuff session cache + cooldowns (freebuff.pruneSessionState) +// Fail-open everywhere; never blocks startup or requests. + +import { pruneExpired } from "open-sse/services/proxyPoolFitness.js"; +import { pruneStaleGeo } from "open-sse/services/poolGeo.js"; +import { isNonServerRuntime } from "@/sse/services/backgroundTokenRefresh.js"; + +const SWEEP_INTERVAL_MS = 10 * 60 * 1000; + +let started = false; +let handle = null; + +async function sweep() { + try { + const fitness = pruneExpired(); + const geo = pruneStaleGeo(); + const { pruneSessionState } = await import("open-sse/executors/freebuff.js"); + const sessions = pruneSessionState(); + if (fitness || geo || sessions) { + console.log(`[StateSweeper] pruned ${fitness} fitness, ${geo} geo, ${sessions} session/cooldown entries`); + } + } catch { + // fail-open: next tick retries + } +} + +export function startStateSweeper({ intervalMs } = {}) { + if (started) return false; + if (isNonServerRuntime()) return false; + started = true; + const period = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : SWEEP_INTERVAL_MS; + handle = setInterval(() => { sweep().catch(() => {}); }, period); + if (handle.unref) handle.unref(); + return true; +} + +export function stopStateSweeper() { + if (handle) clearInterval(handle); + handle = null; + started = false; +} + +export const __test__ = { sweep }; \ No newline at end of file diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js index cd35d331..163986e0 100644 --- a/src/lib/oauth/constants/oauth.js +++ b/src/lib/oauth/constants/oauth.js @@ -127,6 +127,9 @@ export const KIMCHI_CONFIG = { ...PROVIDER_OAUTH["kimchi"] }; // Endpoint: cli-chat-proxy.grok.com — same client_id as xai, different flow + scopes export const GROK_CLI_CONFIG = { ...PROVIDER_OAUTH["grok-cli"] }; +// Freebuff OAuth Configuration (Device Code Flow) +export const FREEBUFF_CONFIG = { ...PROVIDER_OAUTH["freebuff"] }; + // Trae (ByteDance marscode) OAuth — authorization_code flow with local callback. // 1) POST GetLoginGuidance {loginTraceID} → {Result.LoginHost} // 2) Browser opens ${loginHost}/authorization?client_id=...&login_trace_id=...&auth_callback_url=${cb} diff --git a/src/lib/oauth/providerHelpers.js b/src/lib/oauth/providerHelpers.js index 0cb46933..c2a7fdd7 100644 --- a/src/lib/oauth/providerHelpers.js +++ b/src/lib/oauth/providerHelpers.js @@ -50,6 +50,21 @@ function extractEmailFromAccessToken(accessToken) { return payload.email || payload.preferred_username || payload.sub || undefined; } +// Human display name from OIDC-style JWT claims. +// Preference: full `name` → given+family → email local-part. +function extractDisplayNameFromAccessToken(accessToken) { + const payload = decodeJwtPayload(accessToken); + if (!payload) return undefined; + const full = typeof payload.name === "string" ? payload.name.trim() : ""; + if (full) return full; + const given = typeof payload.given_name === "string" ? payload.given_name.trim() : ""; + const family = typeof payload.family_name === "string" ? payload.family_name.trim() : ""; + const combined = [given, family].filter(Boolean).join(" ").trim(); + if (combined) return combined; + const email = typeof payload.email === "string" ? payload.email.trim() : ""; + return email ? email.split("@")[0] : undefined; +} + export async function fetchKiroProfileArn(accessToken) { if (!accessToken) return null; try { @@ -87,4 +102,5 @@ export { decodeXaiIdTokenEmail, decodeJwtPayload, extractEmailFromAccessToken, + extractDisplayNameFromAccessToken, }; diff --git a/src/lib/oauth/providers/codebuddy-intl.js b/src/lib/oauth/providers/codebuddy-intl.js index e82e430f..66bd3905 100644 --- a/src/lib/oauth/providers/codebuddy-intl.js +++ b/src/lib/oauth/providers/codebuddy-intl.js @@ -1,4 +1,5 @@ import { CODEBUDDY_INTL_CONFIG } from "../constants/oauth.js"; +import { extractEmailFromAccessToken, extractDisplayNameFromAccessToken } from "../providerHelpers.js"; // CodeBuddy International — mirrors codebuddy-cn flow against the .ai domain. const codebuddyIntl = { @@ -67,6 +68,12 @@ const codebuddyIntl = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresIn: tokens.expires_in || 86400, + // GUARD — DO NOT REMOVE. See AGENTS.md §4. The CodeBuddy access token is a + // Keycloak JWT carrying email/name claims; surface them so a fresh OAuth + // login is named by identity (and deduped on re-login) instead of falling + // back to "Account N". Covered by tests/unit/codebuddy-intl-connection.test.js. + email: extractEmailFromAccessToken(tokens.access_token) || null, + displayName: extractDisplayNameFromAccessToken(tokens.access_token) || null, providerSpecificData: {}, }), }; diff --git a/src/lib/oauth/providers/freebuff.js b/src/lib/oauth/providers/freebuff.js new file mode 100644 index 00000000..986ca6e6 --- /dev/null +++ b/src/lib/oauth/providers/freebuff.js @@ -0,0 +1,131 @@ +import crypto from "node:crypto"; +import { FREEBUFF_CONFIG } from "../constants/oauth.js"; + +/** + * Freebuff / Codebuff CLI login (fingerprint device-flow — NOT OAuth2): + * 1) POST {baseUrl}/api/auth/cli/code { fingerprintId } // baseUrl = https://freebuff.com + * → { fingerprintId, fingerprintHash, loginUrl, expiresAt } + * (The server echoes the request host into loginUrl, so calling + * freebuff.com yields freebuff.com/login?auth_code=… — exactly the link + * the official CLI shows. www.codebuff.com would return a wrong link.) + * 2) User opens loginUrl in a browser and signs in / confirms the device + * 3) GET {baseUrl}/api/auth/cli/status?fingerprintId=..&fingerprintHash=..&expiresAt=.. + * → { user: { id, email, name, authToken, fingerprintId, ... } } once authorized + * + * The resulting user.authToken is the Bearer token used against the + * OpenAI-compatible endpoint https://www.codebuff.com/api/v1/chat/completions + * (same backend, different host — freebuff.com does not serve /api/v1/*). + * + * Implemented on top of the generic "device_code" flow: the fingerprintId + + * fingerprintHash + expiresAt triple rides in the `device_code` payload and is + * decoded server-side on every poll. + */ +// Login-flow host. The CLI in freebuff mode logs in via freebuff.com, and the +// server builds loginUrl from the host it was called on — so this must stay +// https://freebuff.com (www.codebuff.com would yield the wrong login link). +const LOGIN_HOST = "https://freebuff.com"; + +const freebuff = { + config: FREEBUFF_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const fingerprintId = crypto.randomUUID(); + const baseUrl = (config.baseUrl || LOGIN_HOST).replace(/\/$/, ""); + const response = await fetch( + `${baseUrl}${config.loginCodePath || "/api/auth/cli/code"}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "codebuff-cli/0.0.138", + }, + body: JSON.stringify({ fingerprintId }), + }, + ); + if (!response.ok) { + const error = await response.text(); + throw new Error(`Freebuff login code request failed: ${error}`); + } + const data = await response.json(); + const loginUrl = typeof data.loginUrl === "string" ? data.loginUrl : ""; + const authCode = loginUrl.match(/auth_code=([^&]+)/)?.[1] || ""; + const expiresAt = Number(data.expiresAt) || 0; + // Server codes live ~1h, but the official CLI stops polling after 5 minutes + // (and OAuthModal derives its deadline from expires_in). Clamp to + // oauthTimeoutMs so the modal doesn't hammer /api/auth/cli/status for an hour. + // When the server omits expiresAt, fall back to the full oauthTimeoutMs — + // never the 60s floor, or the user gets only a minute to finish login. + const timeoutSec = Math.max(60, Math.floor((config.oauthTimeoutMs || 300000) / 1000)); + const serverMs = + Number.isFinite(expiresAt) && expiresAt > 0 ? expiresAt - Date.now() : timeoutSec * 1000; + const expiresIn = Math.max(60, Math.min(Math.floor(serverMs / 1000), timeoutSec)); + return { + // fingerprintId + fingerprintHash + expiresAt travel inside device_code; + // pollToken decodes them for /api/auth/cli/status. + device_code: JSON.stringify({ + fingerprintId: data.fingerprintId || fingerprintId, + fingerprintHash: data.fingerprintHash, + expiresAt, + }), + user_code: authCode || "", + verification_uri: loginUrl, + verification_uri_complete: loginUrl, + expires_in: expiresIn, + interval: 5, + }; + }, + pollToken: async (config, deviceCode) => { + let parsed = {}; + try { + parsed = JSON.parse(deviceCode) || {}; + } catch { + parsed = {}; + } + const { fingerprintId, fingerprintHash, expiresAt } = parsed; + if (!fingerprintId || !fingerprintHash || !expiresAt) { + return { ok: true, data: { error: "authorization_pending" } }; + } + // The status endpoint is a GET with query params; it returns 401 + // {"error":"Authentication failed"} while the device is still waiting for + // the browser sign-in, and 200 { user } once authorized. Mirror the CLI: + // keep polling on anything except a `user` payload (the modal enforces its + // own 5-minute deadline). + const baseUrl = (config.baseUrl || LOGIN_HOST).replace(/\/$/, ""); + const query = new URLSearchParams({ fingerprintId, fingerprintHash, expiresAt: String(expiresAt) }); + const response = await fetch( + `${baseUrl}${config.loginStatusPath || "/api/auth/cli/status"}?${query.toString()}`, + { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": "codebuff-cli/0.0.138", + }, + }, + ); + let data; + try { + data = await response.json(); + } catch { + data = {}; + } + + if (data?.user?.authToken) { + return { ok: true, data: { access_token: data.user.authToken, ...data.user } }; + } + return { ok: true, data: { error: "authorization_pending" } }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: null, + email: tokens.email || undefined, + displayName: tokens.name || undefined, + providerSpecificData: { + authMethod: "device_code", + fingerprintId: tokens.fingerprintId || null, + userId: tokens.id || null, + }, + }), +}; + +export default freebuff; diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js index 86ed5338..7cea92a9 100644 --- a/src/lib/oauth/providers/index.js +++ b/src/lib/oauth/providers/index.js @@ -2,7 +2,7 @@ import "open-sse/index.js"; import { generatePKCE } from "../utils/pkce.js"; -import { extractCodexAccountInfo, fetchKiroProfileArn } from "../providerHelpers.js"; +import { extractCodexAccountInfo, fetchKiroProfileArn, extractEmailFromAccessToken, extractDisplayNameFromAccessToken } from "../providerHelpers.js"; import claude from "./claude.js"; import codex from "./codex.js"; @@ -16,6 +16,7 @@ import qoderCn from "./qoder-cn.js"; import github from "./github.js"; import kiro from "./kiro.js"; import cursor from "./cursor.js"; +import freebuff from "./freebuff.js"; import kimi from "./kimi.js"; import kilocode from "./kilocode.js"; import cline from "./cline.js"; @@ -42,6 +43,7 @@ const PROVIDERS = { github, kiro, cursor, + freebuff, kimi, kilocode, cline, @@ -209,6 +211,43 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra // Run-once guard across the process lifetime let codexBackfillDone = false; +let codebuddyIntlBackfillDone = false; + +// Backfill email + displayName for existing CodeBuddy Intl OAuth connections +// created before mapTokens surfaced identity (they show up as "Account N"). +// The access token is a Keycloak JWT carrying email/name claims. +export async function backfillCodeBuddyIntlIdentity() { + if (codebuddyIntlBackfillDone) return; + codebuddyIntlBackfillDone = true; + try { + const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb"); + const connections = await getProviderConnections(); + const targets = connections.filter((c) => { + if (c.provider !== "codebuddy-intl" || c.authType !== "oauth" || !c.accessToken) return false; + // Also re-heal rows whose name is still the generic "Account N" placeholder. + const genericName = typeof c.name === "string" && /^Account \d+$/.test(c.name.trim()); + return !c.email || !c.displayName || genericName; + }); + for (const conn of targets) { + const patch = {}; + const email = conn.email || extractEmailFromAccessToken(conn.accessToken); + const displayName = conn.displayName || extractDisplayNameFromAccessToken(conn.accessToken); + if (!conn.email && email) patch.email = email; + if (!conn.displayName && displayName) patch.displayName = displayName; + // Rename the generic placeholder to the identity (email preferred, matching + // deriveConnectionName's behavior for new logins). + if (/^Account \d+$/.test((conn.name || "").trim()) && (email || displayName)) { + patch.name = email || displayName; + } + if (Object.keys(patch).length) { + await updateProviderConnection(conn.id, patch); + } + } + } catch (err) { + codebuddyIntlBackfillDone = false; + console.log("backfillCodeBuddyIntlIdentity failed:", err?.message || err); + } +} // Backfill email + chatgpt account info for existing codex OAuth connections missing them export async function backfillCodexEmails() { diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index cc0f89c0..045a2f18 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -8,7 +8,6 @@ import ProviderIcon from "@/shared/components/ProviderIcon"; import HeaderMenu from "@/shared/components/HeaderMenu"; import HeaderLanguage from "@/shared/components/HeaderLanguage"; import ThemeToggle from "@/shared/components/ThemeToggle"; -import DonateModal from "@/shared/components/DonateModal"; import { useHeaderSearchStore } from "@/store/headerSearchStore"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers"; @@ -183,7 +182,6 @@ export default function Header({ onMenuClick, showMenuButton = true }) { const pathname = usePathname(); const [displayName, setDisplayName] = useState(""); const [loginMethod, setLoginMethod] = useState(""); - const [donateOpen, setDonateOpen] = useState(false); // Memoize page info to prevent unnecessary recalculations const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]); @@ -316,19 +314,20 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
)} - + code + MIBP Edition +
- setDonateOpen(false)} /> ); } diff --git a/src/shared/components/NoAuthProxyCard.js b/src/shared/components/NoAuthProxyCard.js index 6229e60c..5a823cfc 100644 --- a/src/shared/components/NoAuthProxyCard.js +++ b/src/shared/components/NoAuthProxyCard.js @@ -11,6 +11,7 @@ const STRATEGIES = [ { value: "none", label: "None (single pool)" }, { value: "round-robin", label: "Round-robin" }, { value: "random", label: "Random" }, + { value: "smart", label: "Smart" }, ]; export default function NoAuthProxyCard({ providerId }) { @@ -121,7 +122,9 @@ export default function NoAuthProxyCard({ providerId }) { : isRotation ? rotateStrategy === "round-robin" ? `Rotating through all ${proxyPools.length} active pools in order. State is in-memory (resets on restart).` - : `Picking a random pool from ${proxyPools.length} active pools each request.` + : rotateStrategy === "smart" + ? `Skip pools whose egress IP is blocked for this provider (e.g. per-IP limits). See Proxy Fitness to clear/block.` + : `Picking a random pool from ${proxyPools.length} active pools each request.` : `Uses the selected pool above. Set to Round-robin or Random to rotate across all active pools.`}

diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js index b8f67a06..fb328ac5 100644 --- a/src/shared/components/OAuthModal.js +++ b/src/shared/components/OAuthModal.js @@ -291,6 +291,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, "qoder", "qoder-cn", "grok-cli", + "freebuff", ]; if (deviceCodeProviders.includes(provider)) { setIsDeviceCode(true); diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js index 716f59fb..1b7bbe96 100644 --- a/src/shared/components/Sidebar.js +++ b/src/shared/components/Sidebar.js @@ -25,6 +25,7 @@ const navItems = [ { href: "/dashboard/usage", label: "Usage", icon: "bar_chart" }, { href: "/dashboard/quota", label: "Quota Tracker", icon: "data_usage" }, { href: "/dashboard/token-saver", label: "Token Saver", icon: "savings" }, + { href: "/dashboard/proxy-fitness", label: "Proxy Fitness", icon: "network_check" }, // { href: "/dashboard/pxpipe", label: "PXPIPE", icon: "image" }, { href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" }, ]; @@ -355,6 +356,17 @@ export default function Sidebar({ onClose }) { +
+ + MIBP Edition · GitHub + +
+ {/* Remote Promo Modal */} diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index bcac0bad..341e1af3 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -3,6 +3,7 @@ import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { FREE_PROVIDERS, AI_PROVIDERS } from "@/shared/constants/providers"; +import { buildUsageProviderList } from "@/shared/utils/usageProviders"; // Keep providers without serviceKinds (default LLM) or with "llm" in serviceKinds function isLLMProvider(id) { @@ -235,21 +236,12 @@ export default function UsageStats({ period: periodProp, setPeriod: setPeriodPro for (const node of (nodesData?.nodes || [])) { nodeNameMap[node.id] = node.name; } - const seen = new Set(); - const unique = (d?.connections || []).filter((c) => { - if (c.isActive === false) return false; - if (!isLLMProvider(c.provider)) return false; - if (seen.has(c.provider)) return false; - seen.add(c.provider); - return true; - }).map((c) => ({ - ...c, - nodeName: nodeNameMap[c.provider] || null, + setProviders(buildUsageProviderList({ + connections: d?.connections || [], + freeProviders: FREE_PROVIDERS, + nodeNameMap, + isLLMProvider, })); - const noAuthProviders = Object.values(FREE_PROVIDERS) - .filter((p) => p.noAuth && !seen.has(p.id) && isLLMProvider(p.id)) - .map((p) => ({ provider: p.id, name: p.name })); - setProviders([...unique, ...noAuthProviders]); }) .catch(() => {}); }, []); diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index 5b27f774..e627c560 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -118,6 +118,16 @@ async function runHeavyStartup() { import("@/sse/services/backgroundTokenRefresh.js") .then(({ startBackgroundTokenRefresh }) => startBackgroundTokenRefresh()) .catch((e) => console.log("[BackgroundTokenRefresh] scheduler start failed:", e.message)); + + // Pool egress geo probe — fills the Proxy Fitness / Proxy Pools egress column. + import("@/lib/network/poolEgressProbe.js") + .then(({ startPoolEgressProbe }) => startPoolEgressProbe()) + .catch((e) => console.log("[PoolEgressProbe] scheduler start failed:", e.message)); + + // Periodic in-memory state sweeper — prunes expired fitness/geo/session state. + import("@/lib/network/stateSweeper.js") + .then(({ startStateSweeper }) => startStateSweeper()) + .catch((e) => console.log("[StateSweeper] scheduler start failed:", e.message)); } function hasQuotaAutoPingEnabled(settings) { diff --git a/src/shared/utils/usageProviders.js b/src/shared/utils/usageProviders.js new file mode 100644 index 00000000..9c166289 --- /dev/null +++ b/src/shared/utils/usageProviders.js @@ -0,0 +1,38 @@ +// Provider list for the Usage page. +// +// GUARD — DO NOT DELETE the `!p.hidden` filter below. See AGENTS.md §3. +// +// Two sources, deduped by provider id: +// 1. Active LLM connections (one entry per provider). +// 2. noAuth free providers that need no connection (e.g. opencode). +// +// Hidden providers are excluded — the Providers page filters `hidden`, so a +// hidden noAuth provider (devin-cli, mimo-free) must not leak into Usage with +// zero connections and zero traffic. +// Covered by tests/unit/usage-provider-list.test.js. +export function buildUsageProviderList({ + connections = [], + freeProviders = {}, + nodeNameMap = {}, + isLLMProvider = () => true, +} = {}) { + const seen = new Set(); + const unique = connections + .filter((c) => { + if (c.isActive === false) return false; + if (!isLLMProvider(c.provider)) return false; + if (seen.has(c.provider)) return false; + seen.add(c.provider); + return true; + }) + .map((c) => ({ + ...c, + nodeName: nodeNameMap[c.provider] || null, + })); + + const noAuthProviders = Object.values(freeProviders) + .filter((p) => p.noAuth && !p.hidden && !seen.has(p.id) && isLLMProvider(p.id)) + .map((p) => ({ provider: p.id, name: p.name })); + + return [...unique, ...noAuthProviders]; +} diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 7aa530d3..483390dc 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -18,6 +18,7 @@ import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; import { handleComboChat, handleFusionChat, detectRequiredCapabilities } from "open-sse/services/combo.js"; import { augmentModelsWithCapacityAdapter, withCapacityAdapterStripping, getActiveAdapterStrategy } from "open-sse/services/capacityAdapter.js"; import { handleBypassRequest } from "open-sse/utils/bypassHandler.js"; +import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import { detectFormatByEndpoint } from "open-sse/translator/formats.js"; import * as log from "../utils/logger.js"; @@ -291,6 +292,23 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re pxpipeTransform: chatSettings.pxpipeEnabled ? await getPxpipeTransform() : null, onPxpipeEvent: appendPxpipeEvent, providerThinking, + // Pool-scoped failure recovery: re-resolve proxy config excluding the + // failed pool so the request retries via another pool, not a dead end. + resolveProxyConfig: async (creds, excludePoolIds = []) => { + const psd = { ...(creds?.providerSpecificData || {}) }; + if (psd.proxyPoolIds?.length) psd.proxyPoolScope = `${provider}::${model}`; + const resolved = await resolveConnectionProxyConfig(psd, creds?.connectionId || creds?.id, excludePoolIds); + if (!resolved?.proxyPoolId) return null; + return { + connectionProxyEnabled: resolved.connectionProxyEnabled, + connectionProxyUrl: resolved.connectionProxyUrl, + connectionNoProxy: resolved.connectionNoProxy, + connectionProxyPoolId: resolved.proxyPoolId || null, + vercelRelayUrl: resolved.vercelRelayUrl || "", + proxyPoolId: resolved.proxyPoolId || null, + strictProxy: resolved.strictProxy === true, + }; + }, // Detect source format by endpoint + body sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null, onCredentialsRefreshed: async (newCreds) => { diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index eff83e7c..fa393b86 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -9,6 +9,18 @@ import * as log from "../utils/logger.js"; // Mutex to prevent race conditions during account selection let selectionMutex = Promise.resolve(); +export function filterConnectionsForModel(providerId, connections, model, settings = {}) { + const override = (settings.providerStrategies || {})[providerId] || {}; + if (override.strictModelAssignment !== true || !model) { + return connections; + } + return connections.filter((connection) => { + const assignedModel = connection.providerSpecificData?.assignedModel + || (providerId === "freebuff" ? connection.providerSpecificData?.freebuffModel : null); + return assignedModel === model; + }); +} + const GITHUB_MONTHLY_USAGE_LIMIT = "you've reached your additional usage limit for your plan"; function githubMonthlyResetMs(status, errorText, provider) { @@ -48,10 +60,16 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu const override = (settings.providerStrategies || {})[providerId] || {}; const strategy = override.rotateStrategy || "none"; let pickedId = override.proxyPoolId || null; + let poolIds = []; if (strategy !== "none") { const allPools = await getProxyPools({ isActive: true }); - const poolIds = allPools.filter(p => p.proxyUrl).map(p => p.id); - pickedId = pickProxyPoolId(poolIds, strategy, providerId); + poolIds = allPools.filter(p => p.proxyUrl).map(p => p.id); + // Scope region-aware ("smart") filtering to this provider/model so + // pools marked unfit here are skipped. + const scope = `${providerId}::${model || "*"}`; + pickedId = pickProxyPoolId(poolIds, strategy, providerId, { scope }); + } else if (override.proxyPoolId) { + poolIds = [override.proxyPoolId]; } const resolvedProxy = await resolveConnectionProxyConfig({ proxyPoolId: pickedId || "" }); return { @@ -65,11 +83,21 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu connectionNoProxy: resolvedProxy.connectionNoProxy, connectionProxyPoolId: resolvedProxy.proxyPoolId || null, vercelRelayUrl: resolvedProxy.vercelRelayUrl || "", + proxyPoolId: resolvedProxy.proxyPoolId || null, + strictProxy: resolvedProxy.strictProxy === true, + // Let chatCore's pool-scoped retry rotate across the same candidate + // pool set (excluding the failed pool) instead of reusing it — this + // is what makes per-IP limit retries work for no-auth providers. + proxyPoolIds: poolIds, + proxyRotationStrategy: strategy, }, }; } - const connections = await getProviderConnections({ provider: providerId, isActive: true }); + let connections = await getProviderConnections({ provider: providerId, isActive: true }); + const settings = await getSettings(); + const providerOverride = (settings.providerStrategies || {})[providerId] || {}; + connections = filterConnectionsForModel(providerId, connections, model, settings); log.debug("AUTH", `${provider} | total connections: ${connections.length}, excludeIds: ${excludeSet.size > 0 ? [...excludeSet].join(",") : "none"}, model: ${model || "any"}`); if (connections.length === 0) { @@ -133,9 +161,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu return null; } - const settings = await getSettings(); // Per-provider strategy overrides global setting - const providerOverride = (settings.providerStrategies || {})[providerId] || {}; const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first"; let connection; @@ -192,7 +218,11 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu connection = availableConnections[0]; } - const resolvedProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {}); + // Scope the region-aware picker to this provider/model (e.g. freebuff::gpt-5.6-luna) + const psdForProxy = connection.providerSpecificData?.proxyPoolIds?.length + ? { ...connection.providerSpecificData, proxyPoolScope: `${providerId}::${model || ""}` } + : connection.providerSpecificData; + const resolvedProxy = await resolveConnectionProxyConfig(psdForProxy || {}, connection.id); return { authType: connection.authType, @@ -213,6 +243,9 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu connectionNoProxy: resolvedProxy.connectionNoProxy, connectionProxyPoolId: resolvedProxy.proxyPoolId || null, vercelRelayUrl: resolvedProxy.vercelRelayUrl || "", + proxyPoolId: resolvedProxy.proxyPoolId || null, + noFitPool: resolvedProxy.noFitPool === true, + strictProxy: resolvedProxy.strictProxy === true, }, connectionId: connection.id, // Include current status for optimization check @@ -254,9 +287,15 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr } else if (resetsAtMs && resetsAtMs > Date.now()) { shouldFallback = true; // Antigravity quota API provides exact per-model resetAt. Do not truncate it. - cooldownMs = resolveProviderId(provider) === "antigravity" + // Freebucks exhaustion is likewise a hard stop until the daily Pacific + // reset (up to ~24h) — skip the account for the day rather than re-poke it + // every 30 min; guard at 26h so a bad server value can't lock forever. + const providerId = resolveProviderId(provider); + cooldownMs = providerId === "antigravity" ? resetsAtMs - Date.now() - : Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS); + : providerId === "freebuff" + ? Math.min(resetsAtMs - Date.now(), 26 * 60 * 60 * 1000) + : Math.min(resetsAtMs - Date.now(), MAX_RATE_LIMIT_COOLDOWN_MS); newBackoffLevel = 0; } else { ({ shouldFallback, cooldownMs, newBackoffLevel } = checkFallbackError(status, errorText, backoffLevel)); diff --git a/src/sse/services/backgroundTokenRefresh.js b/src/sse/services/backgroundTokenRefresh.js index b83bf335..db772566 100644 --- a/src/sse/services/backgroundTokenRefresh.js +++ b/src/sse/services/backgroundTokenRefresh.js @@ -22,7 +22,7 @@ function isTruthyEnv(value) { return v === "1" || v === "true" || v === "yes" || v === "on"; } -function isNonServerRuntime() { +export function isNonServerRuntime() { if (typeof window !== "undefined") return true; const phase = process.env.NEXT_PHASE || ""; if ( @@ -55,6 +55,9 @@ export function selectConnectionsNeedingRefresh(connections, nowMs = Date.now()) const authType = String(conn.authType || "").toLowerCase().replace(/_/g, ""); if (authType !== "oauth") continue; if (!conn.refreshToken) continue; + // Refresh token known-dead (invalid_grant/invalid_request) — stop retrying + // every tick; surfaced as "re-login required" instead. + if (conn.providerSpecificData?.refreshBlocked) continue; const expiresAtMs = getCredentialExpiryMs(conn); if (expiresAtMs === null) continue; @@ -80,7 +83,27 @@ async function loadActiveConnections() { async function refreshOne(connection) { const { checkAndRefreshToken } = await import("./tokenRefresh.js"); - return checkAndRefreshToken(connection.provider, connection, { force: true }); + const result = await checkAndRefreshToken(connection.provider, connection, { force: true }); + + // Dead refresh token (revoked/reused/expired): persist the block marker so + // future ticks skip it, then surface the re-login requirement. The marker is + // lifted by checkAndRefreshToken on the next successful refresh. + if (result?.refreshError) { + const { updateProviderConnection } = await import("../../lib/db/repos/connectionsRepo.js"); + await updateProviderConnection(connection.id, { + providerSpecificData: { + ...(connection.providerSpecificData || {}), + refreshBlocked: result.refreshError, + refreshBlockedAt: result.refreshErrorAt, + }, + }); + log.warn("BG_TOKEN_REFRESH", "Refresh token unrecoverable — auto-refresh stopped, re-login required", { + id: connection.id, + provider: connection.provider, + error: result.refreshError, + }); + } + return result; } /** diff --git a/src/sse/services/tokenRefresh.js b/src/sse/services/tokenRefresh.js index 58a6f870..83a3244e 100644 --- a/src/sse/services/tokenRefresh.js +++ b/src/sse/services/tokenRefresh.js @@ -20,7 +20,8 @@ import { formatProviderCredentials as _formatProviderCredentials, getAllAccessTokens as _getAllAccessTokens, refreshKiroToken as _refreshKiroToken, - getRefreshLeadMs as _getRefreshLeadMs + getRefreshLeadMs as _getRefreshLeadMs, + isUnrecoverableRefreshError, } from "open-sse/services/tokenRefresh.js"; import { refreshProviderCredentials as _refreshProviderCredentials, @@ -243,10 +244,26 @@ export async function checkAndRefreshToken(provider, credentials, options = {}) }); const newCreds = await _refreshProviderCredentials(provider, creds, log); + if (isUnrecoverableRefreshError(newCreds)) { + // Refresh token is dead (revoked/reused/expired) — retrying forever just + // spams xAI's endpoint every tick. Tag the result so the background + // scheduler can stop retrying and surface "re-login required". + log.warn("TOKEN_REFRESH", `Refresh token unrecoverable for ${provider} — re-login required`, { + error: newCreds.error, + }); + return { ...creds, refreshError: newCreds.error, refreshErrorAt: new Date().toISOString() }; + } if (newCreds?.accessToken || newCreds?.apiKey || newCreds?.copilotToken) { const mergedCreds = { ...newCreds, - existingProviderSpecificData: creds.providerSpecificData, + // Lift any previous refreshBlocked marker — the refresh just succeeded + // (covers in-place re-auth flows that keep the same connection row). + existingProviderSpecificData: { + ...(creds.providerSpecificData || {}), + ...(creds.providerSpecificData?.refreshBlocked + ? { refreshBlocked: undefined, refreshBlockedAt: undefined } + : {}), + }, }; // Persist to DB (non-blocking path continues below) diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index a387cb7f..699e5534 100644 --- a/tests/__baseline__/alias-baseline.json +++ b/tests/__baseline__/alias-baseline.json @@ -149,6 +149,7 @@ "deepseek": "deepseek", "featherless": "featherless", "fireworks": "fireworks", + "freebuff": "fb", "gemini": "gemini", "gemini-cli": "gc", "github": "gh", @@ -234,6 +235,7 @@ "edge-tts", "elevenlabs-tts-models", "fal-ai", + "fb", "featherless", "fireworks", "gc", diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index e28594df..9d2b4aed 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -15,6 +15,22 @@ }, "format": "openai" }, + "alims-intl": { + "baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + "headers": {}, + "quirks": { + "preserveCacheControl": true + }, + "format": "openai" + }, + "alitp-intl": { + "baseUrl": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions", + "headers": {}, + "quirks": { + "preserveCacheControl": true + }, + "format": "openai" + }, "anthropic": { "baseUrl": "https://api.anthropic.com/v1/messages", "format": "claude", @@ -52,6 +68,16 @@ "clientSecret": "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", "tokenUrl": "https://oauth2.googleapis.com/token" }, + "api-airforce": { + "baseUrl": "https://api.airforce/v1/chat/completions", + "validateUrl": "https://api.airforce/v1/models", + "headers": { + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy" + }, + "forceStream": true, + "format": "openai" + }, "assemblyai": { "baseUrl": "https://api.assemblyai.com/v1/audio/transcriptions", "validateUrl": "https://api.assemblyai.com/v1/account", @@ -62,11 +88,26 @@ "headers": {}, "format": "openai" }, + "baidu": { + "baseUrl": "https://qianfan.baidubce.com/v2/chat/completions", + "validateUrl": "https://qianfan.baidubce.com/v2/models", + "format": "openai" + }, + "bazaarlink": { + "baseUrl": "https://bazaarlink.ai/api/v1/chat/completions", + "validateUrl": "https://bazaarlink.ai/api/v1/models", + "format": "openai" + }, "blackbox": { "baseUrl": "https://api.blackbox.ai/v1/chat/completions", "thinkingFormat": "openai", "format": "openai" }, + "bluesminds": { + "baseUrl": "https://api.bluesminds.com/v1/chat/completions", + "validateUrl": "https://api.bluesminds.com/v1/models", + "format": "openai" + }, "byteplus": { "baseUrl": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions", "headers": {}, @@ -93,7 +134,7 @@ "Anthropic-Version": "2023-06-01", "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28", "Anthropic-Dangerous-Direct-Browser-Access": "true", - "User-Agent": "claude-cli/2.1.258 (external, sdk-cli)", + "User-Agent": "claude-cli/2.1.280 (external, sdk-cli)", "X-App": "cli", "X-Stainless-Helper-Method": "stream", "X-Stainless-Retry-Count": "0", @@ -141,6 +182,7 @@ "combined": true, "header": "Authorization", "scheme": "bearer", + "preserveHookAuth": true, "hooks": [ "clineHeaders" ] @@ -160,6 +202,7 @@ "combined": true, "header": "Authorization", "scheme": "bearer", + "preserveHookAuth": true, "hooks": [ "clineHeaders" ] @@ -195,6 +238,29 @@ "format": "openai", "tokenUrl": "https://copilot.tencent.com/v2/plugin/auth/token" }, + "codebuddy-intl": { + "baseUrl": "https://www.codebuddy.ai/v2/chat/completions", + "forceStream": true, + "thinkingFormat": "openai", + "headers": { + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-Product": "SaaS", + "X-IDE-Type": "IDE", + "X-IDE-Name": "IDE", + "x-requested-with": "XMLHttpRequest", + "x-codebuddy-request": "1" + }, + "auth": { + "combined": true, + "header": "Authorization", + "scheme": "bearer" + }, + "usage": { + "url": "https://www.codebuddy.ai/v2/billing/meter/get-user-resource" + }, + "format": "openai", + "tokenUrl": "https://www.codebuddy.ai/v2/plugin/auth/token" + }, "codex": { "baseUrl": "https://chatgpt.com/backend-api/codex/responses", "format": "openai-responses", @@ -290,6 +356,26 @@ "validateUrl": "https://api.fireworks.ai/inference/v1/models", "format": "openai" }, + "freebuff": { + "baseUrl": "https://www.codebuff.com/api/v1/chat/completions", + "format": "openai", + "headers": { + "User-Agent": "ai-sdk/openai-compatible/1.0/codebuff" + }, + "retry": { + "429": { + "attempts": 2, + "delayMs": 2000 + }, + "503": { + "attempts": 2, + "delayMs": 1500 + } + }, + "usage": { + "url": "https://www.codebuff.com/api/v1/freebuff/session" + } + }, "gemini-cli": { "baseUrl": "https://cloudcode-pa.googleapis.com/v1internal", "format": "gemini-cli", @@ -472,6 +558,11 @@ "clientSecret": "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW", "tokenUrl": "https://iflow.cn/oauth/token" }, + "kilo-gateway": { + "baseUrl": "https://api.kilo.ai/api/gateway/chat/completions", + "validateUrl": "https://api.kilo.ai/api/gateway/models", + "format": "openai" + }, "kilocode": { "baseUrl": "https://api.kilo.ai/api/openrouter/chat/completions", "headers": {}, @@ -573,6 +664,11 @@ "limitsPath": "/getUsageLimits" } }, + "llm7": { + "baseUrl": "https://api.llm7.io/v1/chat/completions", + "validateUrl": "https://api.llm7.io/v1/models", + "format": "openai" + }, "mimo-free": { "baseUrl": "https://api.xiaomimimo.com/api/free-ai/openai/chat", "noAuth": true, @@ -695,6 +791,11 @@ "noAuth": true, "format": "openai" }, + "morph": { + "baseUrl": "https://api.morphllm.com/v1/chat/completions", + "validateUrl": "https://api.morphllm.com/v1/models", + "format": "openai" + }, "nanobanana": { "baseUrl": "https://api.nanobananaapi.ai/v1/chat/completions", "validateUrl": "https://api.nanobananaapi.ai/v1/models", @@ -719,16 +820,6 @@ "validateUrl": "https://ollama.com/api/tags", "format": "ollama" }, - "qoder-cn": { - "baseUrl": "https://gateway.qoder.com.cn/algo/api/v2/service/pro/sse/agent_chat_generation", - "headers": {}, - "timeoutMs": 120000, - "stallTimeoutMs": 120000, - "usage": { - "url": "https://openapi.qoder.com.cn/api/v2/quota/usage" - }, - "format": "openai" - }, "openai": { "baseUrl": "https://api.openai.com/v1/chat/completions", "forceStream": true, @@ -833,6 +924,11 @@ }, "format": "openai" }, + "perplexity-agent": { + "baseUrl": "https://api.perplexity.ai/v1/responses", + "validateUrl": "https://api.perplexity.ai/v1/models", + "format": "openai-responses" + }, "perplexity-web": { "baseUrl": "https://www.perplexity.ai/rest/sse/perplexity_ask", "format": "perplexity-web", @@ -843,10 +939,20 @@ "validateUrl": "https://api.perplexity.ai/models", "format": "openai" }, - "perplexity-agent": { - "baseUrl": "https://api.perplexity.ai/v1/responses", - "validateUrl": "https://api.perplexity.ai/v1/models", - "format": "openai-responses" + "poolside": { + "baseUrl": "https://inference.poolside.ai/v1/chat/completions", + "validateUrl": "https://inference.poolside.ai/v1/models", + "format": "openai" + }, + "qoder-cn": { + "baseUrl": "https://gateway.qoder.com.cn/algo/api/v2/service/pro/sse/agent_chat_generation", + "headers": {}, + "timeoutMs": 120000, + "stallTimeoutMs": 120000, + "usage": { + "url": "https://openapi.qoder.com.cn/api/v2/quota/usage" + }, + "format": "openai" }, "qoder": { "baseUrl": "https://api3.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation", @@ -858,17 +964,33 @@ }, "format": "openai" }, + "sambanova": { + "baseUrl": "https://api.sambanova.ai/v1/chat/completions", + "validateUrl": "https://api.sambanova.ai/v1/models", + "format": "openai" + }, "siliconflow": { "baseUrl": "https://api.siliconflow.com/v1/chat/completions", "validateUrl": "https://api.siliconflow.com/v1/models", "thinkingFormat": "openai", "format": "openai" }, + "tencent": { + "baseUrl": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + "validateUrl": "https://api.hunyuan.cloud.tencent.com/v1/models", + "format": "openai" + }, "together": { "baseUrl": "https://api.together.xyz/v1/chat/completions", "validateUrl": "https://api.together.xyz/v1/models", "format": "openai" }, + "tokenrouter": { + "baseUrl": "https://api.tokenrouter.com/v1/chat/completions", + "validateUrl": "https://api.tokenrouter.com/v1/models", + "thinkingFormat": "tokenrouter", + "format": "openai" + }, "venice": { "baseUrl": "https://api.venice.ai/api/v1/chat/completions", "validateUrl": "https://api.venice.ai/api/v1/models", @@ -969,37 +1091,6 @@ } ] }, - "alims-intl": { - "baseUrl": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", - "headers": {}, - "quirks": { - "preserveCacheControl": true - }, - "format": "openai" - }, - "codebuddy-intl": { - "baseUrl": "https://www.codebuddy.ai/v2/chat/completions", - "forceStream": true, - "thinkingFormat": "openai", - "headers": { - "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", - "X-Product": "SaaS", - "X-IDE-Type": "IDE", - "X-IDE-Name": "IDE", - "x-requested-with": "XMLHttpRequest", - "x-codebuddy-request": "1" - }, - "auth": { - "combined": true, - "header": "Authorization", - "scheme": "bearer" - }, - "usage": { - "url": "https://www.codebuddy.ai/v2/billing/meter/get-user-resource" - }, - "format": "openai", - "tokenUrl": "https://www.codebuddy.ai/v2/plugin/auth/token" - }, "zed": { "baseUrl": "https://cloud.zed.dev/completions", "format": "openai", @@ -1016,74 +1107,5 @@ "url": "https://cloud.zed.dev/client/users/me" }, "modelsUrl": "https://cloud.zed.dev/models" - }, - "api-airforce": { - "baseUrl": "https://api.airforce/v1/chat/completions", - "validateUrl": "https://api.airforce/v1/models", - "headers": { - "HTTP-Referer": "https://endpoint-proxy.local", - "X-Title": "Endpoint Proxy" - }, - "forceStream": true, - "format": "openai" - }, - "baidu": { - "baseUrl": "https://qianfan.baidubce.com/v2/chat/completions", - "validateUrl": "https://qianfan.baidubce.com/v2/models", - "format": "openai" - }, - "bazaarlink": { - "baseUrl": "https://bazaarlink.ai/api/v1/chat/completions", - "validateUrl": "https://bazaarlink.ai/api/v1/models", - "format": "openai" - }, - "bluesminds": { - "baseUrl": "https://api.bluesminds.com/v1/chat/completions", - "validateUrl": "https://api.bluesminds.com/v1/models", - "format": "openai" - }, - "kilo-gateway": { - "baseUrl": "https://api.kilo.ai/api/gateway/chat/completions", - "validateUrl": "https://api.kilo.ai/api/gateway/models", - "format": "openai" - }, - "llm7": { - "baseUrl": "https://api.llm7.io/v1/chat/completions", - "validateUrl": "https://api.llm7.io/v1/models", - "format": "openai" - }, - "sambanova": { - "baseUrl": "https://api.sambanova.ai/v1/chat/completions", - "validateUrl": "https://api.sambanova.ai/v1/models", - "format": "openai" - }, - "tencent": { - "baseUrl": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", - "validateUrl": "https://api.hunyuan.cloud.tencent.com/v1/models", - "format": "openai" - }, - "morph": { - "baseUrl": "https://api.morphllm.com/v1/chat/completions", - "validateUrl": "https://api.morphllm.com/v1/models", - "format": "openai" - }, - "poolside": { - "baseUrl": "https://inference.poolside.ai/v1/chat/completions", - "validateUrl": "https://inference.poolside.ai/v1/models", - "format": "openai" - }, - "tokenrouter": { - "baseUrl": "https://api.tokenrouter.com/v1/chat/completions", - "validateUrl": "https://api.tokenrouter.com/v1/models", - "thinkingFormat": "tokenrouter", - "format": "openai" - }, - "alitp-intl": { - "baseUrl": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions", - "headers": {}, - "quirks": { - "preserveCacheControl": true - }, - "format": "openai" } } \ No newline at end of file diff --git a/tests/bun.lock b/tests/bun.lock new file mode 100644 index 00000000..7b6cd9af --- /dev/null +++ b/tests/bun.lock @@ -0,0 +1,151 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "9router-tests", + "devDependencies": { + "vitest": "^4.1.11", + }, + }, + }, + "packages": { + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@oxc-project/types": ["@oxc-project/types@0.150.0", "", {}, "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw=="], + + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.9", "", { "os": "android", "cpu": "arm" }, "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw=="], + + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.9", "", { "os": "android", "cpu": "arm64" }, "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg=="], + + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw=="], + + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw=="], + + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g=="], + + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.9", "", { "os": "linux", "cpu": "arm" }, "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw=="], + + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw=="], + + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA=="], + + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.9", "", { "os": "linux", "cpu": "ppc64" }, "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw=="], + + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.9", "", { "os": "linux", "cpu": "s390x" }, "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ=="], + + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q=="], + + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.9", "", { "os": "linux", "cpu": "x64" }, "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ=="], + + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.9", "", { "os": "none", "cpu": "arm64" }, "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg=="], + + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww=="], + + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], + + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], + + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], + + "rolldown": ["rolldown@1.2.9", "", { "dependencies": { "@oxc-project/types": "=0.150.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.9", "@rolldown/binding-android-arm64": "1.2.9", "@rolldown/binding-darwin-arm64": "1.2.9", "@rolldown/binding-darwin-x64": "1.2.9", "@rolldown/binding-freebsd-x64": "1.2.9", "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", "@rolldown/binding-linux-arm64-gnu": "1.2.9", "@rolldown/binding-linux-arm64-musl": "1.2.9", "@rolldown/binding-linux-ppc64-gnu": "1.2.9", "@rolldown/binding-linux-s390x-gnu": "1.2.9", "@rolldown/binding-linux-x64-gnu": "1.2.9", "@rolldown/binding-linux-x64-musl": "1.2.9", "@rolldown/binding-openharmony-arm64": "1.2.9", "@rolldown/binding-win32-arm64-msvc": "1.2.9", "@rolldown/binding-win32-x64-msvc": "1.2.9" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], + + "vite": ["vite@8.3.0", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.28", "rolldown": "~1.2.6", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ=="], + + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + } +} diff --git a/tests/package.json b/tests/package.json index 7d3544b8..0c51e2d6 100644 --- a/tests/package.json +++ b/tests/package.json @@ -9,7 +9,7 @@ "test:watch": "vitest --reporter=verbose" }, "devDependencies": { - "vitest": "^4.0.0" + "vitest": "^4.1.11" }, "engines": { "node": ">=18" diff --git a/tests/setup/isolateDataDir.js b/tests/setup/isolateDataDir.js new file mode 100644 index 00000000..fa207952 --- /dev/null +++ b/tests/setup/isolateDataDir.js @@ -0,0 +1,29 @@ +// Global test isolation: never let the unit/translator suite write to the user's +// real database (~/.9router). Some tests (e.g. zed-live-models, zed-native-auth) +// exercise real route handlers that call createProviderConnection — without this +// they append test rows ("zed-live-*@example.com", "guard-*@example.com", +// "Account N") straight into the live DB. +// +// GUARD — DO NOT DELETE this file or remove it from vitest.config.js setupFiles. +// See AGENTS.md §2. Covered by tests/unit/test-data-dir-isolation.test.js. +// +// DATA_DIR must be set before src/lib/dataDir.js is imported (it reads the env +// at module-eval time), which is exactly what a vitest setupFile guarantees. +// +// Escape hatches: +// - RUN_REAL=1 → keep the real DATA_DIR so *.real.test.js can read it. +// - DATA_DIR= → respect an explicit override (CI / manual isolation). +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const RUN_REAL = process.env.RUN_REAL === "1"; +const explicit = process.env.DATA_DIR; + +if (!RUN_REAL && !explicit) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-test-")); + process.env.DATA_DIR = dir; + process.on("exit", () => { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } + }); +} diff --git a/tests/translator/__snapshots__/golden-url-header.test.js.snap b/tests/translator/__snapshots__/golden-url-header.test.js.snap new file mode 100644 index 00000000..93e52d4d --- /dev/null +++ b/tests/translator/__snapshots__/golden-url-header.test.js.snap @@ -0,0 +1,1989 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`GOLDEN buildHeaders (default executor providers) > alicode → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > alicode-intl → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > alims-intl → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > alitp-intl → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > anthropic → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": "", + }, + "nonStream": { + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "x-api-key": "", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > api-airforce → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > assemblyai → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > baidu → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > bazaarlink → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > blackbox → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > bluesminds → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > byteplus → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > cerebras → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > chutes → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > claude → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28", + "Anthropic-Dangerous-Direct-Browser-Access": "true", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "User-Agent": "claude-cli/2.1.280 (external, sdk-cli)", + "X-App": "cli", + "X-Stainless-Arch": "arm64", + "X-Stainless-Helper-Method": "stream", + "X-Stainless-Lang": "js", + "X-Stainless-Os": "MacOS", + "X-Stainless-Package-Version": "0.80.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime": "node", + "X-Stainless-Runtime-Version": "v24.14.0", + "X-Stainless-Timeout": "600", + "x-api-key": "", + }, + "nonStream": { + "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28", + "Anthropic-Dangerous-Direct-Browser-Access": "true", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "User-Agent": "claude-cli/2.1.280 (external, sdk-cli)", + "X-App": "cli", + "X-Stainless-Arch": "arm64", + "X-Stainless-Helper-Method": "stream", + "X-Stainless-Lang": "js", + "X-Stainless-Os": "MacOS", + "X-Stainless-Package-Version": "0.80.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime": "node", + "X-Stainless-Runtime-Version": "v24.14.0", + "X-Stainless-Timeout": "600", + "x-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advanced-tool-use-2025-11-20,effort-2025-11-24,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28", + "Anthropic-Dangerous-Direct-Browser-Access": "true", + "Anthropic-Version": "2023-06-01", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "claude-cli/2.1.280 (external, sdk-cli)", + "X-App": "cli", + "X-Stainless-Arch": "arm64", + "X-Stainless-Helper-Method": "stream", + "X-Stainless-Lang": "js", + "X-Stainless-Os": "MacOS", + "X-Stainless-Package-Version": "0.80.0", + "X-Stainless-Retry-Count": "0", + "X-Stainless-Runtime": "node", + "X-Stainless-Runtime-Version": "v24.14.0", + "X-Stainless-Timeout": "600", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > cline → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "Cline/3.0.61", + "X-CLIENT-TYPE": "cline-cli", + "X-CLIENT-VERSION": "3.0.61", + "X-CORE-VERSION": "3.0.61", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "cli", + "X-PLATFORM-VERSION": "3.0.61", + "X-Title": "Cline", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "Cline/3.0.61", + "X-CLIENT-TYPE": "cline-cli", + "X-CLIENT-VERSION": "3.0.61", + "X-CORE-VERSION": "3.0.61", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "cli", + "X-PLATFORM-VERSION": "3.0.61", + "X-Title": "Cline", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "Cline/3.0.61", + "X-CLIENT-TYPE": "cline-cli", + "X-CLIENT-VERSION": "3.0.61", + "X-CORE-VERSION": "3.0.61", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "cli", + "X-PLATFORM-VERSION": "3.0.61", + "X-Title": "Cline", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > clinepass → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "Cline/3.0.61", + "X-CLIENT-TYPE": "cline-cli", + "X-CLIENT-VERSION": "3.0.61", + "X-CORE-VERSION": "3.0.61", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "cli", + "X-PLATFORM-VERSION": "3.0.61", + "X-Title": "Cline", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "Cline/3.0.61", + "X-CLIENT-TYPE": "cline-cli", + "X-CLIENT-VERSION": "3.0.61", + "X-CORE-VERSION": "3.0.61", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "cli", + "X-PLATFORM-VERSION": "3.0.61", + "X-Title": "Cline", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://cline.bot", + "User-Agent": "Cline/3.0.61", + "X-CLIENT-TYPE": "cline-cli", + "X-CLIENT-VERSION": "3.0.61", + "X-CORE-VERSION": "3.0.61", + "X-IS-MULTIROOT": "false", + "X-PLATFORM": "cli", + "X-PLATFORM-VERSION": "3.0.61", + "X-Title": "Cline", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > cloudflare-ai → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-cn → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "CLI", + "X-IDE-Type": "CLI", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "CLI", + "X-IDE-Type": "CLI", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "CLI/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "CLI", + "X-IDE-Type": "CLI", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > codebuddy-intl → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "IDE", + "X-IDE-Type": "IDE", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "IDE", + "X-IDE-Type": "IDE", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "IDE/2.108.1 CodeBuddy/2.108.1", + "X-IDE-Name": "IDE", + "X-IDE-Type": "IDE", + "X-Product": "SaaS", + "x-codebuddy-request": "1", + "x-requested-with": "XMLHttpRequest", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > cohere → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > deepgram → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > deepseek → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > devin-cli → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > featherless → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > fireworks → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > freebuff → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "ai-sdk/openai-compatible/1.0/codebuff", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "ai-sdk/openai-compatible/1.0/codebuff", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "ai-sdk/openai-compatible/1.0/codebuff", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > gemini → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Content-Type": "application/json", + "x-goog-api-key": "", + }, + "nonStream": { + "Content-Type": "application/json", + "x-goog-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > gitlab → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > glm → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, + "nonStream": { + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > glm-cn → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > grok-cli → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "grok-shell/0.2.99 (linux; x86_64)", + "x-grok-client-identifier": "grok-shell", + "x-grok-client-version": "0.2.99", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "grok-shell/0.2.99 (linux; x86_64)", + "x-grok-client-identifier": "grok-shell", + "x-grok-client-version": "0.2.99", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "grok-shell/0.2.99 (linux; x86_64)", + "x-grok-client-identifier": "grok-shell", + "x-grok-client-version": "0.2.99", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > groq → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > hyperbolic → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > kilo-gateway → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > kilocode → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > kimchi → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "kimchi/0.1.50", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "kimchi/0.1.50", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "User-Agent": "kimchi/0.1.50", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > kimi → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "X-Msh-Device-Id": "kimi-", + "X-Msh-Device-Model": "Linux x64", + "X-Msh-Device-Name": "host1760805970", + "X-Msh-Platform": "9router", + "X-Msh-Version": "0.5.86", + "x-api-key": "", + }, + "nonStream": { + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "X-Msh-Device-Id": "kimi-", + "X-Msh-Device-Model": "Linux x64", + "X-Msh-Device-Name": "host1760805970", + "X-Msh-Platform": "9router", + "X-Msh-Version": "0.5.86", + "x-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "X-Msh-Device-Id": "kimi-", + "X-Msh-Device-Model": "Linux x64", + "X-Msh-Device-Name": "host1760805970", + "X-Msh-Platform": "9router", + "X-Msh-Version": "0.5.86", + "x-api-key": "", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > llm7 → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > minimax → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, + "nonStream": { + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > minimax-cn → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, + "nonStream": { + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, + "oauth": { + "Accept": "text/event-stream", + "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14", + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json", + "x-api-key": "", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > mistral → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > mmf → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > morph → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > nanobanana → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > nebius → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > nvidia → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > ollama → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > openai → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > opencode-zen → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > openrouter → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + "HTTP-Referer": "https://endpoint-proxy.local", + "X-Title": "Endpoint Proxy", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > perplexity → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > perplexity-agent → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > poolside → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > qoder-cn → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > sambanova → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > siliconflow → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > tencent → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > together → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > tokenrouter → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > trae → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "", + "Content-Type": "application/json", + "Referer": "https://solo.trae.ai/", + "X-Preferenced-Language": "en", + "X-Trae-Client-Type": "web", + }, + "nonStream": { + "Authorization": "", + "Content-Type": "application/json", + "Referer": "https://solo.trae.ai/", + "X-Preferenced-Language": "en", + "X-Trae-Client-Type": "web", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "", + "Content-Type": "application/json", + "Referer": "https://solo.trae.ai/", + "X-Preferenced-Language": "en", + "X-Trae-Client-Type": "web", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > venice → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > vercel-ai-gateway → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > volcengine-ark → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > windsurf → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "", + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + }, + "nonStream": { + "Accept": "application/grpc-web+proto", + "Authorization": "", + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "", + "Content-Type": "application/grpc-web+proto", + "X-Grpc-Web": "1", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > xai → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > xiaomi-mimo → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "nonStream": { + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "Bearer ", + "Content-Type": "application/json", + }, +} +`; + +exports[`GOLDEN buildHeaders (default executor providers) > zed → headers (apiKey / oauth) 1`] = ` +{ + "apiKey": { + "Accept": "text/event-stream", + "Authorization": "", + "Content-Type": "application/json", + "content-type": "application/json", + }, + "nonStream": { + "Authorization": "", + "Content-Type": "application/json", + "content-type": "application/json", + }, + "oauth": { + "Accept": "text/event-stream", + "Authorization": "", + "Content-Type": "application/json", + "content-type": "application/json", + }, +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > alicode → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://coding.dashscope.aliyuncs.com/v1/chat/completions", + "stream": "https://coding.dashscope.aliyuncs.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > alicode-intl → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", + "stream": "https://coding-intl.dashscope.aliyuncs.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > alims-intl → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", + "stream": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > alitp-intl → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions", + "stream": "https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > anthropic → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.anthropic.com/v1/messages", + "stream": "https://api.anthropic.com/v1/messages", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > api-airforce → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.airforce/v1/chat/completions", + "stream": "https://api.airforce/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > assemblyai → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.assemblyai.com/v1/audio/transcriptions", + "stream": "https://api.assemblyai.com/v1/audio/transcriptions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > baidu → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://qianfan.baidubce.com/v2/chat/completions", + "stream": "https://qianfan.baidubce.com/v2/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > bazaarlink → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://bazaarlink.ai/api/v1/chat/completions", + "stream": "https://bazaarlink.ai/api/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > blackbox → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.blackbox.ai/v1/chat/completions", + "stream": "https://api.blackbox.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > bluesminds → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.bluesminds.com/v1/chat/completions", + "stream": "https://api.bluesminds.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > byteplus → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions", + "stream": "https://ark.ap-southeast.bytepluses.com/api/coding/v3/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > cerebras → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.cerebras.ai/v1/chat/completions", + "stream": "https://api.cerebras.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > chutes → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://llm.chutes.ai/v1/chat/completions", + "stream": "https://llm.chutes.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > claude → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.anthropic.com/v1/messages?beta=true", + "stream": "https://api.anthropic.com/v1/messages?beta=true", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > cline → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.cline.bot/api/v1/chat/completions", + "stream": "https://api.cline.bot/api/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > clinepass → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.cline.bot/api/v1/chat/completions", + "stream": "https://api.cline.bot/api/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > cloudflare-ai → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions", + "stream": "https://api.cloudflare.com/client/v4/accounts/ACC123/ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > codebuddy-cn → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://copilot.tencent.com/v2/chat/completions", + "stream": "https://copilot.tencent.com/v2/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > codebuddy-intl → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://www.codebuddy.ai/v2/chat/completions", + "stream": "https://www.codebuddy.ai/v2/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > cohere → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.cohere.ai/v1/chat/completions", + "stream": "https://api.cohere.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > deepgram → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.deepgram.com/v1/listen", + "stream": "https://api.deepgram.com/v1/listen", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > deepseek → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.deepseek.com/chat/completions", + "stream": "https://api.deepseek.com/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > devin-cli → url (stream + non-stream) 1`] = ` +{ + "nonStream": "devin://acp/stdio", + "stream": "devin://acp/stdio", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > featherless → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.featherless.ai/v1/chat/completions", + "stream": "https://api.featherless.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > fireworks → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.fireworks.ai/inference/v1/chat/completions", + "stream": "https://api.fireworks.ai/inference/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > freebuff → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://www.codebuff.com/api/v1/chat/completions", + "stream": "https://www.codebuff.com/api/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > gemini → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://generativelanguage.googleapis.com/v1beta/models/test-model:generateContent", + "stream": "https://generativelanguage.googleapis.com/v1beta/models/test-model:streamGenerateContent?alt=sse", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > gitlab → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://gitlab.com/api/v4/chat/completions", + "stream": "https://gitlab.com/api/v4/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > glm → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.z.ai/api/anthropic/v1/messages?beta=true", + "stream": "https://api.z.ai/api/anthropic/v1/messages?beta=true", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > glm-cn → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", + "stream": "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > grok-cli → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://cli-chat-proxy.grok.com/v1/responses", + "stream": "https://cli-chat-proxy.grok.com/v1/responses", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > groq → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.groq.com/openai/v1/chat/completions", + "stream": "https://api.groq.com/openai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > hyperbolic → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.hyperbolic.xyz/v1/chat/completions", + "stream": "https://api.hyperbolic.xyz/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > kilo-gateway → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.kilo.ai/api/gateway/chat/completions", + "stream": "https://api.kilo.ai/api/gateway/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > kilocode → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.kilo.ai/api/openrouter/chat/completions", + "stream": "https://api.kilo.ai/api/openrouter/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > kimchi → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://llm.kimchi.dev/openai/v1/chat/completions", + "stream": "https://llm.kimchi.dev/openai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > kimi → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.kimi.com/coding/v1/messages?beta=true", + "stream": "https://api.kimi.com/coding/v1/messages?beta=true", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > llm7 → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.llm7.io/v1/chat/completions", + "stream": "https://api.llm7.io/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > minimax → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.minimax.io/anthropic/v1/messages?beta=true", + "stream": "https://api.minimax.io/anthropic/v1/messages?beta=true", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > minimax-cn → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.minimaxi.com/anthropic/v1/messages?beta=true", + "stream": "https://api.minimaxi.com/anthropic/v1/messages?beta=true", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > mistral → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.mistral.ai/v1/chat/completions", + "stream": "https://api.mistral.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > mmf → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.xiaomimimo.com/api/free-ai/openai/chat", + "stream": "https://api.xiaomimimo.com/api/free-ai/openai/chat", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > morph → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.morphllm.com/v1/chat/completions", + "stream": "https://api.morphllm.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > nanobanana → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.nanobananaapi.ai/v1/chat/completions", + "stream": "https://api.nanobananaapi.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > nebius → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.studio.nebius.ai/v1/chat/completions", + "stream": "https://api.studio.nebius.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > nvidia → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://integrate.api.nvidia.com/v1/chat/completions", + "stream": "https://integrate.api.nvidia.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > ollama → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://ollama.com/api/chat", + "stream": "https://ollama.com/api/chat", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > openai → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.openai.com/v1/chat/completions", + "stream": "https://api.openai.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > opencode-zen → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://opencode.ai/zen/v1/chat/completions", + "stream": "https://opencode.ai/zen/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > openrouter → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://openrouter.ai/api/v1/chat/completions", + "stream": "https://openrouter.ai/api/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > perplexity → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.perplexity.ai/chat/completions", + "stream": "https://api.perplexity.ai/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > perplexity-agent → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.perplexity.ai/v1/responses", + "stream": "https://api.perplexity.ai/v1/responses", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > poolside → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://inference.poolside.ai/v1/chat/completions", + "stream": "https://inference.poolside.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > qoder-cn → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://gateway.qoder.com.cn/algo/api/v2/service/pro/sse/agent_chat_generation", + "stream": "https://gateway.qoder.com.cn/algo/api/v2/service/pro/sse/agent_chat_generation", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > sambanova → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.sambanova.ai/v1/chat/completions", + "stream": "https://api.sambanova.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > siliconflow → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.siliconflow.com/v1/chat/completions", + "stream": "https://api.siliconflow.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > tencent → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", + "stream": "https://api.hunyuan.cloud.tencent.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > together → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.together.xyz/v1/chat/completions", + "stream": "https://api.together.xyz/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > tokenrouter → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.tokenrouter.com/v1/chat/completions", + "stream": "https://api.tokenrouter.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > trae → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://core-normal.trae.ai/api/remote/v1", + "stream": "https://core-normal.trae.ai/api/remote/v1", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > venice → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.venice.ai/api/v1/chat/completions", + "stream": "https://api.venice.ai/api/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > vercel-ai-gateway → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://ai-gateway.vercel.sh/v1/chat/completions", + "stream": "https://ai-gateway.vercel.sh/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > volcengine-ark → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions", + "stream": "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > windsurf → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage", + "stream": "https://server.codeium.com/exa.language_server_pb.LanguageServerService/GetChatMessage", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > xai → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.x.ai/v1/chat/completions", + "stream": "https://api.x.ai/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > xiaomi-mimo → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://api.xiaomimimo.com/v1/chat/completions", + "stream": "https://api.xiaomimimo.com/v1/chat/completions", +} +`; + +exports[`GOLDEN buildUrl (default executor providers) > zed → url (stream + non-stream) 1`] = ` +{ + "nonStream": "https://cloud.zed.dev/completions", + "stream": "https://cloud.zed.dev/completions", +} +`; diff --git a/tests/unit/cline-data-envelope.test.js b/tests/unit/cline-data-envelope.test.js new file mode 100644 index 00000000..e6c7bb33 --- /dev/null +++ b/tests/unit/cline-data-envelope.test.js @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/usageDb.js", () => ({ + appendRequestLog: vi.fn(async () => {}), + saveRequestDetail: vi.fn(async () => {}), + saveRequestUsage: vi.fn(async () => {}) +})); + +const { unwrapDataEnvelope } = await import("../../open-sse/handlers/chatCore/nonStreamingHandler.js"); +const { buildClineHeaders } = await import("../../open-sse/shared/clineAuth.js"); + +// Cline gateway wraps non-streaming Chat Completions in {data, success}. +// handleNonStreamingResponse calls unwrapDataEnvelope unconditionally (it runs +// before the needsTranslation gate, which openai→openai never passes). +const ENVELOPED = { + data: { + id: "gen-123", + object: "chat.completion", + created: 1789056634, + model: "deepseek/deepseek-v4-flash-0731", + choices: [{ index: 0, message: { role: "assistant", content: "Hi there!" }, finish_reason: "stop" }], + usage: { prompt_tokens: 9, completion_tokens: 29, total_tokens: 38 }, + }, + success: true, +}; + +describe("cline {data} envelope unwrap", () => { + it("unwraps choices/usage to top level", () => { + const out = unwrapDataEnvelope(structuredClone(ENVELOPED)); + expect(out.choices?.[0]?.message?.content).toBe("Hi there!"); + expect(out.usage?.prompt_tokens).toBe(9); + }); + + it("leaves plain OpenAI bodies untouched", () => { + const out = unwrapDataEnvelope(structuredClone(ENVELOPED.data)); + expect(out.choices?.[0]?.message?.content).toBe("Hi there!"); + }); + + it("prefers top-level choices when both exist", () => { + const body = { ...structuredClone(ENVELOPED), choices: [{ index: 0, message: { role: "assistant", content: "top" }, finish_reason: "stop" }] }; + const out = unwrapDataEnvelope(body); + expect(out.choices?.[0]?.message?.content).toBe("top"); + }); + + it("ignores non-envelope bodies", () => { + expect(unwrapDataEnvelope(null)).toBe(null); + expect(unwrapDataEnvelope({ error: "x" })).toEqual({ error: "x" }); + }); +}); + +describe("cline auth header shape", () => { + it("sends API keys as plain Bearer", () => { + expect(buildClineHeaders("sk_abc", {}, { isApiKey: true }).Authorization).toBe("Bearer sk_abc"); + }); + + it("prefixes WorkOS JWT OAuth tokens with workos:", () => { + const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig"; + expect(buildClineHeaders(jwt).Authorization).toBe(`Bearer workos:${jwt}`); + expect(buildClineHeaders(`workos:${jwt}`).Authorization).toBe(`Bearer workos:${jwt}`); + }); + + it("does not workos:-prefix opaque tokens (API keys, clp_…)", () => { + expect(buildClineHeaders("tok123").Authorization).toBe("Bearer tok123"); + expect(buildClineHeaders("clp_abc123").Authorization).toBe("Bearer clp_abc123"); + }); + + it("sends Cline product identity headers (free-model gate)", () => { + const h = buildClineHeaders("tok123"); + expect(h["X-CLIENT-TYPE"]).toBe("cline-cli"); + expect(h["User-Agent"]).toMatch(/^Cline\//); + }); +}); diff --git a/tests/unit/codebuddy-intl-backfill.test.js b/tests/unit/codebuddy-intl-backfill.test.js new file mode 100644 index 00000000..a44b33b2 --- /dev/null +++ b/tests/unit/codebuddy-intl-backfill.test.js @@ -0,0 +1,119 @@ +// Existing CodeBuddy Intl OAuth connections created before mapTokens surfaced +// identity show up as "Account N" with no email. The self-healing backfill must +// fill email + displayName from the access-token JWT so the dashboard shows the +// real identity without forcing a re-login. +// +// backfillCodeBuddyIntlIdentity has a module-level run-once guard, so each test +// re-imports a fresh module instance via vi.resetModules() + dynamic import. +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderConnections: vi.fn(), + updateProviderConnection: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnections: mocks.getProviderConnections, + updateProviderConnection: mocks.updateProviderConnection, +})); + +// The providers index imports open-sse/index.js for proxy-aware fetch; stub it. +vi.mock("open-sse/index.js", () => ({})); + +function makeJwt(payload) { + const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url"); + return `${b64({ alg: "RS256", typ: "JWT" })}.${b64(payload)}.sig`; +} + +const JWT = makeJwt({ + iss: "https://www.codebuddy.ai/auth/realms/copilot", + email: "aghiyaramadh@gmail.com", + name: "aghiya ramadh", +}); + +async function loadBackfill() { + vi.resetModules(); + const mod = await import("../../src/lib/oauth/providers/index.js"); + return mod.backfillCodeBuddyIntlIdentity; +} + +describe("backfillCodeBuddyIntlIdentity", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.updateProviderConnection.mockResolvedValue({}); + }); + + it("fills email + displayName for a legacy 'Account N' connection", async () => { + mocks.getProviderConnections.mockResolvedValue([ + { + id: "conn-legacy", + provider: "codebuddy-intl", + authType: "oauth", + name: "Account 1", + email: null, + displayName: null, + accessToken: JWT, + }, + ]); + + const backfill = await loadBackfill(); + await backfill(); + + expect(mocks.updateProviderConnection).toHaveBeenCalledTimes(1); + const [id, patch] = mocks.updateProviderConnection.mock.calls[0]; + expect(id).toBe("conn-legacy"); + expect(patch.email).toBe("aghiyaramadh@gmail.com"); + expect(patch.displayName).toBe("aghiya ramadh"); + // Generic placeholder name is replaced by the identity. + expect(patch.name).toBe("aghiyaramadh@gmail.com"); + }); + + it("keeps a user-customized name (does not overwrite non-generic names)", async () => { + mocks.getProviderConnections.mockResolvedValue([ + { + id: "conn-custom", + provider: "codebuddy-intl", + authType: "oauth", + name: "My Work Account", + email: null, + displayName: null, + accessToken: JWT, + }, + ]); + + const backfill = await loadBackfill(); + await backfill(); + + expect(mocks.updateProviderConnection).toHaveBeenCalledTimes(1); + const [, patch] = mocks.updateProviderConnection.mock.calls[0]; + expect(patch.email).toBe("aghiyaramadh@gmail.com"); + expect(patch.name).toBeUndefined(); + }); + + it("leaves connections that already have identity untouched", async () => { + mocks.getProviderConnections.mockResolvedValue([ + { + id: "conn-ok", + provider: "codebuddy-intl", + authType: "oauth", + name: "aghiya ramadh", + email: "aghiyaramadh@gmail.com", + displayName: "aghiya ramadh", + accessToken: JWT, + }, + ]); + + const backfill = await loadBackfill(); + await backfill(); + expect(mocks.updateProviderConnection).not.toHaveBeenCalled(); + }); + it("ignores other providers", async () => { + mocks.getProviderConnections.mockResolvedValue([ + { id: "c1", provider: "codex", authType: "oauth", email: null, accessToken: JWT }, + ]); + + const backfill = await loadBackfill(); + await backfill(); + expect(mocks.updateProviderConnection).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/codebuddy-intl-connection.test.js b/tests/unit/codebuddy-intl-connection.test.js new file mode 100644 index 00000000..ff6445ce --- /dev/null +++ b/tests/unit/codebuddy-intl-connection.test.js @@ -0,0 +1,120 @@ +// CodeBuddy Intl (.ai) OAuth connections: +// 1. The connection test must actually probe the token (was "Provider test not supported" +// because codebuddy-intl was missing from OAUTH_TEST_CONFIG). +// 2. mapTokens must surface email/displayName from the access token JWT so a fresh OAuth +// login is named by identity instead of falling back to "Account N". +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getProviderConnectionById: vi.fn(), + updateProviderConnection: vi.fn(), + resolveConnectionProxyConfig: vi.fn(), +})); + +vi.mock("@/lib/localDb", () => ({ + getProviderConnectionById: mocks.getProviderConnectionById, + updateProviderConnection: mocks.updateProviderConnection, +})); + +vi.mock("@/lib/network/connectionProxy", () => ({ + resolveConnectionProxyConfig: mocks.resolveConnectionProxyConfig, +})); + +import codebuddyIntl from "../../src/lib/oauth/providers/codebuddy-intl.js"; +import { testSingleConnection } from "../../src/app/api/providers/[id]/test/testUtils.js"; + +// Minimal unsigned JWT (header.payload.sig) — mapTokens only decodes the payload. +function makeJwt(payload) { + const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString("base64url"); + return `${b64({ alg: "RS256", typ: "JWT" })}.${b64(payload)}.sig`; +} + +const originalFetch = global.fetch; + +describe("codebuddy-intl mapTokens identity", () => { + it("extracts email and display name from the access token JWT", () => { + const token = makeJwt({ + iss: "https://www.codebuddy.ai/auth/realms/copilot", + email: "aghiyaramadh@gmail.com", + name: "aghiya ramadh", + preferred_username: "aghiyaramadh@gmail.com", + }); + + const out = codebuddyIntl.mapTokens({ + access_token: token, + refresh_token: "rt", + expires_in: 3600, + }); + + expect(out.accessToken).toBe(token); + expect(out.refreshToken).toBe("rt"); + expect(out.email).toBe("aghiyaramadh@gmail.com"); + expect(out.displayName).toBe("aghiya ramadh"); + }); + + it("falls back to given/family name when name is absent", () => { + const token = makeJwt({ email: "a@b.com", given_name: "Aghiya", family_name: "Ramadh" }); + const out = codebuddyIntl.mapTokens({ access_token: token, expires_in: 3600 }); + expect(out.email).toBe("a@b.com"); + expect(out.displayName).toBe("Aghiya Ramadh"); + }); + + it("does not throw and leaves identity null for an opaque (non-JWT) token", () => { + const out = codebuddyIntl.mapTokens({ access_token: "opaque-token", expires_in: 3600 }); + expect(out.accessToken).toBe("opaque-token"); + expect(out.email).toBeNull(); + expect(out.displayName).toBeNull(); + }); +}); + +describe("codebuddy-intl connection test", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.resolveConnectionProxyConfig.mockResolvedValue({}); + mocks.updateProviderConnection.mockResolvedValue({}); + mocks.getProviderConnectionById.mockResolvedValue({ + id: "conn-cb-intl", + provider: "codebuddy-intl", + authType: "oauth", + accessToken: makeJwt({ email: "aghiyaramadh@gmail.com", name: "aghiya ramadh" }), + refreshToken: "rt", + expiresAt: new Date(Date.now() + 3600_000).toISOString(), + providerSpecificData: {}, + }); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("probes the token instead of returning 'Provider test not supported'", async () => { + const calls = []; + global.fetch = vi.fn((url) => { + calls.push(String(url)); + return Promise.resolve( + new Response(JSON.stringify({ email: "aghiyaramadh@gmail.com" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + const result = await testSingleConnection("conn-cb-intl"); + + expect(result.error).not.toBe("Provider test not supported"); + expect(result.valid).toBe(true); + expect(calls.length).toBeGreaterThan(0); + // Must hit a real identity/usage endpoint on the codebuddy.ai domain. + expect(calls.some((u) => u.includes("codebuddy.ai"))).toBe(true); + }); + + it("marks the connection invalid on 401", async () => { + global.fetch = vi.fn(() => + Promise.resolve(new Response("unauthorized", { status: 401 })), + ); + + const result = await testSingleConnection("conn-cb-intl"); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/invalid|revoked/i); + }); +}); diff --git a/tests/unit/freebuff-model-assignment.test.js b/tests/unit/freebuff-model-assignment.test.js new file mode 100644 index 00000000..11a76eee --- /dev/null +++ b/tests/unit/freebuff-model-assignment.test.js @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { filterConnectionsForModel } from "../../src/sse/services/auth.js"; + +const connections = [ + { id: "flash-1", providerSpecificData: { freebuffModel: "deepseek/deepseek-v4-flash" } }, + { id: "mimo-1", providerSpecificData: { freebuffModel: "mimo/mimo-v2.5" } }, + { id: "unassigned", providerSpecificData: {} }, +]; + +describe("Freebuff strict model assignment", () => { + it("keeps only accounts assigned to the requested model", () => { + const result = filterConnectionsForModel("freebuff", connections, "mimo/mimo-v2.5", { + providerStrategies: { freebuff: { strictModelAssignment: true } }, + }); + + expect(result.map((connection) => connection.id)).toEqual(["mimo-1"]); + }); + + it("excludes unassigned accounts when strict mode is enabled", () => { + const result = filterConnectionsForModel("freebuff", connections, "deepseek/deepseek-v4-flash", { + providerStrategies: { freebuff: { strictModelAssignment: true } }, + }); + + expect(result.map((connection) => connection.id)).toEqual(["flash-1"]); + }); + + it("preserves the existing pool when strict mode is disabled", () => { + expect(filterConnectionsForModel("freebuff", connections, "mimo/mimo-v2.5", {})).toBe(connections); + }); + + it("does not affect other providers when their toggle is off", () => { + expect(filterConnectionsForModel("codex", connections, "mimo/mimo-v2.5", { + providerStrategies: {}, + })).toBe(connections); + }); +}); diff --git a/tests/unit/freebuff-provider.test.js b/tests/unit/freebuff-provider.test.js new file mode 100644 index 00000000..e937cd3e --- /dev/null +++ b/tests/unit/freebuff-provider.test.js @@ -0,0 +1,834 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import freebuff from "../../src/lib/oauth/providers/freebuff.js"; + +// Mock proxyAwareFetch so session/run/chat flows never hit the network. +const fetchMock = vi.fn(); +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: (...args) => fetchMock(...args), +})); + +import { FreebuffExecutor, __test__ } from "../../open-sse/executors/freebuff.js"; + +const { + ensureSession, + requestSession, + startRun, + resetSessionCache, + rootAgentIdForModel, + injectFreebuffMarker, + fetchSessionOffers, + guardOfferClaim, + FREEBUFF_SYSTEM_MARKER, +} = __test__; + +const CONFIG = { + baseUrl: "https://freebuff.com", + loginCodePath: "/api/auth/cli/code", + loginStatusPath: "/api/auth/cli/status", +}; + +function jsonResponse(data, { ok = true, status = 200 } = {}) { + return { + ok, + status, + json: async () => data, + text: async () => JSON.stringify(data), + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +beforeEach(() => { + fetchMock.mockReset(); + resetSessionCache(); +}); + +describe("freebuff oauth flow", () => { + it("requestDeviceCode posts a fingerprint to the freebuff.com login host and surfaces the login URL", async () => { + const loginUrl = "https://freebuff.com/login?auth_code=AbCd-123"; + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + fingerprintId: "fp-1", + fingerprintHash: "hash-1", + loginUrl, + expiresAt: Date.now() + 60000, + }), + }), + ); + const out = await freebuff.requestDeviceCode(CONFIG); + expect(out.verification_uri_complete).toBe(loginUrl); + expect(out.user_code).toBe("AbCd-123"); + expect(out.interval).toBe(5); + expect(out.expires_in).toBe(60); + // The server echoes the request host into loginUrl — it must be freebuff.com, + // not www.codebuff.com, to match the official CLI's login link. + const [url] = global.fetch.mock.calls[0]; + expect(url.startsWith("https://freebuff.com/api/auth/cli/code")).toBe(true); + const payload = JSON.parse(out.device_code); + expect(payload.fingerprintId).toBe("fp-1"); + expect(payload.fingerprintHash).toBe("hash-1"); + }); + + it("requestDeviceCode falls back to oauthTimeoutMs when the server omits expiresAt", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + fingerprintId: "fp-1", + fingerprintHash: "hash-1", + loginUrl: "https://freebuff.com/login?auth_code=Ab", + // no expiresAt — must NOT collapse to the 60s floor + }), + }), + ); + const out = await freebuff.requestDeviceCode(CONFIG); + expect(out.expires_in).toBe(300); + }); + + it("requestDeviceCode leaves user_code empty when loginUrl has no auth_code", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + fingerprintId: "fp-1", + fingerprintHash: "hash-1", + loginUrl: "https://freebuff.com/login", + expiresAt: Date.now() + 60000, + }), + }), + ); + const out = await freebuff.requestDeviceCode(CONFIG); + expect(out.user_code).toBe(""); + }); + + it("requestDeviceCode clamps expires_in to oauthTimeoutMs", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + fingerprintId: "fp-1", + fingerprintHash: "hash-1", + loginUrl: "https://freebuff.com/login?auth_code=Ab", + // server-side codes live ~1h; the modal deadline must stay at 5 min + expiresAt: Date.now() + 3600000, + }), + }), + ); + const out = await freebuff.requestDeviceCode(CONFIG); + expect(out.expires_in).toBe(300); + }); + + it("pollToken keeps polling on 401 pending (GET with query params)", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({ error: "Authentication failed" }), + }), + ); + const res = await freebuff.pollToken( + CONFIG, + JSON.stringify({ fingerprintId: "fp-1", fingerprintHash: "h", expiresAt: 123 }), + ); + expect(res.ok).toBe(true); + expect(res.data.error).toBe("authorization_pending"); + const [url, opts] = global.fetch.mock.calls[0]; + expect(url).toContain("https://freebuff.com/api/auth/cli/status?"); + expect(url).toContain("fingerprintId=fp-1"); + expect(opts.method).toBe("GET"); + }); + + it("pollToken returns the authToken on success", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + user: { id: "u1", email: "a@b.c", name: "A", authToken: "tok-123", fingerprintId: "fp-1" }, + }), + }), + ); + const res = await freebuff.pollToken( + CONFIG, + JSON.stringify({ fingerprintId: "fp-1", fingerprintHash: "h", expiresAt: 123 }), + ); + expect(res.data.access_token).toBe("tok-123"); + }); + + it("mapTokens stores accessToken + identity", () => { + const t = freebuff.mapTokens({ + access_token: "tok", + email: "a@b.c", + name: "A", + id: "u1", + fingerprintId: "fp", + }); + expect(t.accessToken).toBe("tok"); + expect(t.email).toBe("a@b.c"); + expect(t.displayName).toBe("A"); + expect(t.refreshToken).toBeNull(); + expect(t.providerSpecificData.fingerprintId).toBe("fp"); + expect(t.providerSpecificData.authMethod).toBe("device_code"); + }); +}); + +describe("freebuff executor wire shape", () => { + it("injects codebuff_metadata at TOP LEVEL (mirrors the CLI, not nested under codebuff)", () => { + const ex = new FreebuffExecutor(); + const body = { model: "deepseek/deepseek-v4-flash", messages: [{ role: "user", content: "hi" }] }; + const out = ex.transformRequest(body.model, body, true, { + providerSpecificData: { fingerprintId: "fp-1" }, + }); + // Top-level keys — the backend rejects the nested shape with + // "No runId found in request body". + expect(out.codebuff_metadata.cost_mode).toBe("free"); + expect(out.codebuff_metadata.client_id).toBe("fp-1"); + // run_id is the registered runId and is attached by execute(), not here. + expect(out.codebuff_metadata.run_id).toBeUndefined(); + expect(out.codebuff).toBeUndefined(); + expect(out.provider.allow_fallbacks).toBe(false); + // Free-tier marker is prepended so the first message opens with the CLI root prompt. + expect(out.messages[0].content).toBe(FREEBUFF_SYSTEM_MARKER); + }); + + it("buildUrl targets the Codebuff chat completions endpoint (www.codebuff.com)", () => { + const ex = new FreebuffExecutor(); + expect(ex.buildUrl()).toBe("https://www.codebuff.com/api/v1/chat/completions"); + }); + + it("injects the end_turn tool into any tool-calling request (backend foreign_toolset gate)", () => { + const ex = new FreebuffExecutor(); + const body = { + model: "deepseek/deepseek-v4-flash", + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "read_file", description: "read" } }], + }; + const out = ex.transformRequest(body.model, body, true, { providerSpecificData: { fingerprintId: "fp-1" } }); + const names = out.tools.map((t) => t.function.name); + expect(names).toContain("read_file"); + expect(names).toContain("end_turn"); + expect(out.tools[out.tools.length - 1].function).toMatchObject({ + name: "end_turn", + description: "Signal the end of the current task.", + }); + }); + + it("does not inject end_turn when the request has no tools", () => { + const ex = new FreebuffExecutor(); + const body = { model: "deepseek/deepseek-v4-flash", messages: [{ role: "user", content: "hi" }] }; + const out = ex.transformRequest(body.model, body, true, { providerSpecificData: { fingerprintId: "fp-1" } }); + expect(out.tools).toBeUndefined(); + }); + + it("does not duplicate end_turn when the caller already declared it", () => { + const ex = new FreebuffExecutor(); + const endTurn = { type: "function", function: { name: "end_turn", description: "Signal the end of the current task.", parameters: { type: "object", properties: {} } } }; + const body = { + model: "deepseek/deepseek-v4-flash", + messages: [{ role: "user", content: "hi" }], + tools: [endTurn], + }; + const out = ex.transformRequest(body.model, body, true, { providerSpecificData: { fingerprintId: "fp-1" } }); + expect(out.tools).toHaveLength(1); + expect(out.tools[0].function.name).toBe("end_turn"); + }); +}); + +describe("freebuff session pre-flight", () => { + it("claims a session via POST /session with x-freebuff-model and caches it per token+model", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + status: "active", + instanceId: "inst-1", + model: "deepseek/deepseek-v4-flash", + expiresAt: new Date(Date.now() + 3600000).toISOString(), + }), + ); + const first = await ensureSession("tok-1", "deepseek/deepseek-v4-flash", null); + expect(first).toEqual({ instanceId: "inst-1", status: "active" }); + + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("https://www.codebuff.com/api/v1/freebuff/session"); + expect(opts.method).toBe("POST"); + expect(opts.headers["x-freebuff-model"]).toBe("deepseek/deepseek-v4-flash"); + expect(opts.headers.Authorization).toBe("Bearer tok-1"); + + // Second call for the same token+model hits the cache — no new claim. + await ensureSession("tok-1", "deepseek/deepseek-v4-flash", null); + expect(fetchMock.mock.calls.length).toBe(1); + + // Different model → separate claim. + fetchMock.mockResolvedValue( + jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }), + ); + await ensureSession("tok-1", "z-ai/glm-5.3-flash", null); + expect(fetchMock.mock.calls.length).toBe(2); + }); + + it("treats status none as no-session-needed (instanceId null)", async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: "none", accessTier: "full" })); + const res = await ensureSession("tok-1", "deepseek/deepseek-v4-flash", null); + expect(res).toEqual({ instanceId: null, status: "none" }); + }); + + it("throws a friendly error on rate_limited", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ status: "rate_limited", message: "4 of 6 sessions used today" }), + ); + await expect(ensureSession("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toThrow( + /session limit reached/i, + ); + }); + + it("rate_limited with a resetAt locks the account until the daily Pacific reset (skip the day, not retry loops)", async () => { + const resetAt = "2099-01-01T00:00:00.000Z"; + fetchMock.mockResolvedValue( + jsonResponse({ + status: "rate_limited", + resetAt, + retryAfterMs: 1234, + freebucksShortfall: { price: 15, balance: 0 }, + message: "Freebucks exhausted", + }), + ); + await expect(requestSession("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toMatchObject({ + status: 429, + resetsAtMs: Date.parse(resetAt), + }); + }); + + it("spend_limited falls back to retryAfterMs when resetAt is absent", async () => { + const retryAfterMs = 90 * 60 * 1000; + fetchMock.mockResolvedValue( + jsonResponse({ status: "spend_limited", retryAfterMs, message: "daily spend cap" }), + ); + const before = Date.now(); + try { + await requestSession("tok-1", "deepseek/deepseek-v4-flash", null); + throw new Error("should have rejected"); + } catch (error) { + expect(error.status).toBe(429); + expect(error.resetsAtMs).toBeGreaterThanOrEqual(before + retryAfterMs - 1000); + expect(error.resetsAtMs).toBeLessThanOrEqual(before + retryAfterMs + 1000); + } + }); + + it("handles spend_limited arriving as HTTP 429 (the actual wire shape) — still skips until reset", async () => { + const resetAt = "2099-01-01T00:00:00.000Z"; + fetchMock.mockResolvedValue( + jsonResponse( + { + status: "spend_limited", + accessTier: "full", + upgrade: { url: "https://freebuff.com/plans", message: "Get 150 Freebucks a day from $8/mo." }, + message: "This account hit today's hard usage cap.", + resetAt, + }, + { status: 429, ok: false }, + ), + ); + await expect(requestSession("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toMatchObject({ + status: 429, + resetsAtMs: Date.parse(resetAt), + }); + }); + + it("handles rate_limited arriving as HTTP 429 with only retryAfterMs", async () => { + const retryAfterMs = 15 * 60 * 1000; + fetchMock.mockResolvedValue( + jsonResponse({ status: "rate_limited", retryAfterMs, message: "limit" }, { status: 429, ok: false }), + ); + const before = Date.now(); + try { + await requestSession("tok-1", "deepseek/deepseek-v4-flash", null); + throw new Error("should have rejected"); + } catch (error) { + expect(error.status).toBe(429); + expect(error.resetsAtMs).toBeGreaterThanOrEqual(before + retryAfterMs - 1000); + expect(error.resetsAtMs).toBeLessThanOrEqual(before + retryAfterMs + 1000); + } + }); + + it("keeps an unknown HTTP 429 as a generic failure (no gate status → no resetsAtMs)", async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: "nope" }, { status: 429, ok: false })); + try { + await requestSession("tok-1", "deepseek/deepseek-v4-flash", null); + throw new Error("should have rejected"); + } catch (error) { + expect(error.status).toBe(429); + expect(error.resetsAtMs).toBeUndefined(); + } + }); + + it("rate_limited without any reset hint stays a plain error (transient cooldown path)", async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: "rate_limited", message: "busy" })); + try { + await requestSession("tok-1", "deepseek/deepseek-v4-flash", null); + throw new Error("should have rejected"); + } catch (error) { + expect(error.status).toBeUndefined(); + expect(error.resetsAtMs).toBeUndefined(); + } + }); + + it("throws a friendly error on country_blocked", async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: "country_blocked" })); + await expect(ensureSession("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toThrow( + /not available in your region/i, + ); + }); + + it("throws a 401 re-login error when the session endpoint rejects the token", async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: "unauthorized" }, { status: 401, ok: false })); + await expect(requestSession("tok-expired", "deepseek/deepseek-v4-flash", null)).rejects.toThrow(/re-login/i); + }); +}); + +describe("freebuff limited-offer (Claude Fable 5) claims", () => { + const FABLE = "anthropic/claude-fable-5"; + const offerRow = (over = {}) => ({ + model: FABLE, + remaining: 3, + total: 10, + userRemaining: 1, + userResetAt: new Date(Date.now() + 3600000).toISOString(), + ...over, + }); + + it("GETs limitedModelOffers (never claims) and caches them per token", async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: "none", limitedModelOffers: [offerRow()] })); + const offers = await fetchSessionOffers("tok-1", null); + expect(offers.map((o) => o.model)).toEqual([FABLE]); + + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("https://www.codebuff.com/api/v1/freebuff/session"); + expect(opts.method).toBe("GET"); + expect(opts.headers.Authorization).toBe("Bearer tok-1"); + expect(opts.headers.Accept).toBe("application/json"); + + // Second read within the cache TTL does not refetch. + await fetchSessionOffers("tok-1", null); + expect(fetchMock.mock.calls.length).toBe(1); + }); + + it("allows the claim while the offer is open", async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: "none", limitedModelOffers: [offerRow()] })); + expect(await guardOfferClaim("tok-1", FABLE, null)).toMatchObject({ model: FABLE, remaining: 3 }); + expect(fetchMock.mock.calls.length).toBe(1); + expect(fetchMock.mock.calls[0][1].method).toBe("GET"); + }); + + it("lets non-offer models claim without any offer GET", async () => { + expect(await guardOfferClaim("tok-1", "deepseek/deepseek-v4-flash", null)).toBeNull(); + expect(fetchMock.mock.calls.length).toBe(0); + }); + + it("refuses the claim when the wave pool is closed (no offer row)", async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: "none", limitedModelOffers: [] })); + await expect(guardOfferClaim("tok-1", FABLE, null)).rejects.toThrow(/not being offered right now/i); + }); + + it("refuses the claim when the account's daily Fable sessions are used up", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ status: "none", limitedModelOffers: [offerRow({ userRemaining: 0 })] }), + ); + await expect(guardOfferClaim("tok-1", FABLE, null)).rejects.toThrow(/has used its Claude Fable 5 sessions/i); + }); + + it("claims a Fable session only after the offer passes: GET offers, then POST claim", async () => { + fetchMock.mockImplementation(async (url, opts = {}) => { + if (url.includes("/freebuff/session") && opts.method === "GET") { + return jsonResponse({ status: "none", limitedModelOffers: [offerRow()] }); + } + if (url.includes("/freebuff/session")) { + return jsonResponse({ status: "active", instanceId: "inst-fable", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + } + return jsonResponse({ ok: false }, { status: 500, ok: false }); + }); + + const res = await ensureSession("tok-1", FABLE, null); + expect(res).toEqual({ instanceId: "inst-fable", status: "active" }); + + const methods = fetchMock.mock.calls.map(([, o]) => o.method); + expect(methods).toEqual(["GET", "POST"]); + const [, postOpts] = fetchMock.mock.calls[1]; + expect(postOpts.headers["x-freebuff-model"]).toBe(FABLE); + + // Cached claim → no further requests. + await ensureSession("tok-1", FABLE, null); + expect(fetchMock.mock.calls.length).toBe(2); + }); + + it("never POSTs a claim when the Fable wave is closed", async () => { + fetchMock.mockResolvedValue(jsonResponse({ status: "none", limitedModelOffers: [] })); + await expect(ensureSession("tok-1", FABLE, null)).rejects.toThrow(/not being offered right now/i); + const methods = fetchMock.mock.calls.map(([, o]) => o.method); + expect(methods).toEqual(["GET"]); + }); +}); + +describe("freebuff free-tier system marker", () => { + it("prepends the canonical marker when the first message is a system prompt", () => { + const out = injectFreebuffMarker({ + messages: [{ role: "system", content: "You are a helpful assistant." }, { role: "user", content: "hi" }], + }); + expect(out.messages[0].content).toBe(`${FREEBUFF_SYSTEM_MARKER}\n\nYou are a helpful assistant.`); + expect(out.messages[1].role).toBe("user"); + }); + + it("inserts a marker system message when the first message is not a system prompt", () => { + const out = injectFreebuffMarker({ messages: [{ role: "user", content: "hi" }] }); + expect(out.messages[0]).toEqual({ role: "system", content: FREEBUFF_SYSTEM_MARKER }); + expect(out.messages[1]).toEqual({ role: "user", content: "hi" }); + }); + + it("is idempotent when the first system message already opens with the marker", () => { + const messages = [{ role: "system", content: FREEBUFF_SYSTEM_MARKER }]; + const out = injectFreebuffMarker({ messages }); + expect(out.messages).toBe(messages); + }); + + it("inserts a marker system message when the first system content is a block array", () => { + const out = injectFreebuffMarker({ + messages: [{ role: "system", content: [{ type: "text", text: "hi" }] }, { role: "user", content: "x" }], + }); + expect(out.messages[0]).toEqual({ role: "system", content: FREEBUFF_SYSTEM_MARKER }); + expect(out.messages[1].content).toEqual([{ type: "text", text: "hi" }]); + expect(out.messages[2].content).toBe("x"); + }); +}); + +describe("freebuff run registration", () => { + it("maps freebuff models to their root free agent ids", () => { + expect(rootAgentIdForModel("deepseek/deepseek-v4-flash")).toBe("base3-free-deepseek-flash"); + expect(rootAgentIdForModel("z-ai/glm-5.3-flash")).toBe("base3-free-glm-5-3-flash"); + expect(rootAgentIdForModel("z-ai/glm-5.2")).toBe("base3-free-glm"); + expect(rootAgentIdForModel("mimo/mimo-v2.5")).toBe("base3-free-mimo"); + expect(rootAgentIdForModel("openai/gpt-5.6-luna")).toBe("base3-free-luna"); + expect(rootAgentIdForModel("upstage/solar-pro4")).toBe("base3-free-solar-pro4"); + expect(rootAgentIdForModel("meta/muse-spark-1.2-contributor")).toBe("base3-free-muse-spark"); + expect(rootAgentIdForModel("anthropic/claude-fable-5")).toBe("base3-free-fable"); + // Withdrawn upstream models are unmapped — they fall back, and the backend + // refuses their sessions anyway. + expect(rootAgentIdForModel("meta/muse-spark-1.3-contributor")).toBe("base2-free"); + expect(rootAgentIdForModel("deepseek/deepseek-v4-pro")).toBe("base2-free"); + expect(rootAgentIdForModel("minimax/minimax-m3")).toBe("base2-free"); + expect(rootAgentIdForModel("some/unknown-model")).toBe("base2-free"); + }); + + it("registers a run via POST /agent-runs and returns the runId", async () => { + fetchMock.mockResolvedValue(jsonResponse({ runId: "run-abc" })); + const runId = await startRun("tok-1", "deepseek/deepseek-v4-flash", null); + expect(runId).toBe("run-abc"); + + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("https://www.codebuff.com/api/v1/agent-runs"); + expect(opts.method).toBe("POST"); + expect(opts.headers.Authorization).toBe("Bearer tok-1"); + const payload = JSON.parse(opts.body); + expect(payload.action).toBe("START"); + expect(payload.agentId).toBe("base3-free-deepseek-flash"); + expect(payload.ancestorRunIds).toEqual([]); + }); + + it("throws when the run start fails", async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: "bad" }, { status: 500, ok: false })); + await expect(startRun("tok-1", "deepseek/deepseek-v4-flash", null)).rejects.toThrow(/run start failed/i); + }); + + it("retries transient network errors on run registration", async () => { + fetchMock.mockImplementation(async (url) => { + if (url.includes("/agent-runs")) { + const calls = fetchMock.mock.calls.filter(([u]) => u.includes("/agent-runs")).length; + if (calls === 1) throw new Error("fetch failed (cause: ECONNRESET)"); + return jsonResponse({ runId: "run-retried" }); + } + throw new Error("unexpected url"); + }); + const runId = await startRun("tok-1", "deepseek/deepseek-v4-flash", null); + expect(runId).toBe("run-retried"); + }); +}); + +describe("freebuff executor execute", () => { + const CHAT_URL = "https://www.codebuff.com/api/v1/chat/completions"; + const SESSION_URL = "https://www.codebuff.com/api/v1/freebuff/session"; + const RUN_URL = "https://www.codebuff.com/api/v1/agent-runs"; + const MODEL = "deepseek/deepseek-v4-flash"; + const credentials = { accessToken: "tok-1", providerSpecificData: { fingerprintId: "fp-1" } }; + + // Default happy-path backend: session active, run registered, chat 200. + const happyPath = () => { + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) { + return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + } + if (url === RUN_URL) { + return jsonResponse({ runId: "run-1" }); + } + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + }; + + it("sends the registered runId + session instance id on the chat request", async () => { + happyPath(); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + + expect(response.status).toBe(200); + const chatCall = fetchMock.mock.calls.find(([u]) => u === CHAT_URL); + expect(chatCall).toBeTruthy(); + const sent = JSON.parse(chatCall[1].body); + expect(sent.codebuff_metadata.run_id).toBe("run-1"); + expect(sent.codebuff_metadata.freebuff_instance_id).toBe("inst-1"); + expect(sent.codebuff_metadata.trace_session_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(sent.codebuff_metadata.cost_mode).toBe("free"); + expect(sent.codebuff_metadata.client_id).toBe("fp-1"); + expect(sent.codebuff).toBeUndefined(); + // Free-tier marker present at position 0 of the request body. + expect(sent.messages[0].content.startsWith("You are Buffy,")).toBe(true); + }); + + it("retries exactly once on 428 with a fresh session AND a fresh run", async () => { + let chatHits = 0; + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) { + return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + } + if (url === RUN_URL) { + return jsonResponse({ runId: "run-2" }); + } + chatHits += 1; + if (chatHits === 1) return jsonResponse({ error: "waiting_room_required" }, { status: 428, ok: false }); + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + + expect(response.status).toBe(200); + expect(chatHits).toBe(2); + // Session was claimed twice (initial + forced re-claim). + expect(fetchMock.mock.calls.filter(([u]) => u === SESSION_URL).length).toBe(2); + // Runs: START #1, FINISH(cancelled) #1 (abandoned on 428), START #2, + // FINISH(completed) #2. + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + expect(runCalls.length).toBe(4); + const runActions = runCalls.map((c) => JSON.parse(c[1].body).action); + expect(runActions.filter((a) => a === "START").length).toBe(2); + expect(runActions.filter((a) => a === "FINISH").length).toBe(2); + const finishPayload = JSON.parse(runCalls[runCalls.length - 1][1].body); + expect(finishPayload.status).toBe("completed"); + // The retried chat request carried the re-claimed session + fresh run. + const lastChat = fetchMock.mock.calls.filter(([u]) => u === CHAT_URL).pop(); + const sent = JSON.parse(lastChat[1].body); + expect(sent.codebuff_metadata.run_id).toBe("run-2"); + expect(sent.codebuff_metadata.freebuff_instance_id).toBe("inst-2"); + }); + + it("re-claims the session on 409 session_superseded and retries once", async () => { + let chatHits = 0; + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-2" }); + chatHits += 1; + if (chatHits === 1) { + return jsonResponse({ error: "session_superseded", message: "Another instance took over" }, { status: 409, ok: false }); + } + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + + expect(response.status).toBe(200); + expect(chatHits).toBe(2); + // Session re-claimed (initial + forced) and runs restarted. + expect(fetchMock.mock.calls.filter(([u]) => u === SESSION_URL).length).toBe(2); + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + expect(runCalls.filter((c) => JSON.parse(c[1].body).action === "START").length).toBe(2); + }); + + it("re-claims the session on 410 session_expired and retries once", async () => { + let chatHits = 0; + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-2" }); + chatHits += 1; + if (chatHits === 1) return jsonResponse({ error: "session_expired" }, { status: 410, ok: false }); + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + expect(response.status).toBe(200); + expect(chatHits).toBe(2); + }); + + it("throws a 401 re-login error when the chat endpoint rejects the token", async () => { + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-1" }); + return jsonResponse({ error: "unauthorized" }, { status: 401, ok: false }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + await expect( + ex.execute({ model: MODEL, body, stream: false, credentials, log: null }), + ).rejects.toThrow(/re-login/i); + }); + + it("throws when no access token is present", async () => { + const ex = new FreebuffExecutor(); + await expect( + ex.execute({ model: MODEL, body: { messages: [] }, stream: false, credentials: {}, log: null }), + ).rejects.toThrow(/no access token/i); + }); + + it("finishes the run as failed when the chat upstream errors", async () => { + // 400 (not in the 429/502/503 retry set) so the test stays fast and mirrors + // the real upstream rejection. + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-1" }); + return jsonResponse({ error: "upstream boom" }, { status: 400, ok: false }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + + expect(response.status).toBe(400); + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + expect(runCalls.length).toBe(2); // START + FINISH + const finishPayload = JSON.parse(runCalls[1][1].body); + expect(finishPayload.action).toBe("FINISH"); + expect(finishPayload.status).toBe("failed"); + }); + + it("finishes the run as failed when execute throws mid-flight", async () => { + // AbortError (caller/stream abort) is never retried, keeping this test fast. + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-1" }); + throw Object.assign(new Error("aborted"), { name: "AbortError" }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + await expect( + ex.execute({ model: MODEL, body, stream: false, credentials, log: null }), + ).rejects.toThrow(/aborted/); + + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + expect(runCalls.length).toBe(2); // START + FINISH(failed) from the finally block + const finishPayload = JSON.parse(runCalls[1][1].body); + expect(finishPayload.action).toBe("FINISH"); + expect(finishPayload.status).toBe("failed"); + }); + + it("retries the chat POST on a transient fetch-level network error", async () => { + let chatHits = 0; + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-1", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) return jsonResponse({ runId: "run-1" }); + chatHits += 1; + if (chatHits === 1) throw new Error("fetch failed (cause: ECONNRESET)"); + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + const { response } = await ex.execute({ model: MODEL, body, stream: false, credentials, log: null }); + + expect(response.status).toBe(200); + expect(chatHits).toBe(2); + // Run FINISHed exactly once (completed) — no double-FINISH from the retry. + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + expect(runCalls.length).toBe(2); // START + FINISH(completed) + expect(JSON.parse(runCalls[1][1].body).status).toBe("completed"); + }); + + it("does not double-FINISH the abandoned run when the re-claim fails", async () => { + let chatHits = 0; + let runStartCount = 0; + fetchMock.mockImplementation(async (url) => { + if (url === SESSION_URL) return jsonResponse({ status: "active", instanceId: "inst-2", expiresAt: new Date(Date.now() + 3600000).toISOString() }); + if (url === RUN_URL) { + // First START succeeds (run-1). Every later call fails with the + // transient ECONNRESET: the fire-and-forget FINISH swallows it, and + // the re-claim START propagates after its 3 network-retry attempts. + if (runStartCount === 0) { + runStartCount += 1; + return jsonResponse({ runId: "run-1" }); + } + throw new Error("fetch failed (cause: ECONNRESET)"); + } + chatHits += 1; + if (chatHits === 1) return jsonResponse({ error: "session_superseded" }, { status: 409, ok: false }); + return jsonResponse({ choices: [{ message: { content: "hi" } }] }); + }); + + const ex = new FreebuffExecutor(); + const body = { model: MODEL, messages: [{ role: "user", content: "hi" }] }; + // The re-claim failure rethrows the raw upstream error (the log line above + // it carries the "session re-claim failed" context). + await expect( + ex.execute({ model: MODEL, body, stream: false, credentials, log: null }), + ).rejects.toThrow(/fetch failed \(cause: ECONNRESET\)/); + + // run-1 was FINISH'd exactly once, as "cancelled" — the failed re-claim + // must NOT trigger a second (rejected by the server) FINISH. + const runCalls = fetchMock.mock.calls.filter(([u]) => u === RUN_URL); + const finishes = runCalls.filter(([, o]) => JSON.parse(o.body).action === "FINISH"); + expect(finishes.length).toBe(1); + expect(JSON.parse(finishes[0][1].body).status).toBe("cancelled"); + }); +}); + +describe("freebuff executor parseError", () => { + it("explains a 404 'No endpoints found' as the toolset gate, not a credential problem", async () => { + const ex = new FreebuffExecutor(); + const res = jsonResponse( + { error: { message: "No endpoints found for deepseek/deepseek-v4-flash.", code: 404, type: null, param: null } }, + { status: 404, ok: false }, + ); + const parsed = await ex.parseError(res, JSON.stringify({ error: { message: "No endpoints found for deepseek/deepseek-v4-flash.", code: 404 } })); + + expect(parsed.status).toBe(404); + expect(parsed.message).toMatch(/end_turn/i); + expect(parsed.message).not.toMatch(/credential/i); + expect(parsed.resetsAtMs).toBeGreaterThan(Date.now()); + }); + + it("passes other statuses through untouched", async () => { + const ex = new FreebuffExecutor(); + const res = jsonResponse({ error: "bad" }, { status: 500, ok: false }); + const parsed = await ex.parseError(res, JSON.stringify({ error: "bad" })); + expect(parsed.status).toBe(500); + expect(parsed.message).toContain("bad"); + expect(parsed.resetsAtMs).toBeUndefined(); + }); +}); diff --git a/tests/unit/freebuff-usage.test.js b/tests/unit/freebuff-usage.test.js new file mode 100644 index 00000000..efa02aed --- /dev/null +++ b/tests/unit/freebuff-usage.test.js @@ -0,0 +1,313 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(), +})); + +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; +import { getUsageForProvider } from "../../open-sse/services/usage.js"; +import { PROVIDERS } from "../../open-sse/providers/index.js"; +import { USAGE_SUPPORTED_PROVIDERS } from "../../src/shared/constants/providers.js"; +import { parseQuotaData } from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js"; + +const SESSION_URL = "https://www.codebuff.com/api/v1/freebuff/session"; + +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +const PRE_JOIN = { + status: "none", + accessTier: "limited", + rateLimitsByModel: { + "deepseek/deepseek-v4-flash": { + limit: 6, + recentCount: 4.1, + period: "pacific_day", + resetTimeZone: "America/Los_Angeles", + resetAt: "2026-08-06T07:00:00.000Z", + entitlementBreakdown: { base: 6, referral: 0, streak: 0 }, + }, + "openai/gpt-5.6-luna": { + limit: 6, + recentCount: 1, + period: "pacific_day", + resetTimeZone: "America/Los_Angeles", + resetAt: "2026-08-06T07:00:00.000Z", + }, + }, +}; + +describe("freebuff registry usage flag", () => { + it("exposes the session endpoint as transport.usage url", () => { + const cfg = PROVIDERS["freebuff"]; + expect(cfg.usage?.url).toBe(SESSION_URL); + }); + + it("is listed in USAGE_SUPPORTED_PROVIDERS (features.usage)", () => { + expect(USAGE_SUPPORTED_PROVIDERS).toContain("freebuff"); + }); +}); + +describe("getUsageForProvider(freebuff)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("GETs the session endpoint and normalizes per-model session quotas", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(PRE_JOIN)); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.plan).toBe("Freebuff (Limited)"); + expect(usage.quotas["deepseek/deepseek-v4-flash"]).toMatchObject({ + used: 4.1, + total: 6, + resetAt: "2026-08-06T07:00:00.000Z", + recurring: true, + unlimited: false, + displayName: "DeepSeek V4.1 Flash", + }); + expect(usage.quotas["openai/gpt-5.6-luna"]).toMatchObject({ + used: 1, + total: 6, + displayName: "GPT-5.6 Luna", + }); + + // Quota reads MUST be GET (a POST would claim a session and burn quota). + const [url, opts] = proxyAwareFetch.mock.calls[0]; + expect(url).toBe(SESSION_URL); + expect(opts.method).toBe("GET"); + expect(opts.headers.Authorization).toBe("Bearer tok-1"); + }); + + it("folds the active session's own rateLimit into the shared map", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + status: "active", + accessTier: "full", + instanceId: "inst-1", + model: "meta/muse-spark-1.2-contributor", + expiresAt: new Date(Date.now() + 3600000).toISOString(), + rateLimit: { + limit: 6, + recentCount: 2.4, + period: "pacific_day", + resetAt: "2026-08-06T07:00:00.000Z", + }, + rateLimitsByModel: {}, + }), + ); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.plan).toBe("Freebuff"); + expect(usage.quotas["meta/muse-spark-1.2-contributor"]).toMatchObject({ + used: 2.4, + total: 6, + displayName: "Muse Spark 1.2", + }); + }); + + it("reports the Freebucks daily pool under each priced model for metered accounts (no rateLimitsByModel)", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + status: "none", + accessTier: "limited", + rateLimitsByModel: {}, + freebucks: { + balance: 10, + daily: { limit: 25, spent: 15, remaining: 10, resetAt: "2026-09-08T07:00:00.000Z" }, + wallet: { balance: 0, monthlyBonus: 0 }, + spend: { limitUsd: 4, resetAt: "2026-09-08T07:00:00.000Z" }, + monthly: { limitUsd: 10, spentUsd: 3.5, remainingUsd: 6.5, resetAt: "2026-10-01T07:00:00.000Z" }, + planId: null, + prices: { "deepseek/deepseek-v4-flash": 15, "z-ai/glm-5.3-flash": 5 }, + }, + }), + ); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.message).toBeUndefined(); + expect(usage.quotas["deepseek/deepseek-v4-flash"]).toMatchObject({ + used: 15, + total: 25, + resetAt: "2026-09-08T07:00:00.000Z", + recurring: true, + unlimited: false, + price: 15, + displayName: "DeepSeek V4.1 Flash", + }); + expect(usage.quotas["z-ai/glm-5.3-flash"]).toMatchObject({ + used: 15, + total: 25, + price: 5, + displayName: "GLM 5.3 Flash", + }); + // No session pools → only the freebucks-derived rows exist. + expect(Object.keys(usage.quotas)).toEqual([ + "deepseek/deepseek-v4-flash", + "z-ai/glm-5.3-flash", + ]); + // Account summary rides the response for the card header (server data, + // nothing hardcoded client-side). + expect(usage.freebucks).toEqual({ + balance: 10, + daily: { + limit: 25, + spent: 15, + remaining: 10, + resetAt: "2026-09-08T07:00:00.000Z", + }, + wallet: { balance: 0 }, + monthly: { remainingUsd: 6.5, limitUsd: 10, resetAt: "2026-10-01T07:00:00.000Z" }, + }); + }); + + it("folds the server's announced priceChanges into the live price (promos expire without a client release)", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ + status: "none", + accessTier: "full", + rateLimitsByModel: {}, + freebucks: { + balance: 20, + daily: { limit: 25, spent: 5, remaining: 20, resetAt: "2026-09-08T07:00:00.000Z" }, + wallet: { balance: 0, monthlyBonus: 0 }, + planId: null, + prices: { "upstage/solar-pro4": 0 }, + priceNotices: { "upstage/solar-pro4": "0 Freebucks · Labor Day weekend (through Sep 7 PT)" }, + priceChanges: [ + { + at: "2026-01-01T00:00:00.000Z", // already due + modelId: "upstage/solar-pro4", + price: 5, + tagline: "Limited-time trial", + }, + ], + }, + }), + ); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + // The due change is folded in: price 5 + new tagline, schedule emptied. + expect(usage.quotas["upstage/solar-pro4"]).toMatchObject({ + price: 5, + priceNote: "Limited-time trial", + displayName: "Solar Pro 4", + }); + }); + + it("attaches no price to legacy session-quota rows", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse(PRE_JOIN)); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.quotas["deepseek/deepseek-v4-flash"].price).toBeUndefined(); + expect(usage.freebucks).toBeUndefined(); + }); + + it("surfaces a re-login message on 401", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ error: "unauthorized" }, 401)); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "expired", + }); + + expect(usage.message).toMatch(/expired|re-login/i); + expect(usage.quotas).toBeUndefined(); + }); + + it("surfaces a region message on 403 country_blocked (not a re-login hint)", async () => { + proxyAwareFetch.mockResolvedValueOnce( + jsonResponse({ status: "country_blocked", countryCode: "XX" }, 403), + ); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.message).toMatch(/not available in your region/i); + }); + + it("treats 404 (no session row) as pre-join with no quota", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({}, 404)); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.message).toMatch(/no session quota/i); + }); + + it("returns a message when the session response carries no quota map", async () => { + proxyAwareFetch.mockResolvedValueOnce(jsonResponse({ status: "none", accessTier: "full" })); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.plan).toBe("Freebuff"); + expect(usage.message).toMatch(/no session quota/i); + }); + + it("does not throw when the session fetch fails", async () => { + proxyAwareFetch.mockRejectedValueOnce(new Error("network down")); + + const usage = await getUsageForProvider({ + provider: "freebuff", + accessToken: "tok-1", + }); + + expect(usage.message).toMatch(/usage error/i); + }); +}); + +describe("parseQuotaData(freebuff)", () => { + it("uses displayName for the row label and keeps modelKey for ordering", () => { + const rows = parseQuotaData("freebuff", { + plan: "Freebuff (Limited)", + quotas: { + "deepseek/deepseek-v4-flash": { + used: 4.1, + total: 6, + resetAt: "2026-08-06T07:00:00.000Z", + displayName: "DeepSeek V4.1 Flash", + }, + }, + }); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + name: "DeepSeek V4.1 Flash", + modelKey: "deepseek/deepseek-v4-flash", + used: 4.1, + total: 6, + }); + }); +}); diff --git a/tests/unit/grok-cli-oauth-probe.test.js b/tests/unit/grok-cli-oauth-probe.test.js index 5cda4cdb..24eefa55 100644 --- a/tests/unit/grok-cli-oauth-probe.test.js +++ b/tests/unit/grok-cli-oauth-probe.test.js @@ -48,3 +48,31 @@ describe("classifyOAuthProbeResult (grok-cli)", () => { expect(r).toEqual({ valid: true, error: null, soft: false }); }); }); + +describe("classifyOAuthProbeResult (freebuff)", () => { + const FREEBUFF_PROBE = { + acceptStatuses: [403, 404], + softFailMessage: { 403: "gated" }, + }; + + it("treats 404 (no session row) as silent success", () => { + const r = classifyOAuthProbeResult({ ok: false, status: 404 }, FREEBUFF_PROBE, ""); + expect(r).toEqual({ valid: true, error: null, soft: false }); + }); + + it("treats 403 (region/account gate) as soft success", () => { + const r = classifyOAuthProbeResult( + { ok: false, status: 403 }, + FREEBUFF_PROBE, + JSON.stringify({ status: "country_blocked", countryCode: "XX" }), + ); + expect(r.valid).toBe(true); + expect(r.soft).toBe(true); + expect(r.error).toMatch(/gated/); + }); + + it("treats 401 as hard auth failure", () => { + const r = classifyOAuthProbeResult({ ok: false, status: 401 }, FREEBUFF_PROBE, "unauthorized"); + expect(r).toEqual({ valid: false, error: "Token invalid or revoked", soft: false }); + }); +}); diff --git a/tests/unit/pool-geo.test.js b/tests/unit/pool-geo.test.js new file mode 100644 index 00000000..71f4eef2 --- /dev/null +++ b/tests/unit/pool-geo.test.js @@ -0,0 +1,26 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { setPoolGeo, getPoolGeo, poolGeoSnapshot, pruneStaleGeo, resetPoolGeo } from "open-sse/services/poolGeo.js"; + +describe("pool egress geo cache", () => { + beforeEach(() => resetPoolGeo()); + + it("stores geo and classifies stability from egress changes", () => { + setPoolGeo("p1", { ip: "1.1.1.1", country: "US" }); + setPoolGeo("p1", { ip: "1.1.1.1", country: "US" }); // same IP twice + expect(getPoolGeo("p1").isUnstable).toBe(false); + expect(getPoolGeo("p1").ipCount).toBe(1); + + setPoolGeo("p1", { ip: "2.2.2.2", country: "US" }); // changed + expect(getPoolGeo("p1").isUnstable).toBe(true); + expect(getPoolGeo("p1").ipCount).toBe(2); + }); + + it("prunes TTL-stale entries (ipHistory rides along)", () => { + setPoolGeo("p1", { ip: "1.1.1.1", country: "US" }); + setPoolGeo("p1", { ip: "2.2.2.2", country: "US" }); + const cache = globalThis["__9routerPoolGeo__"]; + cache.get("p1").ts = Date.now() - 2 * 60 * 60 * 1000; + expect(pruneStaleGeo()).toBe(1); + expect(poolGeoSnapshot()).toEqual({}); + }); +}); \ No newline at end of file diff --git a/tests/unit/proxy-pool-fitness.test.js b/tests/unit/proxy-pool-fitness.test.js new file mode 100644 index 00000000..431194e1 --- /dev/null +++ b/tests/unit/proxy-pool-fitness.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + markPoolUnfit, + clearPoolUnfit, + clearAllPoolUnfit, + isPoolFit, + fitPoolIds, + poolFitnessSnapshot, + pruneExpired, + resetPoolFitness, +} from "open-sse/services/proxyPoolFitness.js"; +import { pickProxyPoolId } from "../../src/lib/network/connectionProxy.js"; + +describe("proxy pool fitness registry", () => { + beforeEach(() => resetPoolFitness()); + + it("marks a pool unfit for a scope and prunes on expiry", () => { + markPoolUnfit("p1", "freebuff::gpt-5.6-luna", Date.now() + 60_000, "limited_ip"); + expect(isPoolFit("p1", "freebuff::gpt-5.6-luna")).toBe(false); + expect(isPoolFit("p1", "freebuff::other-model")).toBe(true); + expect(isPoolFit("p2", "freebuff::gpt-5.6-luna")).toBe(true); + + markPoolUnfit("p1", "freebuff::gpt-5.6-luna", Date.now() - 1000); // expired + expect(isPoolFit("p1", "freebuff::gpt-5.6-luna")).toBe(true); // pruned on read + }); + + it("provider-wide mark (provider::*) covers any model lookup", () => { + markPoolUnfit("p1", "opencode::*", Date.now() + 60_000, "manual"); + expect(isPoolFit("p1", "opencode::sonnet-4.6")).toBe(false); + expect(isPoolFit("p1", "freebuff::gpt-5.6-luna")).toBe(true); + }); + + it("fitPoolIds filters unfit pools; snapshot drops expired marks", () => { + markPoolUnfit("p1", "fb::m1", Date.now() + 60_000); + markPoolUnfit("p1", "fb::m2", Date.now() - 1000); // expired + expect(fitPoolIds(["p1", "p2"], "fb::m1")).toEqual(["p2"]); + + const snap = poolFitnessSnapshot(); + expect(snap.p1["fb::m1"]).toBeDefined(); + expect(snap.p1["fb::m2"]).toBeUndefined(); + }); + + it("does not reuse a pool when every smart candidate is unfit", () => { + markPoolUnfit("p1", "freebuff::openai/gpt-5.6-luna", Date.now() + 60_000, "limited_ip"); + markPoolUnfit("p2", "freebuff::openai/gpt-5.6-luna", Date.now() + 60_000, "limited_ip"); + + expect(pickProxyPoolId( + ["p1", "p2"], + "smart", + "freebuff", + { scope: "freebuff::openai/gpt-5.6-luna" }, + )).toBeNull(); + }); + + it("preserves fail-open smart fallback for non-Freebuff providers", () => { + markPoolUnfit("p1", "opencode::sonnet-4.6", Date.now() + 60_000, "ip-limit"); + markPoolUnfit("p2", "opencode::sonnet-4.6", Date.now() + 60_000, "ip-limit"); + + expect(pickProxyPoolId( + ["p1", "p2"], + "smart", + "opencode", + { scope: "opencode::sonnet-4.6" }, + )).toBe("p1"); + }); + + it("clear per scope, clear-all per provider, clear-all global, pruneExpired", () => { + markPoolUnfit("p1", "freebuff::m1", Date.now() + 60_000); + markPoolUnfit("p1", "kiro::m3", Date.now() + 60_000); + + clearPoolUnfit("p1", "freebuff::m1"); + expect(isPoolFit("p1", "freebuff::m1")).toBe(true); + + clearAllPoolUnfit("kiro"); + expect(poolFitnessSnapshot().p1).toBeUndefined(); + + markPoolUnfit("p2", "x::y", Date.now() - 1000); + expect(pruneExpired()).toBe(1); + expect(poolFitnessSnapshot()).toEqual({}); + }); +}); diff --git a/tests/unit/test-data-dir-isolation.test.js b/tests/unit/test-data-dir-isolation.test.js new file mode 100644 index 00000000..7b0dbeda --- /dev/null +++ b/tests/unit/test-data-dir-isolation.test.js @@ -0,0 +1,27 @@ +// Guard: the test harness must never write into the user's real DB. +// +// Root cause this locks down: route-level tests (zed-live-models, +// zed-native-auth) call createProviderConnection, which persists to +// $DATA_DIR/db/data.sqlite. With no DATA_DIR set, that resolved to ~/.9router — +// polluting the live DB with "zed-live-*@example.com", "guard-*@example.com" +// and "Account N" rows on every `npx vitest run`. +// +// tests/setup/isolateDataDir.js redirects DATA_DIR to a temp dir unless the +// caller opts out via RUN_REAL=1 or an explicit DATA_DIR. +import { describe, it, expect } from "vitest"; +import os from "node:os"; +import path from "node:path"; + +// When the caller opts into the real DB (RUN_REAL=1) or supplies DATA_DIR, +// isolation is intentionally disabled — this guard only applies to the default. +const ISOLATED = !process.env.RUN_REAL && !process.env.EXPECT_REAL_DATA_DIR; + +describe.skipIf(!ISOLATED)("test DATA_DIR isolation", () => { + it("points DATA_DIR at a temp dir, not ~/.9router", () => { + const dir = process.env.DATA_DIR; + expect(dir).toBeTruthy(); + const home = path.join(os.homedir(), ".9router"); + expect(path.resolve(dir)).not.toBe(path.resolve(home)); + expect(dir.startsWith(os.tmpdir())).toBe(true); + }); +}); diff --git a/tests/unit/usage-provider-list.test.js b/tests/unit/usage-provider-list.test.js new file mode 100644 index 00000000..bcfeaefd --- /dev/null +++ b/tests/unit/usage-provider-list.test.js @@ -0,0 +1,80 @@ +// The Usage page lists providers from two sources: +// 1. active LLM connections (deduped by provider id), and +// 2. noAuth free providers that need no connection (e.g. opencode). +// +// Hidden providers (devin-cli, mimo-free) must NOT be auto-added — they are +// excluded from the Providers page, so surfacing them in Usage (with zero +// connections and zero traffic) is a leak. Regression: devin-cli showed up in +// Usage but nowhere in Providers. +import { describe, it, expect } from "vitest"; +import { buildUsageProviderList } from "../../src/shared/utils/usageProviders.js"; + +const isLLM = () => true; + +describe("buildUsageProviderList", () => { + it("does not auto-add hidden noAuth free providers", () => { + const freeProviders = { + opencode: { id: "opencode", name: "OpenCode", noAuth: true }, + "devin-cli": { id: "devin-cli", name: "Devin CLI", noAuth: true, hidden: true }, + "mimo-free": { id: "mimo-free", name: "MiMo Free", noAuth: true, hidden: true }, + }; + + const list = buildUsageProviderList({ + connections: [], + freeProviders, + isLLMProvider: isLLM, + }); + + const ids = list.map((p) => p.provider); + expect(ids).toContain("opencode"); + expect(ids).not.toContain("devin-cli"); + expect(ids).not.toContain("mimo-free"); + }); + + it("includes active LLM connections, deduped by provider", () => { + const list = buildUsageProviderList({ + connections: [ + { provider: "codex", isActive: true }, + { provider: "codex", isActive: true }, + { provider: "zed", isActive: true }, + ], + freeProviders: {}, + isLLMProvider: isLLM, + }); + expect(list.map((p) => p.provider)).toEqual(["codex", "zed"]); + }); + + it("skips inactive connections and non-LLM providers", () => { + const list = buildUsageProviderList({ + connections: [ + { provider: "codex", isActive: false }, + { provider: "whisper", isActive: true }, + ], + freeProviders: {}, + isLLMProvider: (id) => id !== "whisper", + }); + expect(list).toEqual([]); + }); + + it("does not duplicate a free provider that already has a connection", () => { + const freeProviders = { + opencode: { id: "opencode", name: "OpenCode", noAuth: true }, + }; + const list = buildUsageProviderList({ + connections: [{ provider: "opencode", isActive: true }], + freeProviders, + isLLMProvider: isLLM, + }); + expect(list.map((p) => p.provider)).toEqual(["opencode"]); + }); + + it("attaches nodeName from the lookup when present", () => { + const list = buildUsageProviderList({ + connections: [{ provider: "node-1", isActive: true }], + freeProviders: {}, + nodeNameMap: { "node-1": "My Node" }, + isLLMProvider: isLLM, + }); + expect(list[0].nodeName).toBe("My Node"); + }); +}); diff --git a/tests/vitest.config.js b/tests/vitest.config.js index d53d85e0..3db8a4ea 100644 --- a/tests/vitest.config.js +++ b/tests/vitest.config.js @@ -9,6 +9,10 @@ export default defineConfig({ environment: "node", globals: true, include: ["**/*.test.js"], + // Redirect DATA_DIR to a throwaway temp dir so route-level tests that call + // createProviderConnection never touch the user's real ~/.9router DB. + // RUN_REAL=1 or an explicit DATA_DIR opts out (see setup/isolateDataDir.js). + setupFiles: ["./setup/isolateDataDir.js"], // Don't scan into git worktrees nested under .claude/ — they carry their // own copies of the test files but lack an installed node_modules (open-sse, // etc.), which makes provider imports fail during collection.