mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-25 00:22:09 +03:00
Compare commits
41 Commits
fix/docker
...
fix/volcen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa597ab0c1 | ||
|
|
8bbe92c692 | ||
|
|
bbc7bf4351 | ||
|
|
56d64e29a4 | ||
|
|
7b36e45df8 | ||
|
|
ddee064f1b | ||
|
|
b1fdfd5ea4 | ||
|
|
71eeaf293c | ||
|
|
406f4524ff | ||
|
|
dfc5b5eec4 | ||
|
|
0a53c8a2ce | ||
|
|
93da24cd79 | ||
|
|
815c7c2864 | ||
|
|
8f15b79a84 | ||
|
|
07a378c86c | ||
|
|
34150506f2 | ||
|
|
76ac1c8b7e | ||
|
|
d732cf615d | ||
|
|
f58e8bef6f | ||
|
|
243445f210 | ||
|
|
13e29f2f39 | ||
|
|
2544ee9498 | ||
|
|
440113c8e8 | ||
|
|
3c2906a80e | ||
|
|
095f424658 | ||
|
|
20de0d9c79 | ||
|
|
9f30b76057 | ||
|
|
019ad33a61 | ||
|
|
dfc9257b07 | ||
|
|
37e71915db | ||
|
|
077bc1a8a2 | ||
|
|
378eff0f75 | ||
|
|
6de542b9b6 | ||
|
|
315b0a94e1 | ||
|
|
e1c2b347f9 | ||
|
|
f88aa48847 | ||
|
|
8301984734 | ||
|
|
028f1b91e4 | ||
|
|
2af1326adf | ||
|
|
644dd32d3f | ||
|
|
9df3f8923d |
@@ -180,6 +180,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **fix(build):** every route no longer answers HTTP 500 on artifacts built from the release tip ([#11343](https://github.com/diegosouzapw/OmniRoute/issues/11343)) — `next.config.mjs` aliased `better-sqlite3` to its build-time stub **unconditionally**, on the premise that `serverExternalPackages` still won at runtime. It does not: a Turbopack `resolveAlias` rewrites the request *before* the externals check, so the request stopped matching the `better-sqlite3` external entry and the stub was baked into the shipped bundle. The sync driver then failed with `r(...) is not a constructor`, fell through `node:sqlite` and sql.js, and the instrumentation hook aborted at boot. Same failure shape as [#6344](https://github.com/diegosouzapw/OmniRoute/issues/6344), so it gets the same treatment: the alias is opt-in via `OMNIROUTE_BETTER_SQLITE3_STUB=1` through the shared `scripts/build/better-sqlite3-stub-flag.mjs` helper — set it only on a build host that actually hits the SIGABRT build-worker teardown ([#10060](https://github.com/diegosouzapw/OmniRoute/issues/10060)); default builds externalize the real native addon. Regression guards: `tests/unit/better-sqlite3-stub-alias-11343.test.mjs` (5) and the env matrix in `tests/unit/next-config.test.ts`.
|
||||
- **security(search)**: block SSRF via `/v1/search` `provider_options.baseUrl` for the Firecrawl search provider — the client-controlled override is now validated as a public URL before it is used to build the server-side fetch target, so a caller with a valid API key can no longer redirect search requests at loopback, RFC1918, or cloud-metadata hosts — thanks @zmf963
|
||||
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
|
||||
- **cli**: route provider test commands through configured connection test endpoints (#10570)
|
||||
|
||||
@@ -169,7 +169,8 @@ export async function runSetupClaudeCommand(opts = {}) {
|
||||
let detail = `HTTP ${res.status}`;
|
||||
try {
|
||||
const errorBody = await res.json();
|
||||
const serverMsg = errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
|
||||
const serverMsg =
|
||||
errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
|
||||
if (serverMsg) detail += ` — ${serverMsg}`;
|
||||
} catch {}
|
||||
throw new Error(detail);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { npmBin, npmExecOptions } from "../npm-exec.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -31,9 +32,13 @@ export async function getCurrentVersion() {
|
||||
// they were already on the latest version (#4376). `execFn` is injectable for tests.
|
||||
export async function getLatestVersion(execFn = execFileAsync) {
|
||||
try {
|
||||
const { stdout } = await execFn("npm", ["view", "omniroute", "version", "--prefer-online"], {
|
||||
timeout: 15000,
|
||||
});
|
||||
// argv is all literals, so enabling the shell on win32 cannot splice a
|
||||
// runtime value into the command line (Hard Rule #13).
|
||||
const { stdout } = await execFn(
|
||||
npmBin(),
|
||||
["view", "omniroute", "version", "--prefer-online"],
|
||||
npmExecOptions(process.platform, { timeoutMs: 15000 })
|
||||
);
|
||||
return stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
@@ -114,9 +119,11 @@ export async function runUpdateCommand(opts = {}) {
|
||||
|
||||
if (showChangelog) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync("npm", ["view", "omniroute", "changelog"], {
|
||||
timeout: 10000,
|
||||
});
|
||||
const { stdout } = await execFileAsync(
|
||||
npmBin(),
|
||||
["view", "omniroute", "changelog"],
|
||||
npmExecOptions(process.platform, { timeoutMs: 15000 })
|
||||
);
|
||||
if (stdout.trim()) {
|
||||
console.log(stdout.trim());
|
||||
} else {
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"testFailed": "提供者测试失败:{error}",
|
||||
"loginEnabled": "登录:已启用(密码已更新)",
|
||||
"loginDisabled": "登录:已禁用",
|
||||
"providerInfo": "提供者:{info}"
|
||||
"providerInfo": "提供者:{info}",
|
||||
"opencode": "安装并配置随附的 @omniroute/opencode-plugin 以用于 OpenCode"
|
||||
},
|
||||
"doctor": {
|
||||
"title": "OmniRoute 诊断",
|
||||
@@ -252,7 +253,9 @@
|
||||
"no_recovery": "禁用崩溃自动重启(调试模式)",
|
||||
"max_restarts": "30 秒内的最大崩溃重启次数(默认:2)",
|
||||
"tray": "显示系统托盘图标(仅桌面,选择加入)",
|
||||
"no_tray": "禁用系统托盘图标"
|
||||
"no_tray": "禁用系统托盘图标",
|
||||
"tls_cert": "用于提供 HTTPS 服务的 TLS 证书(PEM)路径(也可用 OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "用于提供 HTTPS 服务的 TLS 私钥(PEM)路径(也可用 OMNIROUTE_TLS_KEY)"
|
||||
},
|
||||
"backup": {
|
||||
"title": "备份",
|
||||
@@ -1258,5 +1261,69 @@
|
||||
"search": "搜索 npm 注册表中的可用插件",
|
||||
"update": "更新已安装的插件",
|
||||
"scaffold": "搭建新的插件模板"
|
||||
},
|
||||
"authExport": {
|
||||
"description": "导出已解密的提供者凭据(仅限本地,明文输出)",
|
||||
"idOpt": "仅导出与此 id/名称/提供者匹配的连接",
|
||||
"formatOpt": "输出格式:json 或 env",
|
||||
"outOpt": "将输出写入文件而非标准输出(以 0600 权限写入)",
|
||||
"forceOpt": "确认你了解此操作会打印/写入明文密钥",
|
||||
"warning": "⚠ 此操作会打印/写入已解密的明文 API 密钥和 OAuth 令牌。请确保你的屏幕、shell 历史记录以及任何输出文件保持私密。",
|
||||
"confirmHeading": "⚠ 警告:此操作会以明文导出已解密的提供者凭据",
|
||||
"confirmBody": "此命令会为所选连接解密并打印/写入 apiKey、accessToken、refreshToken 和\nidToken。请将输出视为机密。",
|
||||
"confirmFooter": "如需确认,请运行:\n omniroute auth export --force",
|
||||
"missingKey": "导出凭据需要 STORAGE_ENCRYPTION_KEY。",
|
||||
"notFound": "未找到连接:{id}",
|
||||
"invalidFormat": "无效格式:{format}。请使用 json 或 env。"
|
||||
},
|
||||
"radar": {
|
||||
"description": "检查并同步本地 Radar 目录订阅源",
|
||||
"status": "显示本地 Radar 设置和订阅源缓存状态",
|
||||
"sync": "通过本地服务器同步目录、推荐、优惠和 Intel"
|
||||
},
|
||||
"launch": {
|
||||
"description": "启动指向 OmniRoute 的 Claude Code(本地或远程,使用 --profile)",
|
||||
"token": "Claude 客户端应发送的令牌(ANTHROPIC_AUTH_TOKEN)",
|
||||
"notRunning": "无法在 {port} 访问 OmniRoute。请使用 “omniroute serve” 启动它。",
|
||||
"notFound": "在 PATH 中未找到 “claude” CLI。"
|
||||
},
|
||||
"run": {
|
||||
"description": "通过 OmniRoute 启动受支持的 CLI 目标"
|
||||
},
|
||||
"setupClaude": {
|
||||
"description": "从 OmniRoute 模型目录生成 ~/.claude/profiles 的 Claude Code 配置文件"
|
||||
},
|
||||
"connect": {
|
||||
"description": "连接到远程 OmniRoute 服务器并进入远程模式"
|
||||
},
|
||||
"tokens": {
|
||||
"description": "管理限定范围的 CLI 访问令牌(远程模式)"
|
||||
},
|
||||
"configure": {
|
||||
"description": "从活动服务器选择提供者+模型并配置受支持的本地 CLI"
|
||||
},
|
||||
"launchCodex": {
|
||||
"description": "启动指向 OmniRoute 的 Codex CLI(本地或远程 VPS)"
|
||||
},
|
||||
"setupCodex": {
|
||||
"description": "从 OmniRoute 实时模型目录生成 ~/.codex 配置文件"
|
||||
},
|
||||
"packs": {
|
||||
"description": "管理可选的运行时包(ML / 浏览器自动化)",
|
||||
"listDescription": "列出可选包及其安装状态",
|
||||
"installDescription": "将可选包安装到 DATA_DIR",
|
||||
"verifyDescription": "根据随附的校验和索引验证已安装的包",
|
||||
"removeDescription": "移除已安装的可选包",
|
||||
"sourceOpt": "存放包负载和包索引的目录",
|
||||
"warnNoIndex": "未找到 optional-packs.index.json —— 此检出无法进行安装/验证(桌面捆绑包会附带它)",
|
||||
"errUnknown": "未知的包:{name}",
|
||||
"errNoIndex": "未找到包索引;请通过 --source <dir> 传入存放包负载的目录(桌面捆绑包会将其附带在应用旁)",
|
||||
"installed": "包 “{name}” 已安装并在 {dir} 验证通过",
|
||||
"restartHint": "请重启 OmniRoute 服务器(或桌面应用),以便运行时加载该包",
|
||||
"removed": "包 “{name}” 已移除",
|
||||
"notInstalled": "包 “{name}” 未安装",
|
||||
"verifyOk": "所有已安装的包均已验证通过",
|
||||
"verifyFailed": "{count} 个包验证失败",
|
||||
"noneInstalled": "未安装可选包"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"testFailed": "提供者測試失敗:{error}",
|
||||
"loginEnabled": "登入:已啟用(密碼已更新)",
|
||||
"loginDisabled": "登入:已停用",
|
||||
"providerInfo": "提供者:{info}"
|
||||
"providerInfo": "提供者:{info}",
|
||||
"opencode": "安裝並配置隨附的 @omniroute/opencode-plugin 以用於 OpenCode"
|
||||
},
|
||||
"doctor": {
|
||||
"title": "OmniRoute 診斷",
|
||||
@@ -252,7 +253,9 @@
|
||||
"no_recovery": "停用崩潰自動重啟(除錯模式)",
|
||||
"max_restarts": "30 秒內的最大崩潰重啟次數(預設:2)",
|
||||
"tray": "顯示系統托盤圖示(僅桌面,選擇加入)",
|
||||
"no_tray": "停用系統托盤圖示"
|
||||
"no_tray": "停用系統托盤圖示",
|
||||
"tls_cert": "用於提供 HTTPS 服務的 TLS 憑證(PEM)路徑(也可用 OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "用於提供 HTTPS 服務的 TLS 私鑰(PEM)路徑(也可用 OMNIROUTE_TLS_KEY)"
|
||||
},
|
||||
"backup": {
|
||||
"title": "備份",
|
||||
@@ -1258,5 +1261,69 @@
|
||||
"search": "搜尋 npm 登錄檔中的可用外掛",
|
||||
"update": "更新已安裝的外掛",
|
||||
"scaffold": "搭建新的外掛模板"
|
||||
},
|
||||
"authExport": {
|
||||
"description": "匯出已解密的提供者憑據(僅限本機,明文輸出)",
|
||||
"idOpt": "僅匯出與此 id/名稱/提供者相符的連線",
|
||||
"formatOpt": "輸出格式:json 或 env",
|
||||
"outOpt": "將輸出寫入檔案而非標準輸出(以 0600 權限寫入)",
|
||||
"forceOpt": "確認你了解此操作會列印/寫入明文密鑰",
|
||||
"warning": "⚠ 此操作會列印/寫入已解密的明文 API 金鑰和 OAuth 令牌。請確保你的螢幕、shell 歷史記錄以及任何輸出檔案保持私密。",
|
||||
"confirmHeading": "⚠ 警告:此操作會以明文匯出已解密的提供者憑據",
|
||||
"confirmBody": "此命令會為所選連線解密並列印/寫入 apiKey、accessToken、refreshToken 和\nidToken。請將輸出視為機密。",
|
||||
"confirmFooter": "如需確認,請執行:\n omniroute auth export --force",
|
||||
"missingKey": "匯出憑據需要 STORAGE_ENCRYPTION_KEY。",
|
||||
"notFound": "找不到連線:{id}",
|
||||
"invalidFormat": "無效格式:{format}。請使用 json 或 env。"
|
||||
},
|
||||
"radar": {
|
||||
"description": "檢查並同步本機 Radar 目錄訂閱來源",
|
||||
"status": "顯示本機 Radar 設定和訂閱來源快取狀態",
|
||||
"sync": "透過本機伺服器同步目錄、推薦、優惠和 Intel"
|
||||
},
|
||||
"launch": {
|
||||
"description": "啟動指向 OmniRoute 的 Claude Code(本機或遠端,使用 --profile)",
|
||||
"token": "Claude 用戶端應傳送的令牌(ANTHROPIC_AUTH_TOKEN)",
|
||||
"notRunning": "無法在 {port} 存取 OmniRoute。請使用「omniroute serve」啟動它。",
|
||||
"notFound": "在 PATH 中找不到「claude」CLI。"
|
||||
},
|
||||
"run": {
|
||||
"description": "透過 OmniRoute 啟動受支援的 CLI 目標"
|
||||
},
|
||||
"setupClaude": {
|
||||
"description": "從 OmniRoute 模型目錄產生 ~/.claude/profiles 的 Claude Code 配置檔"
|
||||
},
|
||||
"connect": {
|
||||
"description": "連線到遠端 OmniRoute 伺服器並進入遠端模式"
|
||||
},
|
||||
"tokens": {
|
||||
"description": "管理限定範圍的 CLI 存取令牌(遠端模式)"
|
||||
},
|
||||
"configure": {
|
||||
"description": "從使用中的伺服器選擇提供者+模型並配置受支援的本機 CLI"
|
||||
},
|
||||
"launchCodex": {
|
||||
"description": "啟動指向 OmniRoute 的 Codex CLI(本機或遠端 VPS)"
|
||||
},
|
||||
"setupCodex": {
|
||||
"description": "從 OmniRoute 即時模型目錄產生 ~/.codex 配置檔"
|
||||
},
|
||||
"packs": {
|
||||
"description": "管理可選的執行階段套件(ML / 瀏覽器自動化)",
|
||||
"listDescription": "列出可選套件及其安裝狀態",
|
||||
"installDescription": "將可選套件安裝到 DATA_DIR",
|
||||
"verifyDescription": "根據隨附的總和檢查碼索引驗證已安裝的套件",
|
||||
"removeDescription": "移除已安裝的可選套件",
|
||||
"sourceOpt": "存放套件負載和套件索引的目錄",
|
||||
"warnNoIndex": "找不到 optional-packs.index.json —— 此檢出無法進行安裝/驗證(桌面套件會隨附它)",
|
||||
"errUnknown": "未知的套件:{name}",
|
||||
"errNoIndex": "找不到套件索引;請透過 --source <dir> 傳入存放套件負載的目錄(桌面套件會將其隨附在應用程式旁)",
|
||||
"installed": "套件「{name}」已安裝並在 {dir} 驗證通過",
|
||||
"restartHint": "請重新啟動 OmniRoute 伺服器(或桌面應用程式),以便執行階段載入該套件",
|
||||
"removed": "套件「{name}」已移除",
|
||||
"notInstalled": "套件「{name}」未安裝",
|
||||
"verifyOk": "所有已安裝的套件均已驗證通過",
|
||||
"verifyFailed": "{count} 個套件驗證失敗",
|
||||
"noneInstalled": "未安裝可選套件"
|
||||
}
|
||||
}
|
||||
|
||||
34
bin/cli/npm-exec.mjs
Normal file
34
bin/cli/npm-exec.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
// Spawning npm from the CLI, on every platform.
|
||||
//
|
||||
// On Windows npm is `npm.cmd`, a batch wrapper. Node ≥ 24 refuses to spawn a
|
||||
// `.cmd` without a shell (nodejs/node#52554), and a bare `npm` can additionally
|
||||
// resolve to an extensionless shim that `CreateProcess` cannot execute — so the
|
||||
// call fails with `EINVAL` or `ENOENT` while npm works fine in the same terminal.
|
||||
// `src/lib/services/installers/utils.ts` already solves this for the server; this
|
||||
// is the same rule for the `bin/cli` entry points, which cannot import TypeScript.
|
||||
//
|
||||
// SECURITY (Hard Rule #13): enabling the shell means the SHELL splits the command
|
||||
// line, not `execFile`. Every argv element passed alongside these options must be
|
||||
// a literal — never a runtime value — or it must be validated first. Callers that
|
||||
// need to pass a user-supplied name have to guard it themselves.
|
||||
|
||||
/** The npm binary to spawn on this platform. */
|
||||
export function npmBin(platform = process.platform) {
|
||||
const isBun = Boolean(process.versions.bun);
|
||||
if (platform === "win32") return isBun ? "bun.exe" : "npm.cmd";
|
||||
return isBun ? "bun" : "npm";
|
||||
}
|
||||
|
||||
/**
|
||||
* `execFile` / `spawnSync` options for an npm call.
|
||||
*
|
||||
* @param {NodeJS.Platform} platform
|
||||
* @param {{ timeoutMs?: number, stdio?: string }} [options]
|
||||
*/
|
||||
export function npmExecOptions(platform = process.platform, options = {}) {
|
||||
const base = {};
|
||||
if (options.timeoutMs !== undefined) base.timeout = options.timeoutMs;
|
||||
if (options.stdio !== undefined) base.stdio = options.stdio;
|
||||
if (platform !== "win32") return { ...base, shell: false };
|
||||
return { ...base, shell: true, windowsHide: true };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { execSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime");
|
||||
// systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime,
|
||||
@@ -16,6 +17,16 @@ export const SYSTRAY_PACKAGE = "systray2";
|
||||
export const SYSTRAY_VERSION = "2.1.4";
|
||||
const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`;
|
||||
|
||||
// Dynamic `import()` resolves its specifier as a URL, not a filesystem path.
|
||||
// On Windows the lazily-installed systray2 lives at an absolute path whose
|
||||
// leading drive letter the ESM loader parses as an unsupported URL scheme
|
||||
// (e.g. `c:`) and rejects. Build a file:// URL so the tray import works on
|
||||
// Windows too. Same defect fixed for the CLI db-fallback imports in #11238,
|
||||
// missed at this call site.
|
||||
export function systrayModuleSpecifier(runtimeDir: string): string {
|
||||
return pathToFileURL(join(runtimeDir, "node_modules", SYSTRAY_PACKAGE)).href;
|
||||
}
|
||||
|
||||
export function resolveSystrayBinName(platform: NodeJS.Platform): string | null {
|
||||
if (platform === "win32") return "tray_windows_release.exe";
|
||||
if (platform === "darwin") return "tray_darwin_release";
|
||||
@@ -60,8 +71,7 @@ export async function loadSystray(): Promise<(new (...args: unknown[]) => unknow
|
||||
// drop the +x bit on extraction (observed on macOS).
|
||||
chmodSystrayBinAt(RUNTIME_DIR, process.platform);
|
||||
try {
|
||||
const modPath = join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE);
|
||||
const mod = await import(modPath);
|
||||
const mod = await import(systrayModuleSpecifier(RUNTIME_DIR));
|
||||
return (mod.default ?? mod.SysTray ?? mod) as (new (...args: unknown[]) => unknown) | null;
|
||||
} catch (err) {
|
||||
console.warn(`[omniroute] tray runtime import failed: ${(err as Error).message}`);
|
||||
|
||||
@@ -114,10 +114,13 @@ function writeLinuxSystemdUnit(cliPath) {
|
||||
const unitDir = dirname(linuxSystemdUnitPath());
|
||||
mkdirSync(unitDir, { recursive: true });
|
||||
const envFile = join(userHomeDir(), ".omniroute", ".env");
|
||||
const nodeBinDir = dirname(process.execPath);
|
||||
const userLocalBin = join(userHomeDir(), ".local", "bin");
|
||||
const pathEnv = `${nodeBinDir}:${userLocalBin}:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin`;
|
||||
const lines = [
|
||||
"[Unit]",
|
||||
"Description=OmniRoute AI proxy router",
|
||||
"After=network-online.target",
|
||||
"After=network-online.target graphical-session.target",
|
||||
"Wants=network-online.target",
|
||||
"",
|
||||
"[Service]",
|
||||
@@ -134,6 +137,7 @@ function writeLinuxSystemdUnit(cliPath) {
|
||||
`ExecStart=${buildServeExecLine(cliPath, { tray: false })}`,
|
||||
"Restart=on-failure",
|
||||
"RestartSec=5",
|
||||
`Environment="PATH=${pathEnv}"`,
|
||||
];
|
||||
if (existsSync(envFile)) lines.push(`EnvironmentFile=-${envFile}`);
|
||||
lines.push("", "[Install]", "WantedBy=default.target", "");
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
- **fix(providers):** Antigravity OAuth marks connects with no Cloud Code projectId as degraded instead of a false "Connected"; BYOP detection at connect time, auto-disable of confirmed-missing accounts, and selection-side rotation ([#11284](https://github.com/diegosouzapw/OmniRoute/issues/11284))
|
||||
1
changelog.d/fixes/11347-codex-claude-empty-tool-use.md
Normal file
1
changelog.d/fixes/11347-codex-claude-empty-tool-use.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(translator):** Codex Responses tool calls translated for Claude clients no longer emit a duplicate `tool_use` block with the same ID and an empty name, preventing Claude Code from terminating with `No such tool available` ([#11347](https://github.com/diegosouzapw/OmniRoute/pull/11347))
|
||||
1
changelog.d/fixes/11394-modelsdev-interval-slider.md
Normal file
1
changelog.d/fixes/11394-modelsdev-interval-slider.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** Model Database sync interval slider ticks now match the thumb position — checkpoint-space slider with magnetic snap on release ([#11394](https://github.com/diegosouzapw/OmniRoute/pull/11394)) — thanks @An0nym0us92
|
||||
1
changelog.d/fixes/cli-update-npm-win32-11335.md
Normal file
1
changelog.d/fixes/cli-update-npm-win32-11335.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(cli):** `omniroute update` now finds npm on Windows. It called `execFile("npm", …)` with no shell, and on Node ≥ 24 a `.cmd` wrapper cannot be spawned that way (nodejs/node#52554) — while a bare `npm` can also resolve to an extensionless shim `CreateProcess` refuses. The result was `✖ Could not check latest version. Is npm available?` in a terminal where `npm view omniroute version` worked fine, so the updater was unusable on Windows even though nothing was wrong with the install. This is the same class as #5379/#5542, which fixed the server-side calls; the CLI entry points were missed because they are plain `.mjs` and cannot import the TypeScript helper. `bin/cli/npm-exec.mjs` now states the same rule for them: `npm.cmd` plus a shell on win32, no shell anywhere else. Both npm lookups in `update.mjs` (version and changelog) pass a literal argv array, so enabling the shell cannot splice a runtime value into the command line — a test asserts that and fails if a future edit interpolates one. (#11335)
|
||||
1
changelog.d/fixes/compression-worker-bundler-resolve.md
Normal file
1
changelog.d/fixes/compression-worker-bundler-resolve.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(compression):** use `pathToFileURL` in `compressionWorkerPool` so bundlers (Webpack / Turbopack) do not attempt static asset resolution of missing `compressionWorker.js` during build
|
||||
1
changelog.d/fixes/glm-credit-limit-quota.md
Normal file
1
changelog.d/fixes/glm-credit-limit-quota.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(usage):** z.ai/GLM coding-plan subscription keys now render their quota cards again, with absolute credits. Z.ai's `/api/monitor/usage/quota/limit` switched these keys from `TOKENS_LIMIT` to `CREDIT_LIMIT` rows (same `unit`/`number` semantics: unit=3/number=5 → 5-hour window, unit=6/number=1 → weekly), and the parser only matched `TOKENS_LIMIT`/`TIME_LIMIT`, so both rows were dropped and the subscription card rendered empty. `CREDIT_LIMIT` is now accepted alongside `TOKENS_LIMIT`, and when the row carries absolute credit fields (`usage`/`currentValue`/`remaining`) they are preferred over the percent-only scale, so the card shows `3341 / 28000` like z.ai's own dashboard instead of `11 / 100`
|
||||
1
changelog.d/fixes/lasterror-provider-error-detail.md
Normal file
1
changelog.d/fixes/lasterror-provider-error-detail.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(auth):** a connection's `lastError` now names the real upstream failure instead of the bare string `Provider error`. `markAccountUnavailable` kept the reason only when it was already a string, so every other shape collapsed to that literal — and the shape that matters most is not a string: a failed `fetch` arrives as `TypeError: fetch failed` with the actionable part on `error.cause.code`, which means a wrong port, a firewall, a DNS failure and a blocked proxy all looked identical in the dashboard and in the console line. `describeUpstreamFailure` (in `src/shared/utils/upstreamError.ts`, reusing the `extractErrorMessage` that already parsed provider bodies) reads Error messages and appends the transport code when the message does not already carry it, reads the usual provider JSON shapes (`error.message`, `message`, string `error`, `detail`, `errors[]`), and falls back to the code alone before giving up. It never serializes the error object wholesale, so a request body or header attached to an error cannot leak into the stored reason — pinned by a test.
|
||||
1
changelog.d/fixes/live-ws-public-url-runtime.md
Normal file
1
changelog.d/fixes/live-ws-public-url-runtime.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(live-ws):** the Live dashboard socket can now be pointed at a reverse proxy without rebuilding the image. `NEXT_PUBLIC_*` is inlined at BUILD time, so a prebuilt Docker or npm image never carries an operator's `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` — which is exactly why the browser discovers the socket through `/api/v1/ws?handshake=1` instead. The server side of that handshake, however, read only the `NEXT_PUBLIC_`-prefixed name, so it had nothing to echo: behind Traefik the dashboard kept dialling `wss://<host>:20132/live-ws` and sat on "Live disabled — WebSocket disconnected. Showing last known state." `LIVE_WS_PUBLIC_URL` is now read at runtime alongside the existing `LIVE_WS_HOST` / `LIVE_WS_PORT`, and the prefixed name stays supported as the fallback, so deployments that already set it are unaffected. Only `ws://` and `wss://` values are accepted, matching the guard the client already applies. (#11331)
|
||||
@@ -227,7 +227,9 @@
|
||||
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1604,
|
||||
"tests/unit/usage-service-hardening.test.ts": 1928,
|
||||
"tests/unit/vscode-token-routes.test.ts": 1633,
|
||||
"tests/unit/executor-antigravity.test.ts": 1427
|
||||
"tests/unit/executor-antigravity.test.ts": 1427,
|
||||
"tests/unit/guardrails/videoBridgeResultCache.test.ts": 1040,
|
||||
"_rebaseline_2026_08_24_video_bridge_fu01_fu03_fu04_result_cache_tests": "PRs #11362 (FU-01 cache hardening) + #11382 (FU-03 visual dedup policy identity) + #11383 (FU-04 focused analysis mode) own test growth: videoBridgeResultCache.test.ts <1000->1040, +40 (sum of three stacked PRs boarded together in the same merge-batch, each adding its own cache-identity assertions on the shared result-cache seam). Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive)."
|
||||
},
|
||||
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
|
||||
"_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948→4062 (-886 LOC); 3 novos hooks extraídos. useProviderConnections.ts=954 acima do cap=800 — justificado: extração direta do god-component (zero lógica nova), própria redução do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 já abaixo do cap.",
|
||||
@@ -308,7 +310,7 @@
|
||||
"_rebaseline_2026_07_27_v3849_train1h": "Merge-train 1H (31 PRs) — owner-approved 2026-07-27. Two distinct causes, kept separate on purpose: (1) GENUINE irreducible growth at existing chokepoints — providerLimits/auth (#8632 Kimi quota-reset recovery), rateLimitManager (#8616 idle wedged limiters), models-catalog-route.test (#8610 OpenCode Go effort aliases); (2) COLLISION with #8585, which banked shrinks measured on the pre-train release tip while 30 sibling PRs in the SAME train grew those files again — chat/accountFallback (#8628), chatCore (#8613), videoGeneration (#8581), imageGeneration. The zero-headroom frozen entries cannot absorb either. Ceilings re-pinned to the post-merge tip; #8612 (also in this train) automates shrink-banking so this self-inflicted drift stops recurring. Detail: src/lib/usage/providerLimits.ts 1006->1013 (#8632); src/sse/services/auth.ts 2492->2508 (#8632); open-sse/services/rateLimitManager.ts 1014->1060 (#8616); src/sse/handlers/chat.ts 1842->1845 (#8628); open-sse/handlers/chatCore.ts 4939->4955 (#8613); open-sse/handlers/imageGeneration.ts 3100->3101 ((sem PR — teto do #8585)); open-sse/handlers/videoGeneration.ts 1038->1063 (#8581); open-sse/services/accountFallback.ts 1965->1966 (#8628); tests/unit/models-catalog-route.test.ts 1608->1636 (#8610)",
|
||||
"frozen": {
|
||||
"_rebaseline_2026_08_20_10878_10799_provider_health_probes": "PRs #10878 (unsupported OpenAI-like validation probes stay neutral) + #10799 (preserve credential health on inconclusive NVIDIA-timeout/Antigravity-400 probes) own growth: src/app/api/providers/[id]/test/route.ts 946->1025 (+79, sum of both boarded together). Both add narrowly-scoped classification branches at the existing test-route dispatch chokepoint (unsupported-capability skip, credential-inconclusive detection) rather than new files, mirroring the prior 2026_06_27_5193 rebaseline of the same file. Covered by tests/unit/provider-validation-unsupported-neutral.test.ts + tests/unit/provider-health-inconclusive-probes.test.ts.",
|
||||
"src/app/api/providers/[id]/test/route.ts": 1215,
|
||||
"src/app/api/providers/[id]/test/route.ts": 1237,
|
||||
"_rebaseline_2026_08_23_11141_oauth_400_recovery": "PR #11141 (HouMinXi) own growth: test/route.ts 1025->1215 (+190, the reactive-400 recovery path — a fully rebuilt probe for refresh+retry on refreshable non-rotating connections, with inconclusive-status preservation and rotating-provider exclusion; all growth is the new probe builder + guards at the existing test-route dispatch, extraction would split the retry flow mid-logic). Covered by tests/unit/oauth-400-recovery.test.ts (8, bug-injection proof). Owner pre-authorized baseline bumps 2026-08-22.",
|
||||
"_rebaseline_2026_06_22_4644_deepseek_web_tools": "PR #4644 (BugsBag/robust deepseek-web tool-call parsing): open-sse/executors/deepseek-web.ts 1117->1125 (+8). The new agentic tool-call path emits surrounding text + reasoning before tool_calls and swaps to the dedicated deepseekWebTools.ts parser; the +8 lines are cohesive wiring at the existing transformSSE chokepoint (the parser itself lives in the new deepseekWebTools.ts file, already under cap). The PR's own fast-gate (PR->release) does not run check:file-size, so this surfaced only at release reconcile. Covered by tests/unit/deepseek-web-tools-variants.test.ts + deepseek-web-tools-execute.test.ts.",
|
||||
"_rebaseline_2026_06_23_4712_deepseek_web_tool_results": "PR for #4712 (deepseek-web drops role:tool): open-sse/executors/deepseek-web.ts 1125->1148 (+23). messagesToPrompt() now folds role:\"tool\" results into the single-prompt transcript (recovering the tool name from the preceding assistant tool_calls by tool_call_id) instead of silently dropping them; the lines are cohesive wiring inside the existing function. Covered by tests/unit/deepseek-web-tool-result-prompt-4712.test.ts.",
|
||||
@@ -433,7 +435,8 @@
|
||||
"src/shared/components/analytics/charts.tsx": 1346,
|
||||
"src/shared/services/cliRuntime.ts": 1459,
|
||||
"src/sse/handlers/chat.ts": 2493,
|
||||
"src/sse/services/auth.ts": 3344,
|
||||
"src/sse/services/auth.ts": 3346,
|
||||
"_rebaseline_2026_08_24_lasterror_provider_error_detail": "PR (ntdat812) own growth: src/sse/services/auth.ts 3344->3346 (+2). One line is the import of describeUpstreamFailure from @/shared/utils/upstreamError, which replaces the string-only collapse `typeof errorText === \"string\" ? errorText.slice(0, 100) : \"Provider error\"` at the single markAccountUnavailable chokepoint (net 0 lines there) — the logic itself lives in upstreamError.ts, next to the extractErrorMessage it reuses, so nothing else moved into this file. The second line is the repo's own lint-staged prettier pass splitting a pre-existing two-statements-on-one-line at getProviderCredentials (`invalidateManagedLease(...); log.warn(...)`); it re-applies on any commit that touches this file, so it is not separable from the change. Covered by tests/unit/provider-error-detail-lastError.test.ts.",
|
||||
"_rebaseline_2026_08_23_11186_synced_inventory_routing": "PR #11186 (pacocartones) own growth: src/sse/services/auth.ts 3260->3337 (+77, loadAdvertisedModelsForSelfHostedConnections + the modelNotAdvertised candidate-filter predicate — pins chat routing to the connection whose synced inventory actually advertises the model, fixing spurious model-not-found on multi-host self-hosted setups; at the existing credential-selection chokepoint, not extractable without splitting the selection flow). Covered by tests/unit/chat-routing-synced-inventory-11089.test.ts. Owner pre-authorized baseline bumps 2026-08-22.",
|
||||
"tests/unit/account-fallback-service.test.ts": 2044,
|
||||
"tests/unit/provider-validation-specialty.test.ts": 3880,
|
||||
@@ -472,7 +475,10 @@
|
||||
"_rebaseline_2026_08_21_10907_sticky_pin_clear": "#10907 own growth: open-sse/executors/commandCode.ts 1023->1038 (+15, effort-suffix sanitization threading for the sticky-pin-clear fix). Cohesive change at the existing executor chokepoint. Covered by tests/unit/command-code-executor.test.ts.",
|
||||
"_rebaseline_2026_08_21_10986_reasoning_only_content": "#10986 own growth: open-sse/executors/commandCode.ts 1038->1059 (+21, reasoning-only content fallback — when upstream emits only reasoning-delta events and never a text-delta, surface the reasoning text as message.content in createJsonResponse and emit a synthetic content delta in createStreamResponse). Cohesive bug fix at the existing executor chokepoint (mirrors precedent style of #10907/#10859). Covered by tests/unit/command-code-executor.test.ts (2 new cases: non-stream + streaming).",
|
||||
"_rebaseline_2026_08_21_11069_m365_har_import": "#11069 own growth: AddApiKeyModal.tsx 1073->1080 (+7 = Import .har file button for the copilot-m365-web credential modal — M365 is the only provider whose credential (access_token+chathubPath) must be extracted from a DevTools HAR WebSocket URL, added as a new modal affordance). Cohesive UI at the existing modal chokepoint; not extractable. Covered by tests/unit/m365-har-import*.test.ts.",
|
||||
"_rebaseline_2026_08_23_tip_drift_post_batch0823": "Tip drift after the 2026-08-23 merge wave: chatBodyAdmission.ts 1009->1118 (+109, gate count incl. +1) and auth.ts 3337->3344 (+7), both grown by merges already on origin/release/v3.8.50 (verified identical on the pristine tip) — not by the codex-appserver-hardening PR that carries this bump. Owner pre-authorized baseline bumps 2026-08-22."
|
||||
"_rebaseline_2026_08_23_tip_drift_post_batch0823": "Tip drift after the 2026-08-23 merge wave: chatBodyAdmission.ts 1009->1118 (+109, gate count incl. +1) and auth.ts 3337->3344 (+7), both grown by merges already on origin/release/v3.8.50 (verified identical on the pristine tip) — not by the codex-appserver-hardening PR that carries this bump. Owner pre-authorized baseline bumps 2026-08-22.",
|
||||
"_rebaseline_2026_08_24_11355_cooldown_recovery_guards": "PR #11355 own growth: test/route.ts 1215->1237, +22 (startup crash-recovery guard: clearStaleCrashCooldowns() now parses the persisted rate_limited_until deadline and skips clearing rows still genuinely in the future, instead of clearing every non-terminal cooldown unconditionally). Cohesive fix at the existing test-route dispatch chokepoint alongside the #11141 probe builder. Covered by tests/unit/startup-stale-cooldown-recovery.test.ts + tests/unit/repro-zai-cooldown-cleared-by-connection-test.test.ts.",
|
||||
"src/lib/guardrails/videoBridgeRuntime.ts": 1009,
|
||||
"_rebaseline_2026_08_24_video_bridge_fu02_fu07_sampler": "PRs #11344 (FU-02 one-frame scene-aware determinism) + #11381 (FU-07 opt-in segment_aware structural sampling) own growth: videoBridgeRuntime.ts <1000->1009, +9 (sum of both boarded together in the same merge-batch). #11344 adds the deterministic one-frame midpoint fallback + policyEffective=uniform report at the existing scene_aware seam; #11381 adds the bounded local-only FFmpeg structural pre-analysis pass (scene/freeze/blur/exposure/SI-TI) and its budget-reallocation logic. Covered by tests/unit/guardrails/videoBridgeSampler.test.ts, tests/unit/guardrails/videoBridgeFu07StructuralSampling.test.ts, tests/integration/video-bridge-sampler-ffmpeg.test.ts. Owner pre-authorized rebaseline for legitimate PR growth (2026-08-19 directive)."
|
||||
},
|
||||
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
|
||||
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) — owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore — the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
|
||||
|
||||
@@ -108,24 +108,48 @@ A successful policy returns `AuthSubject` with `kind ∈ { client_api_key, dashb
|
||||
|
||||
`src/shared/constants/publicApiRoutes.ts` is the explicit allowlist:
|
||||
|
||||
The list is split by **shape**, and the split is load-bearing (GHSA-74g9-q8f6-793h): a prefix is
|
||||
matched with `startsWith()`, so it also matches every adjacent path sharing its leading characters.
|
||||
`/api/usage/om-usage` as a prefix marked `/api/usage/om-usage<anything>` PUBLIC, and Next resolves
|
||||
that to `/api/usage/[connectionId]` — a handler with no auth of its own.
|
||||
|
||||
```ts
|
||||
// Genuine subtrees. Every entry MUST end in "/" (asserted by a unit test).
|
||||
PUBLIC_API_ROUTE_PREFIXES = [
|
||||
"/api/auth/oidc/",
|
||||
"/api/v1/", // treated as CLIENT_API in classify, not as "no-auth public"
|
||||
"/api/oauth/",
|
||||
"/api/codex/connect/",
|
||||
"/api/telegram/",
|
||||
"/api/cursor-cli/",
|
||||
];
|
||||
|
||||
// Single routes, matched EXACTLY (with or without a trailing slash).
|
||||
PUBLIC_API_ROUTES_EXACT = new Set([
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/status",
|
||||
"/api/init",
|
||||
"/api/v1/", // treated as CLIENT_API in classify, not as "no-auth public"
|
||||
"/api/cloud/",
|
||||
"/api/sync/bundle",
|
||||
"/api/oauth/",
|
||||
"/api/cli/connect",
|
||||
"/api/usage/om-usage",
|
||||
"/api/skills/collect/chaos",
|
||||
]);
|
||||
|
||||
// Read-only single routes that also take the CORS origin relaxation.
|
||||
PUBLIC_READONLY_CORS_API_ROUTES = [
|
||||
"/api/health/ping",
|
||||
"/api/monitoring/health",
|
||||
"/api/settings/require-login",
|
||||
];
|
||||
|
||||
PUBLIC_READONLY_API_ROUTE_PREFIXES = ["/api/monitoring/health", "/api/settings/require-login"];
|
||||
// Read-only single route WITHOUT the CORS relaxation.
|
||||
PUBLIC_READONLY_API_ROUTES_EXACT = new Set(["/api/health"]);
|
||||
|
||||
PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
```
|
||||
|
||||
Read-only prefixes are public **only** for safe methods. Note: `classifyRoute()` excludes `/api/v1/*` and `/api/v1beta/*` from the PUBLIC fall-through — those are always `CLIENT_API` so the Bearer-key policy still applies.
|
||||
Read-only routes are public **only** for safe methods. Note: `classifyRoute()` excludes `/api/v1/*` and `/api/v1beta/*` from the PUBLIC fall-through — those are always `CLIENT_API` so the Bearer-key policy still applies.
|
||||
|
||||
## Adding a New Route
|
||||
|
||||
@@ -168,7 +192,7 @@ export async function POST(request: Request) {
|
||||
|
||||
### Pattern 3 — Adding to the public allowlist
|
||||
|
||||
Add the prefix to `PUBLIC_API_ROUTE_PREFIXES` (or `PUBLIC_READONLY_API_ROUTE_PREFIXES` for GET-only). Update unit tests at `tests/unit/public-api-routes.test.ts` and `tests/unit/authz/classify.test.ts`.
|
||||
Pick the set by shape, not by convenience. One route goes in `PUBLIC_API_ROUTES_EXACT` (or `PUBLIC_READONLY_CORS_API_ROUTES` for GET-only); only a genuine subtree goes in `PUBLIC_API_ROUTE_PREFIXES`, and it **must end in `/`**. Putting a single route in the prefix list also publishes every adjacent path that shares its leading characters — including dynamic-segment siblings added later (GHSA-74g9-q8f6-793h). Update unit tests at `tests/unit/public-api-routes.test.ts`, `tests/unit/authz/public-route-exact-match.test.ts` and `tests/unit/authz/classify.test.ts`.
|
||||
|
||||
## Scopes
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import createNextIntlPlugin from "next-intl/plugin";
|
||||
import { createMDX } from "fumadocs-mdx/next";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { betterSqlite3AliasFor } from "./scripts/build/better-sqlite3-stub-flag.mjs";
|
||||
import { mitmManagerAliasFor } from "./scripts/build/mitm-stub-flag.mjs";
|
||||
import { normalizeBasePath } from "./scripts/build/normalizeBasePath.mjs";
|
||||
import {
|
||||
@@ -138,10 +139,14 @@ const nextConfig = {
|
||||
// the stub to every npm/Electron/VPS artifact and broke Agent Bridge
|
||||
// start for all non-Docker users (#6344). See scripts/build/mitm-stub-flag.mjs.
|
||||
...mitmManagerAliasFor(process.env),
|
||||
// Build-time stub so the bundler never traces the native better-sqlite3
|
||||
// addon into a build worker (SIGABRT at worker teardown). Runtime still
|
||||
// uses the real package via serverExternalPackages. (#10060)
|
||||
"better-sqlite3": "./src/lib/db/better-sqlite3.stub.js",
|
||||
// better-sqlite3 → build-time stub ONLY where the build worker actually
|
||||
// aborts while tracing the native addon (SIGABRT at worker teardown,
|
||||
// #10060); opt in with OMNIROUTE_BETTER_SQLITE3_STUB=1. The alias used to
|
||||
// be unconditional on the premise that serverExternalPackages still won
|
||||
// at runtime — it does not: resolveAlias rewrites the request before the
|
||||
// externals check, so the stub was bundled and EVERY route answered 500
|
||||
// (#11343). See scripts/build/better-sqlite3-stub-flag.mjs.
|
||||
...betterSqlite3AliasFor(process.env),
|
||||
...minimalBuildAliases,
|
||||
},
|
||||
// src/lib/agentSkills/generator.ts builds its fs base path from a runtime
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { FreeModelBudget } from "./freeModelCatalog.ts";
|
||||
* rewrites file timestamps on every deploy, which would report a months-old
|
||||
* catalog as "updated today". Bump this whenever the entries below change.
|
||||
*/
|
||||
export const FREE_CATALOG_CURATED_AT = "2026-08-18";
|
||||
export const FREE_CATALOG_CURATED_AT = "2026-08-20";
|
||||
|
||||
export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "chatgpt-web", modelId: "gpt-5.6-luna-free", displayName: "GPT-5.6 Luna (Free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "chatgpt-web-free", tos: "caution" },
|
||||
@@ -318,6 +318,7 @@ export const FREE_MODEL_BUDGETS: FreeModelBudget[] = [
|
||||
{ provider: "opencode-zen", modelId: "opencode/north-mini-code-free", displayName: "North Mini Code (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "opencode-zen-free", tos: "caution" },
|
||||
{ provider: "opencode-zen", modelId: "opencode/nemotron-3-ultra-free", displayName: "Nemotron 3 Ultra (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-uncapped", poolKey: "opencode-zen-free", tos: "caution" },
|
||||
{ provider: "openrouter", modelId: "auto", displayName: "Auto (Best Available)", monthlyTokens: 1200000, creditTokens: 0, freeType: "recurring-daily", poolKey: "openrouter-free", tos: "caution" },
|
||||
{ provider: "openrouter", modelId: "stealth/ox-alpha", displayName: "Stealth Ox Alpha (free)", monthlyTokens: 0, creditTokens: 0, freeType: "recurring-daily", poolKey: "openrouter-free", tos: "caution" },
|
||||
{ provider: "pollinations", modelId: "openai", displayName: "OpenAI (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },
|
||||
{ provider: "pollinations", modelId: "openai-fast", displayName: "OpenAI Fast (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },
|
||||
{ provider: "pollinations", modelId: "openai-large", displayName: "OpenAI Large (Pollinations)", monthlyTokens: 0, creditTokens: 0, freeType: "keyless", poolKey: "pollinations", tos: "caution" },
|
||||
|
||||
@@ -70,6 +70,8 @@ import { togetherProvider } from "./registry/together/index.ts";
|
||||
import { cohereProvider } from "./registry/cohere/index.ts";
|
||||
import { cursorProvider, cursor_apiProvider } from "./registry/cursor/index.ts";
|
||||
import { volcengineProvider } from "./registry/volcengine/index.ts";
|
||||
import { volcengine_agent_planProvider } from "./registry/volcengine/agent-plan/index.ts";
|
||||
import { volcengine_coding_planProvider } from "./registry/volcengine/coding-plan/index.ts";
|
||||
import { freetheaiProvider } from "./registry/freetheai/index.ts";
|
||||
import { g4f_groqProvider } from "./registry/g4f-groq/index.ts";
|
||||
import { g4f_geminiProvider } from "./registry/g4f-gemini/index.ts";
|
||||
@@ -337,6 +339,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
||||
cursor: cursorProvider,
|
||||
"cursor-api": cursor_apiProvider,
|
||||
volcengine: volcengineProvider,
|
||||
"volcengine-agent-plan": volcengine_agent_planProvider,
|
||||
"volcengine-coding-plan": volcengine_coding_planProvider,
|
||||
freetheai: freetheaiProvider,
|
||||
"g4f-groq": g4f_groqProvider,
|
||||
"g4f-gemini": g4f_geminiProvider,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { RegistryEntry, RegistryModel } from "../../../shared.ts";
|
||||
|
||||
/**
|
||||
* Volcano Ark Agent Plan models.
|
||||
*
|
||||
* The Agent Plan subscription (console.volcengine.com/ark/subscription/agent-plan)
|
||||
* is served by the Plan API endpoint — `/api/plan/v3` — which differs from both the
|
||||
* standard pay-per-use API (`/api/v3`) and the Coding Plan API (`/api/coding/v3`).
|
||||
* The Plan API has NO `/models` listing endpoint (returns 404); key validation falls
|
||||
* back to a chat probe against the first model. Model IDs below verified live against
|
||||
* /api/plan/v3/chat/completions (all return 200).
|
||||
*/
|
||||
export const VOLCENGINE_AGENT_PLAN_MODELS: RegistryModel[] = [
|
||||
{
|
||||
id: "doubao-seed-evolving",
|
||||
name: "Doubao Seed Evolving (Agent Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "doubao-seed-2-1-turbo-260628",
|
||||
name: "Doubao Seed 2.1 Turbo (Agent Plan)",
|
||||
contextLength: 262144,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "doubao-seed-2-0-lite-260215",
|
||||
name: "Doubao Seed 2.0 Lite (Agent Plan)",
|
||||
contextLength: 262144,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "doubao-seed-2-0-mini-260215",
|
||||
name: "Doubao Seed 2.0 Mini (Agent Plan)",
|
||||
contextLength: 262144,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash-ga-260731",
|
||||
name: "DeepSeek V4 Flash GA (Agent Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "kimi-k3",
|
||||
name: "Kimi K3 (Agent Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "glm-5-2-260617",
|
||||
name: "GLM 5.2 (Agent Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.7-code",
|
||||
name: "Kimi K2.7 Code (Agent Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "minimax-m3",
|
||||
name: "MiniMax M3 (Agent Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-pro-260425",
|
||||
name: "DeepSeek V4 Pro (Agent Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "minimax-m2.7",
|
||||
name: "MiniMax M2.7 (Agent Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.6",
|
||||
name: "Kimi K2.6 (Agent Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const volcengine_agent_planProvider: RegistryEntry = {
|
||||
id: "volcengine-agent-plan",
|
||||
alias: "veap",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/plan/v3/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: VOLCENGINE_AGENT_PLAN_MODELS,
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { RegistryEntry, RegistryModel } from "../../../shared.ts";
|
||||
|
||||
/**
|
||||
* Volcano Ark Coding Plan models.
|
||||
*
|
||||
* The Coding Plan subscription (console.volcengine.com/ark/subscription/coding-plan)
|
||||
* is served by a DEDICATED endpoint — `/api/coding/v3` — which differs from both the
|
||||
* standard pay-per-use API (`/api/v3`) and the Agent Plan API (`/api/plan/v3`). Using
|
||||
* the wrong base URL returns HTTP 401 "The API key or AK/SK ... is missing or invalid"
|
||||
* even with a valid Coding Plan key. Model IDs below verified live against
|
||||
* /api/coding/v3/chat/completions (all return 200).
|
||||
*/
|
||||
export const VOLCENGINE_CODING_PLAN_MODELS: RegistryModel[] = [
|
||||
{
|
||||
id: "doubao-seed-2-1-turbo",
|
||||
name: "Doubao Seed 2.1 Turbo (Coding Plan)",
|
||||
contextLength: 262144,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "doubao-seed-2.0-lite",
|
||||
name: "Doubao Seed 2.0 Lite (Coding Plan)",
|
||||
contextLength: 262144,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-flash",
|
||||
name: "DeepSeek V4 Flash (Coding Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "glm-5.2",
|
||||
name: "GLM 5.2 (Coding Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.7-code",
|
||||
name: "Kimi K2.7 Code (Coding Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "minimax-m3",
|
||||
name: "MiniMax M3 (Coding Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "deepseek-v4-pro",
|
||||
name: "DeepSeek V4 Pro (Coding Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "minimax-m2.7",
|
||||
name: "MiniMax M2.7 (Coding Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
id: "kimi-k2.6",
|
||||
name: "Kimi K2.6 (Coding Plan)",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const volcengine_coding_planProvider: RegistryEntry = {
|
||||
id: "volcengine-coding-plan",
|
||||
alias: "vecp",
|
||||
format: "openai",
|
||||
executor: "default",
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions",
|
||||
authType: "apikey",
|
||||
authHeader: "bearer",
|
||||
models: VOLCENGINE_CODING_PLAN_MODELS,
|
||||
modelsUrl: "/models",
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
BaseExecutor,
|
||||
type ExecuteInput,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
injectReasoningContentForThinkingModel,
|
||||
isThinkingMessageModel,
|
||||
} from "../utils/reasoningContentInjector.ts";
|
||||
import { runWithProxyContext } from "../utils/proxyFetch.ts";
|
||||
import { runWithDirectFetchContext, runWithProxyContext } from "../utils/proxyFetch.ts";
|
||||
import { forwardOpencodeClientHeaders } from "../utils/opencodeHeaders.ts";
|
||||
import {
|
||||
type AccountProxyConfig,
|
||||
@@ -245,6 +246,17 @@ export function createMuseSparkStreamFinishNormalizer(
|
||||
};
|
||||
}
|
||||
|
||||
function isResponsesTerminalLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) return false;
|
||||
try {
|
||||
const payload = JSON.parse(trimmed.slice(5).trim()) as Record<string, unknown>;
|
||||
return payload.type === "response.completed";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class OpencodeExecutor extends BaseExecutor {
|
||||
/** Delegates to `isPremiumOpencodeModel`. Exported for testability. */
|
||||
static isPremiumModel(model: string, provider: string): boolean {
|
||||
@@ -384,24 +396,51 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = "";
|
||||
const reader = response.body.getReader();
|
||||
let closed = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async pull(controller) {
|
||||
async start(controller) {
|
||||
try {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
if (buffer.length > 0) controller.enqueue(encoder.encode(normalizer(buffer)));
|
||||
controller.close();
|
||||
return;
|
||||
while (!closed) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
buffer += decoder.decode();
|
||||
if (buffer.length > 0 && !closed) {
|
||||
controller.enqueue(encoder.encode(normalizer(buffer)));
|
||||
}
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
controller.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const normalized = normalizer(line);
|
||||
controller.enqueue(encoder.encode(normalized + "\n"));
|
||||
if (isResponsesTerminalLine(line)) {
|
||||
// OpenCode Zen sends a ping after response.completed and may keep
|
||||
// the HTTP connection alive. The Responses terminal event is
|
||||
// authoritative; do not let those post-completion pings hold Chat
|
||||
// Completions open.
|
||||
closed = true;
|
||||
void reader.cancel().catch(() => undefined);
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) controller.enqueue(encoder.encode(normalizer(line) + "\n"));
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
controller.error(err);
|
||||
}
|
||||
}
|
||||
},
|
||||
cancel(reason) {
|
||||
closed = true;
|
||||
reader.cancel(reason).catch(() => undefined);
|
||||
},
|
||||
});
|
||||
@@ -450,7 +489,10 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
// 200s ("Provider returned empty content"). Raise tiny budgets to the
|
||||
// floor before dispatch (see MUSE_SPARK_MIN_OUTPUT_TOKENS).
|
||||
if (input.body && typeof input.body === "object" && !Array.isArray(input.body)) {
|
||||
applyMuseSparkMinOutputTokens(String(input.model ?? ""), input.body as Record<string, unknown>);
|
||||
applyMuseSparkMinOutputTokens(
|
||||
String(input.model ?? ""),
|
||||
input.body as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
this.syncAccountsFromCredentials(input.credentials);
|
||||
@@ -463,7 +505,9 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
// else passes untouched: this path deliberately preserves BaseExecutor's
|
||||
// intra-URL 429 retries (no skipUpstreamRetry here).
|
||||
if (this.accounts.length === 1 && !hasProxies) {
|
||||
const single = (await super.execute(input)) as HttpExecuteResult;
|
||||
const single = (await runWithDirectFetchContext(() =>
|
||||
super.execute(input)
|
||||
)) as HttpExecuteResult;
|
||||
if (single.response.status === 400) {
|
||||
let bodyText: string | null = null;
|
||||
try {
|
||||
@@ -630,10 +674,7 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
// All accounts returned 429 (or errored) — surface the last response.
|
||||
return this.normalizeMuseSparkResponse(
|
||||
input,
|
||||
lastResult ?? (await super.execute(input))
|
||||
);
|
||||
return this.normalizeMuseSparkResponse(input, lastResult ?? (await super.execute(input)));
|
||||
} finally {
|
||||
this._requestFormat = null;
|
||||
}
|
||||
@@ -735,6 +776,18 @@ export class OpencodeExecutor extends BaseExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
// Muse's Responses endpoint rejects the short conversation fingerprint used
|
||||
// by the Chat endpoint in practice. Keep the workaround scoped to Muse.
|
||||
if (
|
||||
this._requestFormat === "openai-responses" &&
|
||||
model.startsWith("muse-spark") &&
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
headers["x-opencode-session"] || ""
|
||||
)
|
||||
) {
|
||||
headers["x-opencode-session"] = randomUUID();
|
||||
}
|
||||
|
||||
void model;
|
||||
|
||||
return headers;
|
||||
|
||||
@@ -1008,27 +1008,63 @@ async function captureViaCdp(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
function killProcessTree(child: ChildProcess | null): void {
|
||||
/**
|
||||
* Terminate a spawned browser process and all of its descendants.
|
||||
*
|
||||
* Windows uses `taskkill /pid <pid> /T /F` to walk the process tree and terminate descendants.
|
||||
* Linux/POSIX sends SIGTERM/SIGKILL to the process group (`-pid`) when detached/group leader,
|
||||
* falling back to direct child kill if the process group is unavailable.
|
||||
*/
|
||||
export function killProcessTree(
|
||||
child:
|
||||
| ChildProcess
|
||||
| { pid?: number; kill?: (signal?: NodeJS.Signals | number | string) => boolean | void }
|
||||
| null
|
||||
| undefined,
|
||||
options?: {
|
||||
platform?: string;
|
||||
processKill?: (pid: number, signal?: NodeJS.Signals | string) => void;
|
||||
spawnFn?: typeof spawn;
|
||||
}
|
||||
): void {
|
||||
if (!child?.pid) return;
|
||||
const pid = child.pid;
|
||||
// Never taskkill our own Node/pkg process or its parent (would kill the backend mid-login).
|
||||
if (pid === process.pid || (typeof process.ppid === "number" && pid === process.ppid)) {
|
||||
return;
|
||||
}
|
||||
const platform = options?.platform || process.platform;
|
||||
const processKill = options?.processKill || process.kill.bind(process);
|
||||
const spawnFn = options?.spawnFn || spawn;
|
||||
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
if (platform === "win32") {
|
||||
// /T kills only this PID's descendants — not system Chrome profiles we did not spawn.
|
||||
const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
||||
const killer = spawnFn("taskkill", ["/pid", String(pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
detached: true,
|
||||
});
|
||||
killer.unref?.();
|
||||
killer?.unref?.();
|
||||
} else {
|
||||
child.kill("SIGTERM");
|
||||
let killedGroup = false;
|
||||
try {
|
||||
processKill(-pid, "SIGTERM");
|
||||
killedGroup = true;
|
||||
} catch {
|
||||
try {
|
||||
child.kill?.("SIGTERM");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
if (killedGroup) {
|
||||
processKill(-pid, "SIGKILL");
|
||||
} else {
|
||||
child.kill?.("SIGKILL");
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -1036,7 +1072,7 @@ function killProcessTree(child: ChildProcess | null): void {
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
child.kill();
|
||||
child.kill?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -1175,12 +1211,15 @@ async function runAdobeFireflyCdpBrowser(opts: {
|
||||
// detach so a long Forter wait does not pin the Node process refcount.
|
||||
// Host job SILENT_BREAKAWAY_OK still prevents Chrome from joining the backend job
|
||||
// (that was killing/wedging VibeProxyServices on Sign in with browser).
|
||||
// On POSIX: detached creates a new process group leader so killProcessTree(-pid)
|
||||
// can terminate Chrome and all its child processes (zygote/renderer/GPU).
|
||||
const isDetached = process.platform !== "win32" || !opts.interactive;
|
||||
child = spawn(browserPath, args, {
|
||||
stdio: "ignore",
|
||||
// Interactive sign-in: show Chrome. Background warm: hide spawn console/window
|
||||
// host; headless flags already suppress the browser UI.
|
||||
windowsHide: !opts.interactive,
|
||||
detached: !opts.interactive,
|
||||
detached: isDetached,
|
||||
});
|
||||
if (!opts.interactive) {
|
||||
try {
|
||||
|
||||
@@ -52,8 +52,23 @@ export function preferAntigravityConnectionsWithStoredProject<T extends Record<s
|
||||
const projectId = (psd as Record<string, unknown>).projectId;
|
||||
return typeof projectId === "string" && projectId.trim().length > 0;
|
||||
};
|
||||
const withStoredProject = connections.filter(hasStoredProject);
|
||||
return withStoredProject.length > 0 ? withStoredProject : connections;
|
||||
// #11284: rows whose missing Cloud Code project was CONFIRMED at request
|
||||
// time (errorCode="missing_project_id") are dead weight — drop them when a
|
||||
// healthier sibling exists. When every row is confirmed missing, keep the
|
||||
// pool so the typed 422 (not an empty-selection 404) explains what to fix.
|
||||
const hasHealthySibling = (connection: T): boolean =>
|
||||
connections.some(
|
||||
(other) => other !== connection && other.errorCode !== "missing_project_id"
|
||||
);
|
||||
const candidates = connections.filter(
|
||||
(connection) =>
|
||||
connection.errorCode !== "missing_project_id" ||
|
||||
!hasHealthySibling(connection) ||
|
||||
!hasStoredProject(connection)
|
||||
);
|
||||
const withStoredProject = candidates.filter(hasStoredProject);
|
||||
if (withStoredProject.length > 0) return withStoredProject;
|
||||
return candidates.length > 0 ? candidates : connections;
|
||||
}
|
||||
|
||||
export async function persistDiscoveredAntigravityProjectId(
|
||||
|
||||
@@ -64,6 +64,11 @@ export function persistDiscoveredAntigravityProjectId(
|
||||
errorCode: null,
|
||||
lastError: null,
|
||||
lastErrorType: null,
|
||||
// #11284: a discovered project proves the account is usable again —
|
||||
// re-enable it (markAntigravityMissingCloudCodeProject may have disabled
|
||||
// it after a confirmed-missing 422).
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
providerSpecificData,
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -77,7 +82,14 @@ export function markAntigravityMissingCloudCodeProject(
|
||||
): void {
|
||||
if (!connectionId) return;
|
||||
|
||||
// #11284: a CONFIRMED missing Cloud Code project is not transient — disable
|
||||
// the row so selection rotates to healthy siblings instead of re-dispatching
|
||||
// into the same 422 every request. "unavailable" is deliberately NOT a
|
||||
// terminal status: persistDiscoveredAntigravityProjectId() re-enables the
|
||||
// account the moment a project shows up at request time.
|
||||
void updateProviderConnection(connectionId, {
|
||||
isActive: false,
|
||||
testStatus: "unavailable",
|
||||
errorCode: "missing_project_id",
|
||||
lastError:
|
||||
"Missing Google projectId for Antigravity account. Reconnect OAuth after completing Gemini Code Assist onboarding.",
|
||||
|
||||
@@ -210,12 +210,16 @@ import {
|
||||
normalizeConnectionStatus,
|
||||
hasFutureRateLimitUntil,
|
||||
getConnectionStatusQuotaCutoffReason,
|
||||
getPersistedConnectionCooldownSkipReason,
|
||||
resolvePersistedConnectionCooldownSkipReason,
|
||||
isContextOverflow400,
|
||||
isParamValidation400,
|
||||
isModelScoped400,
|
||||
} from "./combo/comboPredicates.ts";
|
||||
export {
|
||||
getConnectionStatusQuotaCutoffReason,
|
||||
getPersistedConnectionCooldownSkipReason,
|
||||
resolvePersistedConnectionCooldownSkipReason,
|
||||
isContextOverflow400,
|
||||
isParamValidation400,
|
||||
isModelScoped400,
|
||||
@@ -320,6 +324,26 @@ export {
|
||||
* peekStickyConnectionId guards against clearing an unrelated pin when the
|
||||
* failing target isn't actually the currently sticky-bound connection.
|
||||
*/
|
||||
/**
|
||||
* Connection read for the pre-dispatch persisted-cooldown gate.
|
||||
*
|
||||
* `fresh: false` (first attempt) uses the shared 5s readCache — the row was just
|
||||
* read by the surrounding target resolution, so a second uncached hit is pure cost.
|
||||
* `fresh: true` (every retry) goes straight to SQLite: during a burst a sibling
|
||||
* request routinely writes `rate_limited_until` while this attempt is sleeping out
|
||||
* its retry delay, so the cached snapshot would still say "no cooldown" — which is
|
||||
* exactly how a retry ended up dispatching into a real upstream 429 on a connection
|
||||
* the engine had already marked unavailable.
|
||||
*/
|
||||
async function readConnectionForCooldownGate(
|
||||
connectionId: string,
|
||||
fresh: boolean
|
||||
): Promise<Record<string, unknown> | null | undefined> {
|
||||
if (!fresh) return getCachedProviderConnectionById(connectionId);
|
||||
const { getProviderConnectionById } = await import("@/lib/db/providers");
|
||||
return (await getProviderConnectionById(connectionId)) as Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export function releaseStickyPinOnFailure(
|
||||
messageHash: string | null | undefined,
|
||||
failedConnectionId: string | null | undefined
|
||||
@@ -1214,6 +1238,23 @@ async function handleComboChatInner({
|
||||
}
|
||||
: { ...target, modelAbortSignal: abortControllers.get(i)!.signal };
|
||||
|
||||
// Persist the connection cooldown before dispatch. AUTH only learns
|
||||
// unavailable during credential lookup, so a burst would otherwise
|
||||
// burn max_concurrent slots on real upstream calls against a row
|
||||
// SQLite already locked until the reset.
|
||||
if (target.connectionId && !allowRateLimitedConnection) {
|
||||
const persistedSkip = await resolvePersistedConnectionCooldownSkipReason(
|
||||
target,
|
||||
(id) => readConnectionForCooldownGate(id, false),
|
||||
allowRateLimitedConnection
|
||||
);
|
||||
if (persistedSkip) {
|
||||
log.info("COMBO", persistedSkip);
|
||||
if (i > 0) fallbackCount++;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// #1731 / #1731v2: skip targets already known-exhausted this request (shared predicate).
|
||||
const exhaustedSkip = getExhaustedTargetSkipReason(
|
||||
target,
|
||||
@@ -1471,6 +1512,21 @@ async function handleComboChatInner({
|
||||
log.info("COMBO", `Client disconnected during retry delay — aborting`);
|
||||
return { ok: false, response: errorResponse(499, "Client disconnected") };
|
||||
}
|
||||
|
||||
// Retry re-check: a sibling attempt (or attempt 1) may have persisted
|
||||
// a quota cooldown while this attempt was sleeping out its retry delay
|
||||
// ("Trying model 1/7: zai/glm-5.3 (retry 1)" after "already marked
|
||||
// unavailable until …"). Reads fresh, not cached: see readConnectionForCooldownGate.
|
||||
const persistedRetrySkip = await resolvePersistedConnectionCooldownSkipReason(
|
||||
target,
|
||||
(id) => readConnectionForCooldownGate(id, true),
|
||||
allowRateLimitedConnection
|
||||
);
|
||||
if (persistedRetrySkip) {
|
||||
log.info("COMBO", persistedRetrySkip);
|
||||
if (i > 0) fallbackCount++;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
|
||||
@@ -482,6 +482,73 @@ export function getConnectionStatusQuotaCutoffReason(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-dispatch skip for a combo target whose connection is already on a
|
||||
* persisted cooldown. Combo previously only learned that from AUTH after a
|
||||
* real upstream call, so a burst could burn max_concurrent slots against a
|
||||
* connection that SQLite already marked unavailable until a future reset.
|
||||
*
|
||||
* Honours a future rateLimitedUntil regardless of testStatus, the terminal
|
||||
* statuses that must never be dispatched, and a bare `unavailable` status even
|
||||
* when no timestamp was written alongside it.
|
||||
*/
|
||||
export function getPersistedConnectionCooldownSkipReason(
|
||||
target: { modelStr: string; connectionId?: string | null },
|
||||
connection: Record<string, unknown> | null | undefined,
|
||||
allowRateLimitedConnection = false
|
||||
): string | null {
|
||||
if (allowRateLimitedConnection) return null;
|
||||
if (!target.connectionId || !connection) return null;
|
||||
if (hasFutureRateLimitUntil(connection.rateLimitedUntil)) {
|
||||
return `Skipping ${target.modelStr} — connection ${target.connectionId} has persisted cooldown until ${String(connection.rateLimitedUntil)}`;
|
||||
}
|
||||
const status = normalizeConnectionStatus(connection.testStatus);
|
||||
if (QUOTA_BLOCKING_CONNECTION_STATUSES.has(status)) {
|
||||
return `Skipping ${target.modelStr} — connection ${target.connectionId} status=${status}`;
|
||||
}
|
||||
// `unavailable` with no (or an already-expired) rateLimitedUntil still means AUTH
|
||||
// took this connection out of rotation — markAccountUnavailable() writes the status
|
||||
// before, and sometimes without, a timestamp ("Using zai account …" then a real
|
||||
// upstream 429). Without this branch the pre-skip only fired once the timestamp had
|
||||
// landed, so a burst still dispatched against a connection AUTH had already retired.
|
||||
// Lazy recovery is unaffected: clearAccountError() resets the status on first success.
|
||||
if (status === "unavailable") {
|
||||
return `Skipping ${target.modelStr} — connection ${target.connectionId} status=unavailable`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async wrapper around `getPersistedConnectionCooldownSkipReason` for the combo
|
||||
* dispatchers, which must re-check the persisted cooldown before EVERY upstream
|
||||
* attempt — not just once before the retry loop.
|
||||
*
|
||||
* The retry path is exactly where the stale-read risk lives: a sibling request in
|
||||
* the same burst can write `rate_limited_until` while this attempt is sleeping out
|
||||
* its retry delay, so the caller passes a cache-bypassing fetcher for retry > 0
|
||||
* (the readCache TTL is 5s, long enough to serve a "no cooldown" snapshot written
|
||||
* before the 429 landed).
|
||||
*
|
||||
* Kept dependency-free — the fetcher is injected, so this module stays pure and
|
||||
* unit-testable without a DB.
|
||||
*/
|
||||
export async function resolvePersistedConnectionCooldownSkipReason(
|
||||
target: { modelStr: string; connectionId?: string | null },
|
||||
fetchConnection: (id: string) => Promise<Record<string, unknown> | null | undefined>,
|
||||
allowRateLimitedConnection = false
|
||||
): Promise<string | null> {
|
||||
if (allowRateLimitedConnection) return null;
|
||||
if (!target.connectionId) return null;
|
||||
let connection: Record<string, unknown> | null | undefined;
|
||||
try {
|
||||
connection = await fetchConnection(target.connectionId);
|
||||
} catch {
|
||||
// A DB read failure must never block dispatch — fall through to the upstream call.
|
||||
return null;
|
||||
}
|
||||
return getPersistedConnectionCooldownSkipReason(target, connection, allowRateLimitedConnection);
|
||||
}
|
||||
|
||||
/** @param {string} errorText */
|
||||
export function isContextOverflow400(errorText: string | null | undefined): boolean {
|
||||
const text = String(errorText || "");
|
||||
|
||||
@@ -290,6 +290,7 @@ export function shouldProtectOriginalFirst(
|
||||
return (
|
||||
stickyStuck ||
|
||||
autoUsedExplicitRouter ||
|
||||
strategy === "auto" ||
|
||||
strategy === "quota-share" ||
|
||||
strategy === "weighted" ||
|
||||
strategy === "priority" ||
|
||||
|
||||
@@ -64,6 +64,7 @@ export function compressAggressive(
|
||||
let summarizerSavings = 0;
|
||||
let toolResultSavings = 0;
|
||||
let agingSavings = 0;
|
||||
const lastUserIdx = currentMessages.findLastIndex((m) => m.role === "user");
|
||||
|
||||
// Step 1: Tool-result compression
|
||||
try {
|
||||
@@ -110,7 +111,8 @@ export function compressAggressive(
|
||||
currentMessages,
|
||||
cfg.thresholds,
|
||||
summarizer,
|
||||
cfg.preserveSystemPrompt !== false
|
||||
cfg.preserveSystemPrompt !== false,
|
||||
lastUserIdx
|
||||
);
|
||||
agingSavings = agingResult.saved;
|
||||
currentMessages = agingResult.messages as ChatMessage[];
|
||||
@@ -121,8 +123,9 @@ export function compressAggressive(
|
||||
// Step 3: Fallback summarizer for remaining long messages
|
||||
if (cfg.summarizerEnabled) {
|
||||
try {
|
||||
currentMessages = currentMessages.map((msg) => {
|
||||
currentMessages = currentMessages.map((msg, idx) => {
|
||||
if (cfg.preserveSystemPrompt !== false && msg.role === "system") return msg;
|
||||
if (idx === lastUserIdx) return msg;
|
||||
const text = extractTextContent(msg.content);
|
||||
if (!text || COMPRESSED_MARKER_RE.test(text)) return msg;
|
||||
if (text.length <= cfg.maxTokensPerMessage * 4) return msg;
|
||||
@@ -133,7 +136,10 @@ export function compressAggressive(
|
||||
});
|
||||
if (summary && summary.length < text.length) {
|
||||
summarizerSavings += estimateTokens(text) - estimateTokens(summary);
|
||||
return setContent(msg, `[COMPRESSED:summary] ${summary}`);
|
||||
const finalSummary = COMPRESSED_MARKER_RE.test(summary)
|
||||
? summary
|
||||
: `[COMPRESSED:summary] ${summary}`;
|
||||
return setContent(msg, finalSummary);
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
@@ -153,13 +159,27 @@ export function compressAggressive(
|
||||
|
||||
if (resultStats.savingsPercent < cfg.minSavingsThreshold * 100) {
|
||||
try {
|
||||
const cavemanResult = cavemanCompress({ messages: currentMessages as unknown as Parameters<typeof cavemanCompress>[0]["messages"] });
|
||||
if (cavemanResult?.compressed && cavemanResult.stats) {
|
||||
const cavemanSavings = cavemanResult.stats.savingsPercent ?? 0;
|
||||
if (cavemanSavings > resultStats.savingsPercent) {
|
||||
currentMessages = (cavemanResult.body?.messages ?? currentMessages) as ChatMessage[];
|
||||
resultStats.compressedTokens = cavemanResult.stats.compressedTokens ?? compressedTokens;
|
||||
resultStats.savingsPercent = cavemanSavings;
|
||||
const cavemanResult = cavemanCompress(
|
||||
{
|
||||
messages: currentMessages as unknown as Parameters<typeof cavemanCompress>[0]["messages"],
|
||||
},
|
||||
{ enabled: true }
|
||||
);
|
||||
if (cavemanResult?.compressed && cavemanResult.body?.messages) {
|
||||
const rawMsgs = cavemanResult.body.messages as ChatMessage[];
|
||||
const candidateMsgs = rawMsgs.map((msg, idx) =>
|
||||
idx === lastUserIdx ? currentMessages[idx] : msg
|
||||
);
|
||||
const candidateTokens = candidateMsgs.reduce(
|
||||
(sum, m) => sum + estimateTokens(extractTextContent(m.content)),
|
||||
0
|
||||
);
|
||||
const candidateSavings =
|
||||
originalTokens > 0 ? ((originalTokens - candidateTokens) / originalTokens) * 100 : 0;
|
||||
if (candidateSavings > resultStats.savingsPercent) {
|
||||
currentMessages = candidateMsgs;
|
||||
resultStats.compressedTokens = candidateTokens;
|
||||
resultStats.savingsPercent = candidateSavings;
|
||||
resultStats.techniquesUsed.push("caveman-fallback");
|
||||
}
|
||||
}
|
||||
@@ -172,12 +192,21 @@ export function compressAggressive(
|
||||
{ messages: currentMessages },
|
||||
{ preserveSystemPrompt: cfg.preserveSystemPrompt !== false }
|
||||
);
|
||||
if (liteResult?.compressed && liteResult.stats) {
|
||||
const liteSavings = liteResult.stats.savingsPercent ?? 0;
|
||||
if (liteSavings > resultStats.savingsPercent) {
|
||||
currentMessages = (liteResult.body?.messages ?? currentMessages) as ChatMessage[];
|
||||
resultStats.compressedTokens = liteResult.stats.compressedTokens ?? compressedTokens;
|
||||
resultStats.savingsPercent = liteSavings;
|
||||
if (liteResult?.compressed && liteResult.body?.messages) {
|
||||
const rawMsgs = liteResult.body.messages as ChatMessage[];
|
||||
const candidateMsgs = rawMsgs.map((msg, idx) =>
|
||||
idx === lastUserIdx ? currentMessages[idx] : msg
|
||||
);
|
||||
const candidateTokens = candidateMsgs.reduce(
|
||||
(sum, m) => sum + estimateTokens(extractTextContent(m.content)),
|
||||
0
|
||||
);
|
||||
const candidateSavings =
|
||||
originalTokens > 0 ? ((originalTokens - candidateTokens) / originalTokens) * 100 : 0;
|
||||
if (candidateSavings > resultStats.savingsPercent) {
|
||||
currentMessages = candidateMsgs;
|
||||
resultStats.compressedTokens = candidateTokens;
|
||||
resultStats.savingsPercent = candidateSavings;
|
||||
resultStats.techniquesUsed.push("lite-fallback");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { CompressionResult } from "./types.ts";
|
||||
import type { StackedCompressionStep } from "./strategySelector.ts";
|
||||
@@ -17,9 +17,10 @@ function positiveInteger(value: string | undefined, fallback: number): number {
|
||||
function workerUrl(): URL {
|
||||
const dir = dirname(fileURLToPath(import.meta.url));
|
||||
for (const name of ["compressionWorker.js", "compressionWorker.ts"]) {
|
||||
if (existsSync(join(dir, name))) return new URL(name, import.meta.url);
|
||||
const candidate = join(dir, name);
|
||||
if (existsSync(candidate)) return pathToFileURL(candidate);
|
||||
}
|
||||
return new URL("compressionWorker.js", import.meta.url);
|
||||
return pathToFileURL(join(dir, "compressionWorker.js"));
|
||||
}
|
||||
function unchanged(body: Record<string, unknown>): CompressionResult {
|
||||
return { body, compressed: false, stats: null };
|
||||
|
||||
@@ -67,7 +67,8 @@ export function applyAging(
|
||||
messages: unknown[],
|
||||
thresholds?: AgingThresholds,
|
||||
summarizer?: Summarizer,
|
||||
preserveSystemPrompt = true
|
||||
preserveSystemPrompt = true,
|
||||
spareUserIndex?: number
|
||||
): { messages: unknown[]; saved: number } {
|
||||
const t = thresholds ?? DEFAULT_AGGRESSIVE_CONFIG.thresholds;
|
||||
const sum = summarizer ?? {
|
||||
@@ -81,6 +82,9 @@ export function applyAging(
|
||||
const typed = messages as ChatMessage[];
|
||||
if (typed.length === 0) return { messages: [], saved: 0 };
|
||||
|
||||
const lastUserIdx =
|
||||
spareUserIndex !== undefined ? spareUserIndex : typed.findLastIndex((m) => m.role === "user");
|
||||
|
||||
const totalMessages = typed.length;
|
||||
const result: ChatMessage[] = [];
|
||||
let saved = 0;
|
||||
@@ -89,7 +93,11 @@ export function applyAging(
|
||||
const msg = typed[i];
|
||||
const text = extractTextContent(msg.content);
|
||||
|
||||
if ((preserveSystemPrompt && msg.role === "system") || COMPRESSED_MARKER_RE.test(text)) {
|
||||
if (
|
||||
(preserveSystemPrompt && msg.role === "system") ||
|
||||
COMPRESSED_MARKER_RE.test(text) ||
|
||||
i === lastUserIdx
|
||||
) {
|
||||
result.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TokenExtractionConfig,
|
||||
type TokenSource,
|
||||
} from "./tokenExtractionConfig";
|
||||
import { matchesCookieDomain } from "../utils/cookieDomain";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -196,9 +197,14 @@ export class InAppLoginService extends EventEmitter {
|
||||
for (const source of tokenSources) {
|
||||
if (source.type === "cookie") {
|
||||
const domain = source.domain || undefined;
|
||||
// Exact host or dot-boundary suffix, never `includes()`: a cookie
|
||||
// from `<domain>.attacker.tld` would otherwise be captured and
|
||||
// persisted as the operator's credential. Same class CodeQL flagged
|
||||
// in volcengineConsoleAutoLogin (#860/#861); this callsite was not
|
||||
// flagged because the expected domain is config-supplied.
|
||||
const matched = cookies.find(
|
||||
(c: any) =>
|
||||
c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, "")))
|
||||
c.name === source.name && (!domain || matchesCookieDomain(c.domain, domain))
|
||||
);
|
||||
if (matched && !credentials[source.name]) {
|
||||
credentials[source.name] = matched.value;
|
||||
|
||||
@@ -26,19 +26,121 @@ export function shouldPreserveQuotaSignals(
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a day-granularity quota reset countdown ("Your quota will reset in
|
||||
* 3 days.", "Resets in 13 days") out of an upstream 429 body.
|
||||
* Parse a day-granularity quota reset countdown (\"Your quota will reset in
|
||||
* 3 days.\", \"Resets in 13 days\") out of an upstream 429 body.
|
||||
*
|
||||
* Companion to the Xh/Ym/Zs countdown parsing already handled inline by
|
||||
* `parseRetryFromErrorText` — none of those patterns match when the upstream
|
||||
* expresses the reset window in whole days rather than hours/minutes/seconds,
|
||||
* so a multi-day quota reset previously parsed to `null` and fell back to the
|
||||
* engine's ~seconds-scale default cooldown.
|
||||
*
|
||||
* Delegates to `parseIsoDateTimeResetMs` (absolute \"reset at YYYY-MM-DD HH:MM:SS\")
|
||||
* and then `parseMonthDayResetMs` (year-less \"reset at MM-DD HH:MM:SS UTC\") so
|
||||
* every absolute-reset shape an upstream uses resolves to the real wait.
|
||||
*/
|
||||
export function parseDayGranularityResetMs(msg: string, maxMs: number): number | null {
|
||||
export function parseDayGranularityResetMs(
|
||||
msg: string,
|
||||
maxMs: number,
|
||||
nowMs: number = Date.now()
|
||||
): number | null {
|
||||
const dayMatch = /reset(?:s)?\s+in\s+(\d+)\s*day(?:s)?/i.exec(msg);
|
||||
if (!dayMatch) return null;
|
||||
const days = Number.parseInt(dayMatch[1], 10);
|
||||
if (!Number.isFinite(days) || days <= 0) return null;
|
||||
return Math.min(days * 24 * 3600 * 1000, maxMs);
|
||||
if (dayMatch) {
|
||||
const days = Number.parseInt(dayMatch[1], 10);
|
||||
if (Number.isFinite(days) && days > 0) {
|
||||
return Math.min(days * 24 * 3600 * 1000, maxMs);
|
||||
}
|
||||
}
|
||||
const isoMs = parseIsoDateTimeResetMs(msg, maxMs, nowMs);
|
||||
if (isoMs !== null) return isoMs;
|
||||
return parseMonthDayResetMs(msg, maxMs, nowMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Z.AI (GLM) reports an exhausted weekly/monthly cap with a FULL absolute
|
||||
* datetime rather than a countdown:
|
||||
*
|
||||
* \"[1310][Weekly/Monthly Limit Exhausted. … Your limit will reset at
|
||||
* 2026-08-29 21:01:21]\"
|
||||
*
|
||||
* `parseRetryFromErrorText` (accountFallback.ts) has an equivalent ISO matcher,
|
||||
* but `buildWeeklyQuotaFallback` never reaches it: it calls
|
||||
* `parseDayGranularityResetMs` directly, and neither the \"reset in N days\" nor
|
||||
* the year-less MM-DD parser matched this shape. The weekly fallback therefore
|
||||
* fell back to WEEKLY_QUOTA_COOLDOWN_MS (24h) and the connection was dispatched
|
||||
* again — into a real upstream 429 — every day until the true reset ~6 days out.
|
||||
*
|
||||
* The datetime may use a `T` or a space separator, and may carry `Z` or a
|
||||
* `±HH:MM` offset. A NAIVE datetime (no zone) is interpreted as UTC: Z.AI
|
||||
* reports in UTC, and treating it as local time would shift the cooldown by the
|
||||
* host offset. Returns null when the instant is not in the future.
|
||||
*/
|
||||
export function parseIsoDateTimeResetMs(
|
||||
msg: string,
|
||||
maxMs: number,
|
||||
nowMs: number = Date.now()
|
||||
): number | null {
|
||||
const match =
|
||||
/\b(?:try again at|wait until|reset(?:s)?\s+at|available at|retry after)\s+(\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?)\s*(Z|[+-]\d{2}:?\d{2})?/i.exec(
|
||||
msg
|
||||
);
|
||||
if (!match) return null;
|
||||
const stamp = match[1].replace(/[Tt ]/, "T");
|
||||
// No zone in the body → UTC (see doc comment). Normalize \"+0200\" to \"+02:00\":
|
||||
// the bare-offset form is not part of the ES Date.parse grammar.
|
||||
const rawZone = match[2] ? match[2].toUpperCase() : "Z";
|
||||
const zone = /^[+-]\d{4}$/.test(rawZone)
|
||||
? `${rawZone.slice(0, 3)}:${rawZone.slice(3)}`
|
||||
: rawZone;
|
||||
const resetMs = Date.parse(`${stamp}${zone}`);
|
||||
if (!Number.isFinite(resetMs)) return null;
|
||||
const waitMs = resetMs - nowMs;
|
||||
if (waitMs <= 0) return null;
|
||||
return Math.min(waitMs, maxMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Qwen token-plan (and similar apikey providers) report the weekly reset as
|
||||
* \"The quota will reset at 08-29 15:29:00 UTC\" without a year. Treat that as
|
||||
* the next occurrence of MM-DD HH:MM[:SS] UTC; if the date already passed this
|
||||
* year, roll to next year. Returns null when the parsed instant is not in the
|
||||
* future or the wait would exceed maxMs.
|
||||
*/
|
||||
export function parseMonthDayResetMs(
|
||||
msg: string,
|
||||
maxMs: number,
|
||||
nowMs: number = Date.now()
|
||||
): number | null {
|
||||
const match =
|
||||
/reset(?:s)?\s+at\s+(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::(\d{2}))?\s*(?:UTC|Z)?/i.exec(
|
||||
msg
|
||||
);
|
||||
if (!match) return null;
|
||||
const month = Number.parseInt(match[1], 10);
|
||||
const day = Number.parseInt(match[2], 10);
|
||||
const hour = Number.parseInt(match[3], 10);
|
||||
const minute = Number.parseInt(match[4], 10);
|
||||
const second = match[5] ? Number.parseInt(match[5], 10) : 0;
|
||||
if (
|
||||
month < 1 ||
|
||||
month > 12 ||
|
||||
day < 1 ||
|
||||
day > 31 ||
|
||||
hour > 23 ||
|
||||
minute > 59 ||
|
||||
second > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const now = new Date(nowMs);
|
||||
let year = now.getUTCFullYear();
|
||||
let resetMs = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
if (!Number.isFinite(resetMs)) return null;
|
||||
if (resetMs <= nowMs) {
|
||||
year += 1;
|
||||
resetMs = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
}
|
||||
const waitMs = resetMs - nowMs;
|
||||
if (!Number.isFinite(waitMs) || waitMs <= 0) return null;
|
||||
return Math.min(waitMs, maxMs);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { RateLimitReason } from "../config/constants.ts";
|
||||
import { parseDayGranularityResetMs } from "./quotaResetParsing.ts";
|
||||
|
||||
type RateLimitReasonValue = (typeof RateLimitReason)[keyof typeof RateLimitReason];
|
||||
|
||||
@@ -97,16 +98,29 @@ export function isWeeklyUsageLimitText(lower: string): boolean {
|
||||
return (
|
||||
lower.includes("weekly usage limit") ||
|
||||
lower.includes("weekly limit reached") ||
|
||||
lower.includes("reached your weekly")
|
||||
lower.includes("reached your weekly") ||
|
||||
lower.includes("1-week quota") ||
|
||||
lower.includes("week quota") ||
|
||||
lower.includes("weekly/monthly limit") ||
|
||||
(lower.includes("weekly") && lower.includes("quota") && lower.includes("exhaust"))
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_WEEKLY_QUOTA_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export function buildWeeklyQuotaFallback(errorStr: string): QuotaTextFallback | null {
|
||||
if (!isWeeklyUsageLimitText(errorStr.toLowerCase())) return null;
|
||||
const parsedResetMs = parseDayGranularityResetMs(errorStr, MAX_WEEKLY_QUOTA_COOLDOWN_MS);
|
||||
const cooldownMs =
|
||||
typeof parsedResetMs === "number" && parsedResetMs > 0
|
||||
? parsedResetMs
|
||||
: WEEKLY_QUOTA_COOLDOWN_MS;
|
||||
return {
|
||||
shouldFallback: true,
|
||||
cooldownMs: WEEKLY_QUOTA_COOLDOWN_MS,
|
||||
cooldownMs,
|
||||
reason: RateLimitReason.QUOTA_EXHAUSTED,
|
||||
usedUpstreamRetryHint: typeof parsedResetMs === "number" && parsedResetMs > 0,
|
||||
quotaResetHintMs: typeof parsedResetMs === "number" && parsedResetMs > 0 ? parsedResetMs : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ const MAX_CONVERSATION_AFFINITY_ENTRIES = 1000;
|
||||
* Task routing is additive: other strategies are wholly unaffected.
|
||||
*/
|
||||
export function isTaskRoutingStrategy(strategy: unknown): boolean {
|
||||
return ["smart", "task", "task-aware", "task_aware", "auto"].includes(
|
||||
return ["smart", "task", "task-aware", "task_aware"].includes(
|
||||
String(strategy ?? "").toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,6 +185,26 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [
|
||||
{ cookieDomain: ".chat.qwen.ai" }
|
||||
),
|
||||
|
||||
// ── Volcano Engine Ark Console ───────────────────────────
|
||||
config(
|
||||
"volcengine-console",
|
||||
"Volcano Engine Ark Console",
|
||||
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan",
|
||||
"https://console.volcengine.com",
|
||||
[
|
||||
{ type: "cookie", name: "digest", domain: ".volcengine.com" },
|
||||
{ type: "cookie", name: "AccountID", domain: ".volcengine.com" },
|
||||
{ type: "cookie", name: "csrfToken", domain: ".volcengine.com" },
|
||||
{ type: "cookie", name: "userInfo", domain: ".volcengine.com" },
|
||||
],
|
||||
"Log in to the Volcano Engine Ark console. The console session is used to discover Agent/Coding Plan API keys and live quota usage.",
|
||||
{
|
||||
cookieDomain: ".volcengine.com",
|
||||
successUrlPattern: /console\.volcengine\.com\/ark/i,
|
||||
pollingConfig: { timeout: 300_000, minLoginTime: 3000 },
|
||||
}
|
||||
),
|
||||
|
||||
// ── Kimi Web ──────────────────────────────────────────────
|
||||
config(
|
||||
"kimi-web",
|
||||
|
||||
@@ -68,6 +68,7 @@ import { getXaiUsage } from "./usage/xai.ts";
|
||||
import { getXaiOauthUsage } from "./usage/xaiOauth.ts";
|
||||
import { getGrokCliUsage } from "./usage/grokCli.ts";
|
||||
import { getFirecrawlUsage } from "./usage/firecrawl.ts";
|
||||
import { getVolcenginePlanUsage } from "./usage/volcenginePlan.ts";
|
||||
import { getCommandCodeUsage } from "./usage/command-code.ts";
|
||||
import { getQwenTokenPlanUsage } from "./usage/qwen-token-plan.ts";
|
||||
import { getConolUsage } from "./conolUsage.ts";
|
||||
@@ -135,6 +136,9 @@ export const USAGE_FETCHER_PROVIDERS = [
|
||||
"ha",
|
||||
// Firecrawl team credits (GET /v2/team/credit-usage)
|
||||
"firecrawl",
|
||||
// Volcano Ark Plan subscriptions (agent-plan / coding-plan)
|
||||
"volcengine-agent-plan",
|
||||
"volcengine-coding-plan",
|
||||
// Command Code credits + 5h/weekly windows (GET /alpha/billing/credits)
|
||||
"command-code",
|
||||
"conol-web",
|
||||
@@ -242,6 +246,9 @@ export async function getUsageForProvider(
|
||||
return await getHyperAgentUsage(apiKey || accessToken, providerSpecificData);
|
||||
case "firecrawl":
|
||||
return await getFirecrawlUsage(id || "", apiKey, connection);
|
||||
case "volcengine-agent-plan":
|
||||
case "volcengine-coding-plan":
|
||||
return await getVolcenginePlanUsage(apiKey || "", provider, providerSpecificData);
|
||||
case "command-code":
|
||||
return await getCommandCodeUsage(apiKey || accessToken || "");
|
||||
case "conol-web":
|
||||
|
||||
@@ -155,15 +155,30 @@ export async function getGlmUsage(apiKey: string, providerSpecificData?: Record<
|
||||
const resetMs = toNumber(src.nextResetTime, 0);
|
||||
const resetAt = resetMs > 0 ? new Date(resetMs).toISOString() : null;
|
||||
|
||||
if (type === "TOKENS_LIMIT") {
|
||||
// Z.ai coding-plan keys (CREDIT-based, e.g. GLM Coding Max/Lite) report
|
||||
// CREDIT_LIMIT rows with the same unit/number semantics as TOKENS_LIMIT
|
||||
// (unit=3/number=5 → 5-hour window, unit=6/number=1 → weekly). Without
|
||||
// this branch every CREDIT_LIMIT row is dropped and the quota card
|
||||
// renders empty for subscription keys.
|
||||
if (type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT") {
|
||||
const quotaName = getGlmTokenQuotaName(src, quotas);
|
||||
const usedPercent = toPercentage(src.percentage);
|
||||
const remaining = Math.max(0, 100 - usedPercent);
|
||||
|
||||
// CREDIT_LIMIT rows (z.ai coding-plan keys) carry absolute credits on
|
||||
// top of the percentage: usage = window total, currentValue = consumed,
|
||||
// remaining = credits left. Prefer them so the quota card renders
|
||||
// "3341 / 28000" like z.ai's own dashboard instead of a percent-only
|
||||
// scale. TOKENS_LIMIT rows without absolute fields keep the percent path.
|
||||
const totalCredits = toNumber(src.usage, 0);
|
||||
const usedCredits = totalCredits > 0 ? toNumber(src.currentValue, usedPercent) : usedPercent;
|
||||
const remainingCredits = totalCredits > 0 ? toNumber(src.remaining, remaining) : remaining;
|
||||
const total = totalCredits > 0 ? totalCredits : 100;
|
||||
|
||||
quotas[quotaName] = {
|
||||
used: usedPercent,
|
||||
total: 100,
|
||||
remaining,
|
||||
used: usedCredits,
|
||||
total,
|
||||
remaining: remainingCredits,
|
||||
remainingPercentage: remaining,
|
||||
resetAt,
|
||||
displayName: getGlmQuotaDisplayName(quotaName),
|
||||
|
||||
317
open-sse/services/usage/volcenginePlan.ts
Normal file
317
open-sse/services/usage/volcenginePlan.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* usage/volcenginePlan.ts — Volcano Ark Plan usage fetcher.
|
||||
*
|
||||
* Volcano Engine Ark serves the two subscription plans on DISTINCT chat base URLs:
|
||||
* - Agent Plan → https://ark.cn-beijing.volces.com/api/plan/v3
|
||||
* - Coding Plan → https://ark.cn-beijing.volces.com/api/coding/v3
|
||||
* (both differ from the standard pay-per-use API at /api/v3).
|
||||
*
|
||||
* The data-plane API exposes NO quota/usage endpoint. Real subscription usage
|
||||
* lives behind the Ark console's authenticated "top" API, which is keyed by the
|
||||
* browser session cookie (+ CSRF token), NOT the ark- API key:
|
||||
* - Coding Plan → POST /api/top/ark/cn-beijing/2024-01-01/GetCodingPlanUsage
|
||||
* - Agent Plan → POST /api/top/ark/cn-beijing/2024-01-01/GetAgentPlanAFPUsage
|
||||
*
|
||||
* When the connection carries a console cookie in providerSpecificData
|
||||
* (`volcConsoleCookie` + `volcCsrfToken`), we fetch the real quota windows and
|
||||
* map them into OmniRoute's UsageQuota shape. Without a cookie we fall back to a
|
||||
* data-plane connectivity probe (validates the key, no quota numbers).
|
||||
*/
|
||||
|
||||
import { toRecord, toNumber } from "./scalars.ts";
|
||||
import { type UsageQuota } from "./quota.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const AGENT_PLAN_BASE_URL = "https://ark.cn-beijing.volces.com/api/plan/v3";
|
||||
const CODING_PLAN_BASE_URL = "https://ark.cn-beijing.volces.com/api/coding/v3";
|
||||
|
||||
const CONSOLE_TOP_BASE = "https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01";
|
||||
|
||||
// First model probed for the Agent Plan chat-based validation (no /models endpoint).
|
||||
const AGENT_PLAN_PROBE_MODEL = "doubao-seed-2-0-pro-260215";
|
||||
|
||||
const CONSOLE_HINT_AGENT = "console.volcengine.com/ark → 订阅 Agent Plan";
|
||||
const CONSOLE_HINT_CODING = "console.volcengine.com/ark → 订阅 Coding Plan";
|
||||
|
||||
function getPlanName(provider: string): string {
|
||||
if (provider === "volcengine-agent-plan") return "Volcano Ark Agent Plan";
|
||||
if (provider === "volcengine-coding-plan") return "Volcano Ark Coding Plan";
|
||||
return "Volcano Ark Plan";
|
||||
}
|
||||
|
||||
function getBaseUrl(provider: string, providerSpecificData?: JsonRecord): string {
|
||||
const override = providerSpecificData?.arkPlanBaseUrl;
|
||||
if (typeof override === "string" && override.trim()) return override.trim().replace(/\/+$/, "");
|
||||
if (provider === "volcengine-coding-plan") return CODING_PLAN_BASE_URL;
|
||||
return AGENT_PLAN_BASE_URL;
|
||||
}
|
||||
|
||||
// ── Console cookie helpers ──────────────────────────────────────────────────
|
||||
|
||||
function getConsoleCookie(providerSpecificData?: JsonRecord): string {
|
||||
const cookie = providerSpecificData?.volcConsoleCookie;
|
||||
return typeof cookie === "string" ? cookie.trim() : "";
|
||||
}
|
||||
|
||||
function getConsoleCsrf(providerSpecificData?: JsonRecord, cookie = ""): string {
|
||||
const explicit = providerSpecificData?.volcCsrfToken;
|
||||
if (typeof explicit === "string" && explicit.trim()) return explicit.trim();
|
||||
// Fall back to the csrfToken embedded in the cookie string.
|
||||
const match = cookie.match(/csrfToken=([^;]+)/);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
async function callConsoleApi(
|
||||
action: string,
|
||||
cookie: string,
|
||||
csrf: string,
|
||||
referer: string
|
||||
): Promise<{ ok: boolean; status: number; json: JsonRecord; error?: string }> {
|
||||
const response = await fetch(`${CONSOLE_TOP_BASE}/${action}?`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"content-type": "application/json",
|
||||
cookie,
|
||||
origin: "https://console.volcengine.com",
|
||||
referer,
|
||||
"x-csrf-token": csrf,
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
const text = await response.text();
|
||||
let json: JsonRecord = {};
|
||||
try {
|
||||
json = toRecord(JSON.parse(text));
|
||||
} catch {
|
||||
/* non-JSON */
|
||||
}
|
||||
const err = toRecord(toRecord(json.ResponseMetadata).Error);
|
||||
const errMsg = typeof err.Message === "string" ? err.Message : "";
|
||||
return { ok: response.ok && !errMsg, status: response.status, json, error: errMsg };
|
||||
}
|
||||
|
||||
// ── Console usage → UsageQuota mapping ───────────────────────────────────────
|
||||
|
||||
function tsToIso(seconds: number): string | null {
|
||||
if (!seconds || seconds <= 0) return null;
|
||||
const ms = seconds < 1e12 ? seconds * 1000 : seconds;
|
||||
const d = new Date(ms);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
const CODING_WINDOW_LABEL: Record<string, string> = {
|
||||
session: "Session (5h)",
|
||||
weekly: "Weekly",
|
||||
monthly: "Monthly",
|
||||
daily: "Daily",
|
||||
};
|
||||
|
||||
/**
|
||||
* Map GetCodingPlanUsage → quotas. Coding Plan reports each window as a used
|
||||
* `Percent` (0-100) against `Cap` (100), so remaining = Cap - Percent.
|
||||
*/
|
||||
function mapCodingPlanUsage(result: JsonRecord): Record<string, UsageQuota> {
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
const windows = Array.isArray(result.QuotaUsage) ? result.QuotaUsage : [];
|
||||
for (const raw of windows) {
|
||||
const w = toRecord(raw);
|
||||
const level = String(w.Level || "").toLowerCase();
|
||||
if (!level) continue;
|
||||
const cap = toNumber(w.Cap, 100) || 100;
|
||||
const usedPercent = toNumber(w.Percent, 0);
|
||||
const remainingPercentage = Math.max(0, Math.min(100, cap - usedPercent));
|
||||
quotas[level] = {
|
||||
used: usedPercent,
|
||||
total: cap,
|
||||
remaining: Math.max(0, cap - usedPercent),
|
||||
remainingPercentage,
|
||||
resetAt: tsToIso(toNumber(w.ResetTimestamp, 0)),
|
||||
unlimited: false,
|
||||
displayName: CODING_WINDOW_LABEL[level] || level,
|
||||
};
|
||||
}
|
||||
return quotas;
|
||||
}
|
||||
|
||||
const AGENT_WINDOW_LABEL: Array<[string, string]> = [
|
||||
["AFPFiveHour", "Session (5h)"],
|
||||
["AFPDaily", "Daily"],
|
||||
["AFPWeekly", "Weekly"],
|
||||
["AFPMonthly", "Monthly"],
|
||||
];
|
||||
|
||||
/**
|
||||
* Map GetAgentPlanAFPUsage → quotas. Agent Plan reports absolute `Quota`/`Used`
|
||||
* (AFP credits) per window with a millisecond `ResetTime`.
|
||||
*/
|
||||
function mapAgentPlanUsage(result: JsonRecord): Record<string, UsageQuota> {
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
for (const [key, label] of AGENT_WINDOW_LABEL) {
|
||||
const w = toRecord(result[key]);
|
||||
if (Object.keys(w).length === 0) continue;
|
||||
const total = toNumber(w.Quota, 0);
|
||||
const used = toNumber(w.Used, 0);
|
||||
const remaining = Math.max(0, total - used);
|
||||
const remainingPercentage =
|
||||
total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 100;
|
||||
const resetMs = toNumber(w.ResetTime, 0);
|
||||
quotas[key] = {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage,
|
||||
// Agent Plan ResetTime is in milliseconds already.
|
||||
resetAt: tsToIso(resetMs >= 1e12 ? resetMs / 1000 : resetMs),
|
||||
unlimited: false,
|
||||
displayName: label,
|
||||
};
|
||||
}
|
||||
return quotas;
|
||||
}
|
||||
|
||||
// ── Data-plane connectivity probes (fallback, no cookie) ─────────────────────
|
||||
|
||||
function parseArkError(json: unknown): { code: string; message: string } | null {
|
||||
const data = toRecord(json);
|
||||
const error = toRecord(data.error);
|
||||
if (!error.code && !error.message && !data.message) return null;
|
||||
return {
|
||||
code: String(error.code || ""),
|
||||
message: String(error.message || data.message || ""),
|
||||
};
|
||||
}
|
||||
|
||||
function authErrorMessage(planName: string, status: number, errorMsg: string): string {
|
||||
if (status === 401) {
|
||||
const isFormatError = /format.*incorrect|incorrect.*format/i.test(errorMsg);
|
||||
return isFormatError
|
||||
? `Invalid API key format. ${planName} keys start with 'ark-'. Check your subscription key.`
|
||||
: `Invalid API key or the key does not belong to a ${planName} subscription.`;
|
||||
}
|
||||
if (status === 403) {
|
||||
return `Access denied. Ensure your key has an active ${planName} subscription.`;
|
||||
}
|
||||
return `${planName} API error (${status}): ${errorMsg}`;
|
||||
}
|
||||
|
||||
async function reportError(response: Response, responseText: string, planName: string) {
|
||||
let data: unknown = null;
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
const arkError = parseArkError(data);
|
||||
return {
|
||||
plan: planName,
|
||||
message: authErrorMessage(
|
||||
planName,
|
||||
response.status,
|
||||
arkError?.message || responseText.slice(0, 200)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Coding Plan: validate via the working /models listing endpoint. */
|
||||
async function probeCodingPlan(baseUrl: string, apiKey: string, planName: string) {
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) return reportError(response, responseText, planName);
|
||||
return {
|
||||
plan: planName,
|
||||
message: `${planName} connected. Add your console cookie (volcConsoleCookie) to view live quota, or check ${CONSOLE_HINT_CODING}.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Agent Plan: no /models endpoint — validate via a minimal chat probe. */
|
||||
async function probeAgentPlan(baseUrl: string, apiKey: string, planName: string) {
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: AGENT_PLAN_PROBE_MODEL,
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
max_tokens: 1,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) return reportError(response, responseText, planName);
|
||||
return {
|
||||
plan: planName,
|
||||
message: `${planName} connected. Add your console cookie (volcConsoleCookie) to view live quota, or check ${CONSOLE_HINT_AGENT}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getVolcenginePlanUsage(
|
||||
apiKey: string,
|
||||
provider: string,
|
||||
providerSpecificData?: JsonRecord
|
||||
) {
|
||||
const planName = getPlanName(provider);
|
||||
const isCoding = provider === "volcengine-coding-plan";
|
||||
|
||||
// Preferred path: real usage via the authenticated console "top" API.
|
||||
const cookie = getConsoleCookie(providerSpecificData);
|
||||
if (cookie) {
|
||||
const csrf = getConsoleCsrf(providerSpecificData, cookie);
|
||||
const action = isCoding ? "GetCodingPlanUsage" : "GetAgentPlanAFPUsage";
|
||||
const referer = isCoding
|
||||
? "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan"
|
||||
: "https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan";
|
||||
try {
|
||||
const { ok, status, json, error } = await callConsoleApi(action, cookie, csrf, referer);
|
||||
if (ok) {
|
||||
const result = toRecord(json.Result);
|
||||
const quotas = isCoding ? mapCodingPlanUsage(result) : mapAgentPlanUsage(result);
|
||||
if (Object.keys(quotas).length > 0) {
|
||||
const planType = typeof result.PlanType === "string" ? ` (${result.PlanType})` : "";
|
||||
return { plan: `${planName}${planType}`, quotas };
|
||||
}
|
||||
return {
|
||||
plan: planName,
|
||||
message: `${planName} connected. No active quota windows reported.`,
|
||||
};
|
||||
}
|
||||
// Cookie present but console call failed (expired session / no subscription).
|
||||
if (status === 401 || status === 403 || /login|unauthor|登录|鉴权/i.test(error || "")) {
|
||||
return {
|
||||
plan: planName,
|
||||
message: `Console session expired. Refresh volcConsoleCookie to view live quota.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
plan: planName,
|
||||
message: `${planName}: console usage unavailable${error ? ` (${error})` : ""}.`,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
plan: planName,
|
||||
message: `${planName} — unable to reach the Ark console: ${(err as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: data-plane connectivity probe (needs the ark- API key).
|
||||
if (!apiKey) {
|
||||
return { message: "API key not available. Add an Ark Plan API key to view usage." };
|
||||
}
|
||||
const baseUrl = getBaseUrl(provider, providerSpecificData);
|
||||
try {
|
||||
return isCoding
|
||||
? await probeCodingPlan(baseUrl, apiKey, planName)
|
||||
: await probeAgentPlan(baseUrl, apiKey, planName);
|
||||
} catch (error) {
|
||||
return {
|
||||
plan: planName,
|
||||
message: `${planName} — unable to reach the Ark API: ${(error as Error).message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
995
open-sse/services/volcengineConsoleAutoLogin.ts
Normal file
995
open-sse/services/volcengineConsoleAutoLogin.ts
Normal file
@@ -0,0 +1,995 @@
|
||||
/**
|
||||
* VolcengineConsoleAutoLogin — session-based phone/SMS-code login for the
|
||||
* Volcano Engine console.
|
||||
*
|
||||
* Unlike InAppLoginService (which opens a headful browser and requires the
|
||||
* operator to complete login inside a browser on the server machine), this
|
||||
* service drives a headless Chromium through the console's 手机号登录 (phone +
|
||||
* SMS verification code) flow:
|
||||
*
|
||||
* 1. startLogin(phone) — navigate to the login page, switch to the phone
|
||||
* tab, fill the phone number, click 获取验证码. If the console demands an
|
||||
* image captcha, a screenshot is captured for the dashboard to render.
|
||||
* 2. submitCode(code, captcha?) — fill the SMS code (and image captcha when
|
||||
* requested), click 登录 / 注册, then poll the browser context for the
|
||||
* console session cookies (digest / AccountID / csrfToken / userInfo).
|
||||
* 3. cancel() / resendCode() — lifecycle helpers.
|
||||
*
|
||||
* The service only extracts credentials; persisting/binding them to provider
|
||||
* connections stays in the dashboard API layer (volcenginePlanBinding.ts).
|
||||
*
|
||||
* Selector strategy: the console login page is built with Arco Design and
|
||||
* exposes stable element ids (#Tel_input, #Code_input, #VerificatonCodeInput).
|
||||
* Every interaction goes through multi-candidate selector lists so a single
|
||||
* frontend rename does not break the flow. When a candidate list misses or
|
||||
* risk-control (slider) is detected, the session degrades to
|
||||
* `fallback_manual` and the caller can fall back to the pre-existing
|
||||
* headful-browser flow.
|
||||
*/
|
||||
|
||||
import { randomUUID } from "crypto";
|
||||
import { matchesCookieDomain } from "../utils/cookieDomain";
|
||||
|
||||
// ─── Public types ───────────────────────────────────────────────────────────
|
||||
|
||||
export type VolcLoginPhase =
|
||||
| "starting"
|
||||
| "sending_code"
|
||||
| "waiting_code"
|
||||
| "captcha_required"
|
||||
| "submitting"
|
||||
| "mfa_waiting"
|
||||
| "identity_required"
|
||||
| "success"
|
||||
| "error"
|
||||
| "timeout"
|
||||
| "cancelled"
|
||||
| "fallback_manual";
|
||||
|
||||
export interface VolcLoginSessionView {
|
||||
sessionId: string;
|
||||
phase: VolcLoginPhase;
|
||||
phoneMasked: string;
|
||||
error: string | null;
|
||||
/** data:image/png;base64 screenshot of the image captcha, when required */
|
||||
captchaImage: string | null;
|
||||
/** epoch ms — earliest time a resend should be offered */
|
||||
resendAvailableAt: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
/** True while the console demands an MFA step-up code (second SMS code) */
|
||||
mfaRequired?: boolean;
|
||||
/** Identity options scraped from /auth/login/select_identity, when required */
|
||||
identityOptions?: Array<{ index: number; label: string }>;
|
||||
/** Credentials (console cookies) — only present after success */
|
||||
credentials?: Record<string, string>;
|
||||
/** Set by the API layer after binding plans (not part of this service) */
|
||||
binding?: unknown;
|
||||
}
|
||||
|
||||
export interface StartOptions {
|
||||
/** Total session timeout in ms (default 300_000) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface SubmitCodeOptions {
|
||||
/** Extra wait for cookie polling after submit (default 90_000) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/** Injectable delays — tests shrink these to keep the suite fast. */
|
||||
export interface ServiceDelays {
|
||||
pageSettleMs?: number;
|
||||
tabSwitchMs?: number;
|
||||
sendCodeSettleMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
resendCooldownMs?: number;
|
||||
}
|
||||
|
||||
// ─── Config ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const LOGIN_URL = "https://console.volcengine.com/auth/login";
|
||||
/** Landing page the manual headful flow uses — the console app issues the
|
||||
* remaining session cookies (AccountID/userInfo) once it runs. */
|
||||
const ARK_CONSOLE_URL =
|
||||
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan";
|
||||
|
||||
/** Cookie names required for a valid console session (mirrors tokenExtractionConfig) */
|
||||
const REQUIRED_COOKIES = ["digest", "AccountID", "csrfToken", "userInfo"] as const;
|
||||
|
||||
const DEFAULT_SESSION_TIMEOUT = 300_000;
|
||||
const SUBMIT_COOKIE_TIMEOUT = 90_000;
|
||||
const CAPTURE_POLL_INTERVAL = 1_000;
|
||||
const RESEND_COOLDOWN_MS = 60_000;
|
||||
const MAX_ACTIVE_SESSIONS = 2;
|
||||
|
||||
/** Multi-candidate selectors — first visible candidate wins. */
|
||||
const SELECTORS = {
|
||||
phoneTab: ['.arco-tabs-header-title:has-text("手机号登录")', "text=手机号登录"],
|
||||
phoneInput: ["#Tel_input", 'input[name="Tel"]', 'input[placeholder*="手机号"]'],
|
||||
smsCodeInput: ["#Code_input", 'input[placeholder*="请输入验证码"]'],
|
||||
sendCodeButton: ['button:has-text("获取验证码")', "text=获取验证码"],
|
||||
loginButton: ['button:has-text("登录 / 注册")', 'button:has-text("登录")'],
|
||||
imageCaptchaInput: ["#VerificatonCodeInput", "input.verify-input"],
|
||||
captchaShot: [".arco-modal", '[class*="captcha"]', '[class*="verify"]'],
|
||||
/** Risk-control slider / popup heuristics */
|
||||
riskControl: [
|
||||
'[class*="secsdk-captcha"]',
|
||||
"#captcha_popup",
|
||||
'[class*="captcha-slider"]',
|
||||
'[class*="drag"] [class*="slider"]',
|
||||
],
|
||||
/** MFA step-up modal (需要额外认证): a SECOND 6-digit SMS code is required */
|
||||
mfaModal: ['.arco-modal:has-text("需要额外认证")', "text=需要额外认证"],
|
||||
mfaInput: ["#VerificatonCodeInput", ".arco-modal input.verify-input", ".arco-modal input"],
|
||||
mfaConfirmButton: ['button:has-text("好的")', '.arco-modal button:has-text("确定")'],
|
||||
mfaResendButton: ['button:has-text("重发校验码")'],
|
||||
/** TOTP binding modal (绑定MFA设备) — needs interactive Google Authenticator setup */
|
||||
mfaBindModal: ['.arco-modal:has-text("绑定MFA设备")'],
|
||||
/** Identity selection page (/auth/login/select_identity) — the phone maps to
|
||||
* multiple accounts; the user must pick which identity to log in as.
|
||||
* Structure verified against the real auth bundle (vconsole-auth 1.0.0.2837,
|
||||
* module 12173 + chunk 202): ul[class*=accountUl] > li[class*=accountLi] >
|
||||
* div[class*=item] (click target) with the identity text in [class*=identity];
|
||||
* submit is button[type=submit] ("登录") inside [class*=selectPlatformIdentity].
|
||||
* .arco-list-item is kept as a fallback for future Arco-based redesigns. */
|
||||
identityList: ['ul[class*="accountUl"] li[class*="accountLi"]', ".arco-list-item"],
|
||||
identityItem: ['li[class*="accountLi"] > [class*="item"]', ".arco-list-item"],
|
||||
identitySubmitButton: [
|
||||
'[class*="selectPlatformIdentity"] button[type="submit"]',
|
||||
'button[type="submit"]:has-text("登录")',
|
||||
'button:has-text("登录")',
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** URL marker for the console's identity-selection page */
|
||||
const IDENTITY_URL_PATTERN = /\/auth\/login\/select_identity/i;
|
||||
|
||||
const BROWSER_CONTEXT_OPTIONS = {
|
||||
locale: "zh-CN",
|
||||
timezoneId: "Asia/Shanghai",
|
||||
viewport: { width: 1280, height: 800 },
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
};
|
||||
|
||||
// ─── Minimal playwright structural types ──────────────────────────────────
|
||||
// Playwright is an optional runtime dep (dynamically imported), so we model
|
||||
// only the API surface this service drives instead of importing its types.
|
||||
|
||||
interface PwLocator {
|
||||
first(): PwLocator;
|
||||
isVisible(options?: { timeout?: number }): Promise<boolean>;
|
||||
click(options?: unknown): Promise<void>;
|
||||
fill(value: string): Promise<void>;
|
||||
isDisabled(): Promise<boolean>;
|
||||
screenshot(options?: { type?: string }): Promise<Buffer>;
|
||||
textContent(options?: { timeout?: number }): Promise<string | null>;
|
||||
count(): Promise<number>;
|
||||
nth(index: number): PwLocator;
|
||||
}
|
||||
|
||||
interface PwPage {
|
||||
setDefaultTimeout(timeout: number): void;
|
||||
goto(url: string, options?: { waitUntil?: string; timeout?: number }): Promise<unknown>;
|
||||
locator(selector: string): PwLocator;
|
||||
screenshot(options?: { type?: string }): Promise<Buffer>;
|
||||
url(): string;
|
||||
content(): Promise<string>;
|
||||
}
|
||||
|
||||
interface PwContext {
|
||||
newPage(): Promise<PwPage>;
|
||||
cookies(): Promise<Array<{ name: string; domain: string; value: string }>>;
|
||||
}
|
||||
|
||||
interface PwBrowser {
|
||||
newContext(options?: Record<string, unknown>): Promise<PwContext>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PwModule {
|
||||
chromium: {
|
||||
launch(options?: { headless?: boolean; args?: string[]; channel?: string }): Promise<PwBrowser>;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Session record (internal) ──────────────────────────────────────────────
|
||||
|
||||
interface ActiveSession {
|
||||
sessionId: string;
|
||||
phone: string;
|
||||
phase: VolcLoginPhase;
|
||||
error: string | null;
|
||||
captchaImage: string | null;
|
||||
resendAvailableAt: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
timeoutMs: number;
|
||||
credentials: Record<string, string> | null;
|
||||
/** Binding outcome set by the API layer via withBinding() */
|
||||
binding?: unknown;
|
||||
cancelled: boolean;
|
||||
/** Identity options scraped from the select_identity page */
|
||||
identityOptions: Array<{ index: number; label: string }> | null;
|
||||
// Playwright handles — never serialized
|
||||
browser: PwBrowser | null;
|
||||
context: PwContext | null;
|
||||
page: PwPage | null;
|
||||
}
|
||||
|
||||
export function maskPhone(phone: string): string {
|
||||
if (phone.length < 7) return "***";
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Normalize a CN mobile number: strip +86/86 prefix, spaces, dashes. */
|
||||
export function normalizePhone(raw: string): string | null {
|
||||
const trimmed = String(raw || "")
|
||||
.trim()
|
||||
.replace(/[\s-]/g, "");
|
||||
const bare = trimmed.replace(/^\+?86/, "");
|
||||
return /^1\d{10}$/.test(bare) ? bare : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cookie's `domain` belongs to the Volcengine console.
|
||||
*
|
||||
* Cookie domains must be matched by exact host or dot-boundary suffix, never by
|
||||
* substring: `domain.includes("volcengine.com")` also accepted
|
||||
* `volcengine.com.attacker.tld` and `notvolcengine.com`, so a cookie named
|
||||
* `digest`/`AccountID`/`csrfToken`/`userInfo` set by a look-alike host was
|
||||
* harvested as an operator credential and persisted as a provider connection
|
||||
* (CodeQL js/incomplete-url-substring-sanitization #860/#861). Mirrors
|
||||
* `isAdobeCookieDomain` in adobeFireflyBrowserLogin.ts.
|
||||
*/
|
||||
export function isVolcengineCookieDomain(domain: string | undefined): boolean {
|
||||
return matchesCookieDomain(domain, "volcengine.com");
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ─── Service ────────────────────────────────────────────────────────────────
|
||||
|
||||
export class VolcengineConsoleAutoLoginService {
|
||||
private sessions = new Map<string, ActiveSession>();
|
||||
/** sessionId → bind promise set by the API layer to dedupe lazy binding */
|
||||
private bindInFlight = new Map<string, Promise<unknown>>();
|
||||
/** Injectable for tests — resolves the playwright module instead of `import("playwright")`. */
|
||||
private readonly loadPlaywright: () => Promise<PwModule>;
|
||||
private readonly delays: Required<ServiceDelays>;
|
||||
|
||||
constructor(
|
||||
loadPlaywright: () => Promise<PwModule> = async () => import("playwright"),
|
||||
delays: ServiceDelays = {}
|
||||
) {
|
||||
this.loadPlaywright = loadPlaywright;
|
||||
this.delays = {
|
||||
pageSettleMs: delays.pageSettleMs ?? 2_500,
|
||||
tabSwitchMs: delays.tabSwitchMs ?? 1_000,
|
||||
sendCodeSettleMs: delays.sendCodeSettleMs ?? 2_000,
|
||||
pollIntervalMs: delays.pollIntervalMs ?? CAPTURE_POLL_INTERVAL,
|
||||
resendCooldownMs: delays.resendCooldownMs ?? RESEND_COOLDOWN_MS,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Queries ─────────────────────────────────────────────────────────────
|
||||
|
||||
getActiveSessionCount(): number {
|
||||
let count = 0;
|
||||
for (const session of this.sessions.values()) {
|
||||
if (!isTerminal(session.phase)) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
getStatus(sessionId: string): VolcLoginSessionView | null {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lazy binding hook used by the API layer: the route stores a promise here
|
||||
* so concurrent status polls do not double-bind the same credentials.
|
||||
*/
|
||||
async withBinding<T>(
|
||||
sessionId: string,
|
||||
bind: (credentials: Record<string, string>) => Promise<T>
|
||||
): Promise<VolcLoginSessionView | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
if (session.phase !== "success" || !session.credentials) {
|
||||
return this.toView(session);
|
||||
}
|
||||
if (session.binding !== undefined) return this.toView(session);
|
||||
|
||||
let inFlight = this.bindInFlight.get(sessionId);
|
||||
if (!inFlight) {
|
||||
inFlight = bind(session.credentials)
|
||||
.then((binding: unknown) => {
|
||||
session.binding = binding;
|
||||
return binding;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
// Persist the failure so status polls do not retry forever.
|
||||
session.binding = { error: errorMessage(error) };
|
||||
return session.binding;
|
||||
})
|
||||
.finally(() => {
|
||||
this.bindInFlight.delete(sessionId);
|
||||
});
|
||||
this.bindInFlight.set(sessionId, inFlight);
|
||||
}
|
||||
await inFlight;
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────────────────────────
|
||||
|
||||
async startLogin(
|
||||
phone: string,
|
||||
options?: StartOptions
|
||||
): Promise<{ ok: true; session: VolcLoginSessionView } | { ok: false; error: string }> {
|
||||
const normalized = normalizePhone(phone);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "Invalid phone number (expected an 11-digit CN mobile number)" };
|
||||
}
|
||||
|
||||
this.expireSessions();
|
||||
|
||||
for (const session of this.sessions.values()) {
|
||||
if (session.phone === normalized && !isTerminal(session.phase)) {
|
||||
await this.cancel(session.sessionId);
|
||||
}
|
||||
}
|
||||
if (this.getActiveSessionCount() >= MAX_ACTIVE_SESSIONS) {
|
||||
return { ok: false, error: "Too many concurrent Volcano login sessions" };
|
||||
}
|
||||
|
||||
let playwright: PwModule;
|
||||
try {
|
||||
playwright = await this.loadPlaywright();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Playwright is not installed. Use manual browser login instead.",
|
||||
};
|
||||
}
|
||||
|
||||
const session: ActiveSession = {
|
||||
sessionId: randomUUID(),
|
||||
phone: normalized,
|
||||
phase: "starting",
|
||||
error: null,
|
||||
captchaImage: null,
|
||||
resendAvailableAt: 0,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
timeoutMs: options?.timeout || DEFAULT_SESSION_TIMEOUT,
|
||||
credentials: null,
|
||||
cancelled: false,
|
||||
identityOptions: null,
|
||||
browser: null,
|
||||
context: null,
|
||||
page: null,
|
||||
};
|
||||
this.sessions.set(session.sessionId, session);
|
||||
|
||||
try {
|
||||
// Prefer the playwright-managed Chromium; fall back to the system Chrome
|
||||
// channel on machines without `npx playwright install` browsers (dev laptops).
|
||||
try {
|
||||
session.browser = await playwright.chromium.launch({
|
||||
headless: true,
|
||||
args: ["--disable-blink-features=AutomationControlled"],
|
||||
});
|
||||
} catch (launchError) {
|
||||
if (!/Executable doesn't exist/.test(String(launchError))) throw launchError;
|
||||
session.browser = await playwright.chromium.launch({
|
||||
headless: true,
|
||||
channel: "chrome",
|
||||
args: ["--disable-blink-features=AutomationControlled"],
|
||||
});
|
||||
}
|
||||
session.context = await session.browser.newContext(BROWSER_CONTEXT_OPTIONS);
|
||||
session.page = await session.context.newPage();
|
||||
session.page.setDefaultTimeout(15_000);
|
||||
|
||||
await session.page.goto(LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||
await sleep(this.delays.pageSettleMs);
|
||||
|
||||
// Switch to the phone-code login tab
|
||||
const tab = await this.firstVisible(session.page, SELECTORS.phoneTab);
|
||||
if (!tab) throw new SelectorMissError("phone tab");
|
||||
await tab.click();
|
||||
await sleep(this.delays.tabSwitchMs);
|
||||
|
||||
// Fill the phone number
|
||||
const phoneInput = await this.firstVisible(session.page, SELECTORS.phoneInput);
|
||||
if (!phoneInput) throw new SelectorMissError("phone input");
|
||||
await phoneInput.fill(normalized);
|
||||
|
||||
// Send the SMS code
|
||||
const sendBtn = await this.firstVisible(session.page, SELECTORS.sendCodeButton);
|
||||
if (!sendBtn) throw new SelectorMissError("send-code button");
|
||||
await sendBtn.click();
|
||||
|
||||
session.phase = "sending_code";
|
||||
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
|
||||
await sleep(this.delays.sendCodeSettleMs);
|
||||
|
||||
// Risk-control slider → degrade to the manual headful flow
|
||||
const risk = await this.firstVisible(session.page, SELECTORS.riskControl);
|
||||
if (risk) {
|
||||
session.captchaImage = await this.shot(session.page);
|
||||
session.phase = "fallback_manual";
|
||||
session.error =
|
||||
"Volcano risk control (slider captcha) was triggered in headless mode. Use manual browser login.";
|
||||
await this.closeBrowser(session);
|
||||
return { ok: true, session: this.toView(session) };
|
||||
}
|
||||
|
||||
// Image captcha may be required before the SMS is sent
|
||||
const captchaInput = await this.firstVisible(session.page, SELECTORS.imageCaptchaInput);
|
||||
if (captchaInput) {
|
||||
session.captchaImage = await this.shot(session.page);
|
||||
session.phase = "captcha_required";
|
||||
} else {
|
||||
session.phase = "waiting_code";
|
||||
}
|
||||
return { ok: true, session: this.toView(session) };
|
||||
} catch (error) {
|
||||
await this.closeBrowser(session);
|
||||
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
|
||||
session.error = errorMessage(error);
|
||||
if (session.phase === "fallback_manual") {
|
||||
session.error = `${session.error}. The login page layout may have changed — use manual browser login.`;
|
||||
}
|
||||
return { ok: true, session: this.toView(session) };
|
||||
}
|
||||
}
|
||||
|
||||
async submitCode(
|
||||
sessionId: string,
|
||||
code: string,
|
||||
captcha?: string,
|
||||
options?: SubmitCodeOptions
|
||||
): Promise<VolcLoginSessionView | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
const fromMfa = session.phase === "mfa_waiting";
|
||||
if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) {
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
const smsCode = String(code || "").trim();
|
||||
if (!/^\d{4,6}$/.test(smsCode)) {
|
||||
session.error = "Invalid SMS code";
|
||||
return this.toView(session);
|
||||
}
|
||||
if (session.phase === "captcha_required" && !String(captcha || "").trim()) {
|
||||
session.error = "Image captcha is required";
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
const page = session.page;
|
||||
if (!page) {
|
||||
session.phase = "error";
|
||||
session.error = "Browser session is gone — restart the login";
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
try {
|
||||
if (fromMfa) {
|
||||
// MFA step-up (需要额外认证): fill the SECOND code into the modal
|
||||
// input and confirm with 好的.
|
||||
const mfaInput = await this.firstVisible(page, SELECTORS.mfaInput);
|
||||
if (!mfaInput) throw new SelectorMissError("mfa code input");
|
||||
await mfaInput.fill(smsCode);
|
||||
|
||||
const confirmBtn = await this.firstVisible(page, SELECTORS.mfaConfirmButton);
|
||||
if (!confirmBtn) throw new SelectorMissError("mfa confirm button");
|
||||
await confirmBtn.click();
|
||||
} else {
|
||||
const codeInput = await this.firstVisible(page, SELECTORS.smsCodeInput);
|
||||
if (!codeInput) throw new SelectorMissError("sms code input");
|
||||
await codeInput.fill(smsCode);
|
||||
|
||||
if (captcha) {
|
||||
const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput);
|
||||
if (captchaInput) await captchaInput.fill(String(captcha).trim());
|
||||
}
|
||||
|
||||
const loginBtn = await this.firstVisible(page, SELECTORS.loginButton);
|
||||
if (!loginBtn) throw new SelectorMissError("login button");
|
||||
await loginBtn.click();
|
||||
}
|
||||
|
||||
session.phase = "submitting";
|
||||
session.error = null;
|
||||
session.captchaImage = null;
|
||||
|
||||
return await this.pollUntilResolved(session, {
|
||||
timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT,
|
||||
fromMfa,
|
||||
detectIdentity: true,
|
||||
});
|
||||
} catch (error) {
|
||||
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
|
||||
session.error = errorMessage(error);
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick an identity on the console's /auth/login/select_identity page and
|
||||
* finish the login. `index` maps to the identityOptions list previously
|
||||
* returned in the session view.
|
||||
*/
|
||||
async selectIdentity(
|
||||
sessionId: string,
|
||||
index: number,
|
||||
options?: SubmitCodeOptions
|
||||
): Promise<VolcLoginSessionView | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
if (session.phase !== "identity_required") {
|
||||
return this.toView(session);
|
||||
}
|
||||
const page = session.page;
|
||||
if (!page) {
|
||||
session.phase = "error";
|
||||
session.error = "Browser session is gone — restart the login";
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
try {
|
||||
// Click the requested identity card (the page pre-selects the first one,
|
||||
// so only non-zero indexes need an explicit click).
|
||||
if (index > 0) {
|
||||
const itemSelector = await this.identityItemSelector(page);
|
||||
if (!itemSelector) throw new SelectorMissError("identity item");
|
||||
const items = page.locator(itemSelector);
|
||||
const count = await items.count();
|
||||
if (index < 0 || index >= count) {
|
||||
session.error = `Identity index ${index} is out of range (${count} options)`;
|
||||
return this.toView(session);
|
||||
}
|
||||
await items.nth(index).click();
|
||||
await sleep(this.delays.tabSwitchMs);
|
||||
}
|
||||
|
||||
// Submit the selection (button[type=submit] “登录” on the identity card)
|
||||
const submitBtn = await this.firstVisible(page, SELECTORS.identitySubmitButton);
|
||||
if (!submitBtn) throw new SelectorMissError("identity submit button");
|
||||
await submitBtn.click();
|
||||
|
||||
session.phase = "submitting";
|
||||
session.error = null;
|
||||
session.identityOptions = null;
|
||||
|
||||
return await this.pollUntilResolved(session, {
|
||||
timeoutMs: options?.timeout || SUBMIT_COOKIE_TIMEOUT,
|
||||
fromMfa: false,
|
||||
detectIdentity: false,
|
||||
});
|
||||
} catch (error) {
|
||||
session.phase = error instanceof SelectorMissError ? "fallback_manual" : "error";
|
||||
session.error = errorMessage(error);
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
}
|
||||
|
||||
/** First clickable identity-item selector that matches at least one element. */
|
||||
private async identityItemSelector(page: PwPage): Promise<string | null> {
|
||||
for (const selector of SELECTORS.identityItem) {
|
||||
try {
|
||||
const count = await page.locator(selector).count();
|
||||
if (count > 0) return selector;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared post-submit loop: waits for console cookies, watching for MFA
|
||||
* step-up, identity selection, TOTP binding, and console error toasts.
|
||||
*/
|
||||
private async pollUntilResolved(
|
||||
session: ActiveSession,
|
||||
opts: { timeoutMs: number; fromMfa: boolean; detectIdentity: boolean }
|
||||
): Promise<VolcLoginSessionView> {
|
||||
const page = session.page;
|
||||
if (!page) {
|
||||
session.phase = "error";
|
||||
session.error = "Browser session is gone — restart the login";
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
const deadline = Date.now() + opts.timeoutMs;
|
||||
let pollCount = 0;
|
||||
let navigatedAfterLogin = false;
|
||||
while (Date.now() < deadline) {
|
||||
if (session.cancelled) {
|
||||
session.phase = "cancelled";
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
if (Date.now() - session.createdAt > session.timeoutMs) {
|
||||
session.phase = "timeout";
|
||||
session.error = "Login timed out";
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
const cookies = await session.context.cookies();
|
||||
const credentials: Record<string, string> = {};
|
||||
for (const cookie of cookies as Array<{ name: string; domain: string; value: string }>) {
|
||||
if (
|
||||
REQUIRED_COOKIES.includes(cookie.name as (typeof REQUIRED_COOKIES)[number]) &&
|
||||
isVolcengineCookieDomain(cookie.domain)
|
||||
) {
|
||||
credentials[cookie.name] = cookie.value;
|
||||
}
|
||||
}
|
||||
if (REQUIRED_COOKIES.every((name) => credentials[name])) {
|
||||
session.credentials = credentials;
|
||||
session.phase = "success";
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
// TOTP binding modal (绑定MFA设备) — needs interactive Google
|
||||
// Authenticator setup that cannot be driven headlessly.
|
||||
const bindModal = await this.firstVisible(page, SELECTORS.mfaBindModal);
|
||||
if (bindModal) {
|
||||
session.phase = "fallback_manual";
|
||||
session.error =
|
||||
"The console requires binding an MFA device (Google Authenticator). Use manual browser login to complete the one-time setup.";
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
// MFA step-up modal (需要额外认证) — a second SMS code is required;
|
||||
// hand control back to the user instead of timing out.
|
||||
if (!opts.fromMfa) {
|
||||
const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal);
|
||||
if (mfaModal) {
|
||||
session.phase = "mfa_waiting";
|
||||
session.error = null;
|
||||
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
|
||||
return this.toView(session);
|
||||
}
|
||||
} else if (pollCount >= 5) {
|
||||
// Wrong MFA code → the modal stays up; after a grace window hand
|
||||
// control back so the user can enter the latest code.
|
||||
const mfaModal = await this.firstVisible(page, SELECTORS.mfaModal);
|
||||
if (mfaModal) {
|
||||
session.phase = "mfa_waiting";
|
||||
session.error = "The MFA code was not accepted — enter the latest code";
|
||||
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
|
||||
return this.toView(session);
|
||||
}
|
||||
}
|
||||
|
||||
// Identity selection page (/auth/login/select_identity) — the phone
|
||||
// maps to multiple accounts; scrape the options and let the user pick.
|
||||
if (opts.detectIdentity && IDENTITY_URL_PATTERN.test(page.url())) {
|
||||
const options = await this.scrapeIdentityOptions(page);
|
||||
if (options.length > 0) {
|
||||
session.phase = "identity_required";
|
||||
session.error = null;
|
||||
session.identityOptions = options;
|
||||
return this.toView(session);
|
||||
}
|
||||
}
|
||||
|
||||
// Login redirected away from /auth/login but cookies are incomplete →
|
||||
// the console app may need to run once to issue AccountID/userInfo.
|
||||
// Give it the same landing page the manual flow uses.
|
||||
if (!navigatedAfterLogin && pollCount >= 2 && !page.url().includes("/auth/login")) {
|
||||
navigatedAfterLogin = true;
|
||||
try {
|
||||
await page.goto(ARK_CONSOLE_URL, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30_000,
|
||||
});
|
||||
} catch {
|
||||
// navigation is best-effort; keep polling cookies
|
||||
}
|
||||
}
|
||||
|
||||
// Console error toast (e.g. wrong SMS code) → surface it early
|
||||
const toast = await page
|
||||
.locator('.arco-message-error, [class*="message-error"]')
|
||||
.first()
|
||||
.textContent({ timeout: 250 })
|
||||
.catch(() => null);
|
||||
if (toast && /验证码|密码|错误|失败|频繁/.test(toast)) {
|
||||
session.phase = "error";
|
||||
session.error = toast.trim().slice(0, 120);
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
await sleep(this.delays.pollIntervalMs);
|
||||
pollCount++;
|
||||
}
|
||||
|
||||
session.phase = "timeout";
|
||||
session.error = await this.timeoutDiagnostics(session);
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
/** First identity-list selector that matches at least one element. */
|
||||
private async identityListSelector(page: PwPage): Promise<string | null> {
|
||||
for (const selector of SELECTORS.identityList) {
|
||||
try {
|
||||
const count = await page.locator(selector).count();
|
||||
if (count > 0) return selector;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Scrape identity options from the select_identity page, in document order. */
|
||||
private async scrapeIdentityOptions(
|
||||
page: PwPage
|
||||
): Promise<Array<{ index: number; label: string }>> {
|
||||
const selector = await this.identityListSelector(page);
|
||||
if (!selector) return [];
|
||||
const items = page.locator(selector);
|
||||
const count = await items.count();
|
||||
const options: Array<{ index: number; label: string }> = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text =
|
||||
(await items
|
||||
.nth(i)
|
||||
.textContent()
|
||||
.catch(() => "")) || "";
|
||||
const label = text.replace(/\s+/g, " ").trim();
|
||||
if (label) options.push({ index: i, label: label.slice(0, 100) });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a diagnostic message for the cookie-poll timeout: page URL, cookies
|
||||
* collected so far, and any blocking modal. Keeps future debugging cheap.
|
||||
* When stuck on the identity-selection page, also dumps the page HTML to
|
||||
* /tmp so a selector miss can be fixed from ground truth in one shot.
|
||||
*/
|
||||
private async timeoutDiagnostics(session: ActiveSession): Promise<string> {
|
||||
const parts = ["Timed out waiting for the console session cookies"];
|
||||
try {
|
||||
if (session.page) {
|
||||
parts.push(`url=${session.page.url()}`);
|
||||
const cookies = (await session.context.cookies()) as Array<{
|
||||
name: string;
|
||||
domain: string;
|
||||
}>;
|
||||
const present = REQUIRED_COOKIES.filter((name) =>
|
||||
cookies.some((c) => c.name === name && isVolcengineCookieDomain(c.domain))
|
||||
);
|
||||
parts.push(
|
||||
`cookies=[${present.join(",") || "none of digest/AccountID/csrfToken/userInfo"}]`
|
||||
);
|
||||
const bindModal = await this.firstVisible(session.page, SELECTORS.mfaBindModal);
|
||||
if (bindModal) parts.push("blocked by 绑定MFA设备 modal");
|
||||
const mfaModal = await this.firstVisible(session.page, SELECTORS.mfaModal);
|
||||
if (mfaModal) parts.push("blocked by 需要额外认证 modal");
|
||||
const risk = await this.firstVisible(session.page, SELECTORS.riskControl);
|
||||
if (risk) parts.push("blocked by risk-control slider");
|
||||
if (IDENTITY_URL_PATTERN.test(session.page.url())) {
|
||||
const dump = await this.dumpPageHtml(session);
|
||||
if (dump) parts.push(`identityPageHtml=${dump}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// diagnostics are best-effort
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Best-effort page HTML dump for debugging selector misses. */
|
||||
private async dumpPageHtml(session: ActiveSession): Promise<string | null> {
|
||||
try {
|
||||
const { writeFile } = await import("fs/promises");
|
||||
const path = `/tmp/omniroute-volc-select-identity-${session.sessionId.slice(0, 8)}.html`;
|
||||
await writeFile(path, await session.page.content(), "utf8");
|
||||
return path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async resendCode(sessionId: string): Promise<VolcLoginSessionView | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
const fromMfa = session.phase === "mfa_waiting";
|
||||
if (session.phase !== "waiting_code" && session.phase !== "captcha_required" && !fromMfa) {
|
||||
return this.toView(session);
|
||||
}
|
||||
if (Date.now() < session.resendAvailableAt) {
|
||||
return this.toView(session);
|
||||
}
|
||||
const page = session.page;
|
||||
if (!page) {
|
||||
session.phase = "error";
|
||||
session.error = "Browser session is gone — restart the login";
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
try {
|
||||
// In the MFA step-up modal the button is 重发校验码; on the login form
|
||||
// it counts down ("60s后重发" etc.) — try the fresh label first, then
|
||||
// any 重发/重新获取 variant.
|
||||
const resendSelectors = fromMfa
|
||||
? [...SELECTORS.mfaResendButton]
|
||||
: [
|
||||
'button:has-text("获取验证码")',
|
||||
'button:has-text("重发")',
|
||||
'button:has-text("重新获取")',
|
||||
'button:has-text("重新发送")',
|
||||
];
|
||||
const btn = await this.firstVisible(page, resendSelectors);
|
||||
if (!btn) throw new SelectorMissError("resend button");
|
||||
const disabled = await btn.isDisabled().catch(() => false);
|
||||
if (disabled) {
|
||||
session.error = "Resend is still cooling down on the login page";
|
||||
return this.toView(session);
|
||||
}
|
||||
await btn.click();
|
||||
session.resendAvailableAt = Date.now() + this.delays.resendCooldownMs;
|
||||
await sleep(this.delays.sendCodeSettleMs);
|
||||
|
||||
if (fromMfa) {
|
||||
// Stay in mfa_waiting — the modal persists until a valid code lands.
|
||||
session.phase = "mfa_waiting";
|
||||
session.error = null;
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
const captchaInput = await this.firstVisible(page, SELECTORS.imageCaptchaInput);
|
||||
if (captchaInput) {
|
||||
session.captchaImage = await this.shot(page);
|
||||
session.phase = "captcha_required";
|
||||
} else {
|
||||
session.captchaImage = null;
|
||||
session.phase = "waiting_code";
|
||||
}
|
||||
session.error = null;
|
||||
return this.toView(session);
|
||||
} catch (error) {
|
||||
session.phase = "error";
|
||||
session.error = errorMessage(error);
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(sessionId: string): Promise<VolcLoginSessionView | null> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return null;
|
||||
if (isTerminal(session.phase)) return this.toView(session);
|
||||
session.cancelled = true;
|
||||
session.phase = "cancelled";
|
||||
await this.closeBrowser(session);
|
||||
return this.toView(session);
|
||||
}
|
||||
|
||||
// ─── Internals ───────────────────────────────────────────────────────────
|
||||
|
||||
private toView(session: ActiveSession): VolcLoginSessionView {
|
||||
const view: VolcLoginSessionView = {
|
||||
sessionId: session.sessionId,
|
||||
phase: session.phase,
|
||||
phoneMasked: maskPhone(session.phone),
|
||||
error: session.error,
|
||||
captchaImage: session.phase === "captcha_required" ? session.captchaImage : null,
|
||||
resendAvailableAt: session.resendAvailableAt,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
};
|
||||
if (session.phase === "mfa_waiting") view.mfaRequired = true;
|
||||
if (session.phase === "identity_required" && session.identityOptions) {
|
||||
view.identityOptions = session.identityOptions;
|
||||
}
|
||||
if (session.phase === "success" && session.credentials) view.credentials = session.credentials;
|
||||
if (session.binding !== undefined) view.binding = session.binding;
|
||||
return view;
|
||||
}
|
||||
|
||||
private async closeBrowser(session: ActiveSession): Promise<void> {
|
||||
try {
|
||||
await session.browser?.close?.();
|
||||
} catch {
|
||||
// browser may already be gone
|
||||
} finally {
|
||||
session.browser = null;
|
||||
session.context = null;
|
||||
session.page = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Screenshot for captcha rendering; null when capture fails. */
|
||||
private async shot(page: PwPage): Promise<string | null> {
|
||||
try {
|
||||
const target = await this.firstVisible(page, SELECTORS.captchaShot);
|
||||
const buffer: Buffer | null = target
|
||||
? await target.screenshot({ type: "png" })
|
||||
: await page.screenshot({ type: "png" });
|
||||
return buffer ? `data:image/png;base64,${buffer.toString("base64")}` : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async firstVisible(
|
||||
page: PwPage,
|
||||
selectors: readonly string[]
|
||||
): Promise<PwLocator | null> {
|
||||
for (const selector of selectors) {
|
||||
try {
|
||||
const locator = page.locator(selector).first();
|
||||
if (await locator.isVisible({ timeout: 2_000 })) return locator;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Close and drop sessions past their TTL; keep terminal ones briefly for status reads. */
|
||||
private expireSessions(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, session] of this.sessions) {
|
||||
const age = now - session.createdAt;
|
||||
const terminal = isTerminal(session.phase);
|
||||
if (terminal && age > 10 * 60_000) {
|
||||
this.sessions.delete(id);
|
||||
} else if (!terminal && age > session.timeoutMs + 60_000) {
|
||||
session.phase = "timeout";
|
||||
session.error = "Session expired";
|
||||
void this.closeBrowser(session);
|
||||
this.sessions.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
class SelectorMissError extends Error {
|
||||
constructor(element: string) {
|
||||
super(`Login page element not found: ${element}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminal(phase: VolcLoginPhase): boolean {
|
||||
return (
|
||||
phase === "success" ||
|
||||
phase === "error" ||
|
||||
phase === "timeout" ||
|
||||
phase === "cancelled" ||
|
||||
phase === "fallback_manual"
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// ─── Singleton ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const volcengineConsoleAutoLoginService = new VolcengineConsoleAutoLoginService();
|
||||
@@ -137,7 +137,7 @@ function convertGeminiContent(content) {
|
||||
|
||||
if (part.functionCall) {
|
||||
toolCalls.push({
|
||||
id: `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: part.functionCall.id || `call_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: part.functionCall.name,
|
||||
|
||||
@@ -1155,7 +1155,12 @@ function openaiResponsesToOpenAIResponseStream(chunk, state) {
|
||||
// Keyed by index, not insertion order — readers that need call order for
|
||||
// parallel calls closed out of order should sort by this key rather than
|
||||
// relying on Map iteration order.
|
||||
// Responses→Claude uses this same shared map for Claude block lifecycle
|
||||
// state. Preserve those fields when adding the completed-call summary;
|
||||
// replacing the entry makes the arguments chunk look like a new unnamed
|
||||
// tool and emits a duplicate empty content_block_start.
|
||||
state.toolCalls.set(currentIndex, {
|
||||
...state.toolCalls.get(currentIndex),
|
||||
id: callId,
|
||||
index: currentIndex,
|
||||
type: "function",
|
||||
|
||||
34
open-sse/utils/cookieDomain.ts
Normal file
34
open-sse/utils/cookieDomain.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Cookie-domain matching for browser-driven credential capture.
|
||||
*
|
||||
* Every in-app / console login flow harvests cookies out of a Playwright
|
||||
* context and persists them as operator credentials, so "is this cookie from
|
||||
* the site I sent the browser to?" is an authorization decision. A substring
|
||||
* test is not one: `domain.includes("example.com")` also accepts
|
||||
* `example.com.attacker.tld` and `notexample.com`, which lets a look-alike host
|
||||
* hand us cookies we then store as the operator's real credentials
|
||||
* (CodeQL js/incomplete-url-substring-sanitization).
|
||||
*
|
||||
* A cookie domain is matched by exact host or dot-boundary suffix — nothing
|
||||
* else. Leading dots (the RFC 6265 "domain-matches any subdomain" spelling) and
|
||||
* case are normalized away on both sides.
|
||||
*/
|
||||
export function matchesCookieDomain(
|
||||
cookieDomain: string | undefined,
|
||||
expectedDomain: string | undefined
|
||||
): boolean {
|
||||
const expected = normalizeCookieDomain(expectedDomain);
|
||||
if (!expected) return false;
|
||||
|
||||
const actual = normalizeCookieDomain(cookieDomain);
|
||||
if (!actual) return false;
|
||||
|
||||
return actual === expected || actual.endsWith(`.${expected}`);
|
||||
}
|
||||
|
||||
function normalizeCookieDomain(domain: string | undefined): string {
|
||||
return String(domain || "")
|
||||
.trim()
|
||||
.replace(/^\.+/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
@@ -351,10 +351,7 @@ function sanitizeTransportError(
|
||||
typeof source.code === "string" && /^[A-Z0-9_:-]{1,64}$/.test(source.code)
|
||||
? source.code
|
||||
: fallbackCode;
|
||||
if (
|
||||
typeof source.errorCode === "string" &&
|
||||
/^[a-zA-Z0-9_:-]{1,64}$/.test(source.errorCode)
|
||||
) {
|
||||
if (typeof source.errorCode === "string" && /^[a-zA-Z0-9_:-]{1,64}$/.test(source.errorCode)) {
|
||||
sanitized.errorCode = source.errorCode;
|
||||
}
|
||||
if (typeof source.statusCode === "number" && Number.isFinite(source.statusCode)) {
|
||||
@@ -547,10 +544,7 @@ export function resolveProxyForRequest(targetUrl) {
|
||||
* Dependency-internal TimeoutError/AbortError values are transport failures and
|
||||
* retain the normal safe-method fallback behavior.
|
||||
*/
|
||||
function isCallerAbort(
|
||||
_error: unknown,
|
||||
signal: AbortSignal | null | undefined
|
||||
): boolean {
|
||||
function isCallerAbort(_error: unknown, signal: AbortSignal | null | undefined): boolean {
|
||||
return signal?.aborted === true;
|
||||
}
|
||||
|
||||
@@ -573,8 +567,7 @@ export async function runWithProxyContext(
|
||||
// sentinel must remain direct without being mistaken for a proxy config.
|
||||
const currentContext = proxyContext.getStore();
|
||||
const inheritsDirect = currentContext === DIRECT_PROXY_CONTEXT && !proxyConfig;
|
||||
const effectiveProxyConfig =
|
||||
proxyConfig || (inheritsDirect ? null : currentContext) || null;
|
||||
const effectiveProxyConfig = proxyConfig || (inheritsDirect ? null : currentContext) || null;
|
||||
const contextValue = inheritsDirect ? DIRECT_PROXY_CONTEXT : effectiveProxyConfig;
|
||||
|
||||
const resolvedProxyUrl = effectiveProxyConfig ? proxyConfigToUrl(effectiveProxyConfig) : null;
|
||||
@@ -711,6 +704,11 @@ export async function runWithProxyContext(
|
||||
});
|
||||
}
|
||||
|
||||
/** Run a request with an explicit direct-egress sentinel, bypassing proxy env/context lookup. */
|
||||
export function runWithDirectFetchContext<T>(fn: () => T): T {
|
||||
return proxyContext.run(DIRECT_PROXY_CONTEXT, fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link runWithProxyContext}, but if the assigned proxy is unreachable or fails
|
||||
* its pre-checks the request can degrade to a DIRECT connection instead of throwing.
|
||||
@@ -732,6 +730,12 @@ async function patchedFetch(
|
||||
options: FetchWithDispatcherOptions = {},
|
||||
deps: ProxyFetchDeps = {}
|
||||
) {
|
||||
// Explicit direct contexts must win even when a caller supplied a stale
|
||||
// dispatcher. Native fetch preserves direct streaming semantics.
|
||||
if (proxyContext.getStore() === DIRECT_PROXY_CONTEXT) {
|
||||
return originalFetch(input, options);
|
||||
}
|
||||
|
||||
if (options?.dispatcher) {
|
||||
// When a dispatcher is present, we MUST use the undici library fetch
|
||||
// to ensure version compatibility. Node 22 built-in fetch (undici v6)
|
||||
@@ -1133,9 +1137,7 @@ async function patchedFetch(
|
||||
);
|
||||
const sanitized = sanitizeTransportError(
|
||||
error,
|
||||
originalMsg
|
||||
? `Proxy request failed: ${originalMsg}`
|
||||
: "Proxy request failed",
|
||||
originalMsg ? `Proxy request failed: ${originalMsg}` : "Proxy request failed",
|
||||
"PROXY_REQUEST_FAILED"
|
||||
);
|
||||
console.error(
|
||||
@@ -1190,8 +1192,7 @@ export async function runWithTlsTracking<T>(
|
||||
providerOrIdentityOrFn: string | null | undefined | TlsTrackingIdentity | (() => T),
|
||||
maybeFn?: () => T
|
||||
): Promise<{ result: Awaited<T>; tlsFingerprintUsed: boolean }> {
|
||||
const legacyFn =
|
||||
typeof providerOrIdentityOrFn === "function" ? providerOrIdentityOrFn : maybeFn;
|
||||
const legacyFn = typeof providerOrIdentityOrFn === "function" ? providerOrIdentityOrFn : maybeFn;
|
||||
if (typeof legacyFn !== "function") {
|
||||
throw new TypeError("runWithTlsTracking requires a callback function");
|
||||
}
|
||||
@@ -1201,8 +1202,7 @@ export async function runWithTlsTracking<T>(
|
||||
typeof providerOrIdentityOrFn !== "function"
|
||||
? providerOrIdentityOrFn
|
||||
: {
|
||||
provider:
|
||||
typeof providerOrIdentityOrFn === "string" ? providerOrIdentityOrFn : undefined,
|
||||
provider: typeof providerOrIdentityOrFn === "string" ? providerOrIdentityOrFn : undefined,
|
||||
};
|
||||
const store: TlsFingerprintStore = {
|
||||
used: false,
|
||||
@@ -1214,10 +1214,7 @@ export async function runWithTlsTracking<T>(
|
||||
}
|
||||
|
||||
/** Check whether TLS fingerprint transport is enabled for this route identity. */
|
||||
export function isTlsFingerprintActive(
|
||||
provider?: string | null,
|
||||
proxied = false
|
||||
): boolean {
|
||||
export function isTlsFingerprintActive(provider?: string | null, proxied = false): boolean {
|
||||
return (
|
||||
isTlsFingerprintEnabled() &&
|
||||
activeTlsClient.available &&
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -25588,6 +25588,17 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/libxmljs2/node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libxmljs2/node_modules/cacache": {
|
||||
"version": "19.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",
|
||||
|
||||
36
scripts/build/better-sqlite3-stub-flag.mjs
Normal file
36
scripts/build/better-sqlite3-stub-flag.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Decide whether the Next.js build should alias `better-sqlite3` to the
|
||||
* build-time stub (src/lib/db/better-sqlite3.stub.js).
|
||||
*
|
||||
* History (#11343): the alias was UNCONDITIONAL, added to keep the bundler from
|
||||
* tracing the native addon into a Next.js build worker, whose thread teardown
|
||||
* can abort with SIGABRT (assertion in node::RemoveEnvironmentCleanupHook) and
|
||||
* leave the build without standalone output (#10060).
|
||||
*
|
||||
* The premise recorded next to that alias — "runtime still uses the real
|
||||
* package via serverExternalPackages" — does not hold. A Turbopack
|
||||
* `resolveAlias` rewrites the request BEFORE the externals check runs, so
|
||||
* `better-sqlite3` becomes a relative path, no longer matches the
|
||||
* `serverExternalPackages` entry, and the stub is baked into the bundle. Every
|
||||
* artifact built from that config answered HTTP 500 on every route: the stub's
|
||||
* default export is not a constructor, the sync driver chain fell through to
|
||||
* `node:sqlite` and then sql.js, and the instrumentation hook aborted at boot.
|
||||
*
|
||||
* This is the same failure shape as #6344 (the @/mitm/manager stub shipping to
|
||||
* every npm/Electron/VPS artifact), so it gets the same treatment: the alias is
|
||||
* opt-in, and a default build gets the real, externalized native package.
|
||||
*
|
||||
* Set OMNIROUTE_BETTER_SQLITE3_STUB=1 ONLY on a build host that actually hits
|
||||
* the SIGABRT worker teardown, and never for an artifact that will be run —
|
||||
* the resulting bundle cannot open a database.
|
||||
*/
|
||||
export function shouldStubBetterSqlite3(env = process.env) {
|
||||
return env.OMNIROUTE_BETTER_SQLITE3_STUB === "1";
|
||||
}
|
||||
|
||||
/** Turbopack resolveAlias fragment for `better-sqlite3`, derived from the env. */
|
||||
export function betterSqlite3AliasFor(env = process.env) {
|
||||
return shouldStubBetterSqlite3(env)
|
||||
? { "better-sqlite3": "./src/lib/db/better-sqlite3.stub.js" }
|
||||
: {};
|
||||
}
|
||||
@@ -69,6 +69,7 @@ const files = walk(COMMANDS_DIR);
|
||||
const usedKeys = collectTKeys(files);
|
||||
const en = loadJson(join(LOCALES_DIR, "en.json"));
|
||||
const ptBR = loadJson(join(LOCALES_DIR, "pt-BR.json"));
|
||||
const zhLocales = ["zh-CN", "zh-TW"].map((n) => [n, loadJson(join(LOCALES_DIR, `${n}.json`))]);
|
||||
const enKeys = flattenKeys(en);
|
||||
|
||||
let errors = 0;
|
||||
@@ -95,6 +96,19 @@ if (missingTopLevel.length > 0) {
|
||||
console.log(`[cli-i18n] ✓ pt-BR.json has all ${enTopLevel.length} top-level sections`);
|
||||
}
|
||||
|
||||
// Check 3: zh-CN and zh-TW have full key parity with en.json
|
||||
for (const [name, cat] of zhLocales) {
|
||||
const catKeys = flattenKeys(cat);
|
||||
const missingKeys = [...enKeys].filter((k) => !catKeys.has(k));
|
||||
if (missingKeys.length > 0) {
|
||||
console.error(`[cli-i18n] Keys in en.json missing from ${name}.json:`);
|
||||
for (const k of missingKeys) console.error(` ✗ ${k}`);
|
||||
errors += missingKeys.length;
|
||||
} else {
|
||||
console.log(`[cli-i18n] ✓ ${name}.json has full parity (${enKeys.size} keys)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors > 0) {
|
||||
console.error(`[cli-i18n] FAIL — ${errors} error(s) found`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -111,6 +111,18 @@ export function classifyFragments({ fragments = [], changelog = "" }) {
|
||||
return { stale, keep };
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a stale list by how each entry was matched, for the human report line.
|
||||
* classifyFragments only ever sets matchedBy to "pr-number" (the filename convention) or
|
||||
* "text" (the normalized-bullet fallback); the summary must bucket under those exact values.
|
||||
* Pure - the two counts always add up to stale.length and never mislabel a category.
|
||||
*/
|
||||
export function summarizeStale(stale) {
|
||||
const byPrNumber = (stale || []).filter((s) => s.matchedBy === "pr-number").length;
|
||||
const byText = (stale || []).filter((s) => s.matchedBy === "text").length;
|
||||
return { byPrNumber, byText };
|
||||
}
|
||||
|
||||
export function readFragments(root) {
|
||||
const out = [];
|
||||
for (const sub of FRAGMENT_DIRS) {
|
||||
@@ -141,9 +153,8 @@ function main(argv) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const byRef = stale.filter((s) => s.matchedBy === "ref").length;
|
||||
const byText = stale.length - byRef;
|
||||
process.stdout.write(` matched by ref: ${byRef} · by text: ${byText}\n`);
|
||||
const { byPrNumber, byText } = summarizeStale(stale);
|
||||
process.stdout.write(` matched by pr-number: ${byPrNumber} · by text: ${byText}\n`);
|
||||
for (const s of stale) process.stdout.write(` ${apply ? "removed" : "stale"}: ${s.rel} — ${s.reason}\n`);
|
||||
|
||||
if (!apply) {
|
||||
|
||||
@@ -57,6 +57,7 @@ import CustomModelsSection from "./components/CustomModelsSection";
|
||||
import ConnectionsListPanel from "./components/ConnectionsListPanel";
|
||||
import CoolingConnectionsPanel from "./components/CoolingConnectionsPanel";
|
||||
import ConnectionsHeaderToolbar from "./components/ConnectionsHeaderToolbar";
|
||||
import VolcengineConnectModal from "./components/VolcengineConnectModal";
|
||||
import ProviderAccountRoutingCard from "../../settings/components/ProviderAccountRoutingCard";
|
||||
import ZedImportCard from "./components/ZedImportCard";
|
||||
import CursorAgentNudge from "./components/CursorAgentNudge";
|
||||
@@ -79,6 +80,7 @@ export default function ProviderDetailPageClient() {
|
||||
const [showOAuthModal, _setShowOAuthModal] = useState(false);
|
||||
const [reauthConnection, setReauthConnection] = useState<ConnectionRowConnection | null>(null);
|
||||
const [showKimiAuthMethodModal, setShowKimiAuthMethodModal] = useState(false);
|
||||
const [showVolcengineConnectModal, setShowVolcengineConnectModal] = useState(false);
|
||||
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
|
||||
const [showSiliconFlowEndpointModal, setShowSiliconFlowEndpointModal] = useState(false);
|
||||
const [siliconFlowInitialBaseUrl, setSiliconFlowInitialBaseUrl] = useState<string | undefined>();
|
||||
@@ -92,6 +94,7 @@ export default function ProviderDetailPageClient() {
|
||||
const [importClaudeModalOpen, setImportClaudeModalOpen] = useState(false);
|
||||
const [importGeminiModalOpen, setImportGeminiModalOpen] = useState(false);
|
||||
const [importGrokCliModalOpen, setImportGrokCliModalOpen] = useState(false);
|
||||
const [connectingVolcengineAccount, setConnectingVolcengineAccount] = useState(false);
|
||||
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
|
||||
const isCcCompatible = isClaudeCodeCompatibleProvider(providerId);
|
||||
const isCommandCode = providerId === "command-code";
|
||||
@@ -381,6 +384,43 @@ export default function ProviderDetailPageClient() {
|
||||
openApiKeyAddFlow();
|
||||
}, [providerId, isOAuth, openApiKeyAddFlow]);
|
||||
|
||||
// Legacy manual flow: headful browser login on the machine running OmniRoute.
|
||||
// Kept as the fallback for the phone/SMS auto-login modal.
|
||||
const connectVolcengineAccountManually = useCallback(async () => {
|
||||
setConnectingVolcengineAccount(true);
|
||||
try {
|
||||
const response = await fetch("/api/providers/volcengine-plan/connect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ timeout: 300_000 }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data?.success) {
|
||||
throw new Error(data?.error || "Failed to connect Volcano account");
|
||||
}
|
||||
const results = Array.isArray(data?.binding?.results) ? data.binding.results : [];
|
||||
const connected = results.filter((item: any) => item?.ok).length;
|
||||
const failed = results.filter((item: any) => item && item.ok === false && item.available);
|
||||
if (connected > 0) {
|
||||
notify.success(`Connected ${connected} Volcano plan${connected > 1 ? "s" : ""}`);
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
notify.error(
|
||||
failed.map((item: any) => `${item.plan}: ${item.error || "failed"}`).join("; ")
|
||||
);
|
||||
}
|
||||
await fetchConnections();
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to connect Volcano account");
|
||||
} finally {
|
||||
setConnectingVolcengineAccount(false);
|
||||
}
|
||||
}, [fetchConnections, notify]);
|
||||
|
||||
const connectVolcengineAccount = useCallback(() => {
|
||||
setShowVolcengineConnectModal(true);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
commandCodeAuthState,
|
||||
handleCloseAddApiKeyModal,
|
||||
@@ -595,6 +635,8 @@ export default function ProviderDetailPageClient() {
|
||||
gateConnectionFlow={gateConnectionFlow}
|
||||
openApiKeyAddFlow={openApiKeyAddFlow}
|
||||
openPrimaryAddFlow={openPrimaryAddFlow}
|
||||
connectVolcengineAccount={connectVolcengineAccount}
|
||||
connectingVolcengineAccount={connectingVolcengineAccount}
|
||||
openExternalLinkFlow={openExternalLinkFlow}
|
||||
handleOpenCommandCodeConnect={handleOpenCommandCodeConnect}
|
||||
commandCodeAuthState={commandCodeAuthState}
|
||||
@@ -868,6 +910,16 @@ export default function ProviderDetailPageClient() {
|
||||
setShowTutorialModal={setShowTutorialModal}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Volcano Engine console phone/SMS auto-login (falls back to manual browser login) */}
|
||||
<VolcengineConnectModal
|
||||
isOpen={showVolcengineConnectModal}
|
||||
onClose={() => setShowVolcengineConnectModal(false)}
|
||||
onFallbackManual={connectVolcengineAccountManually}
|
||||
onConnected={fetchConnections}
|
||||
notify={notify}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ type ConnectionsHeaderToolbarProps = {
|
||||
gateConnectionFlow: (callback: () => void) => void;
|
||||
openApiKeyAddFlow: () => void;
|
||||
openPrimaryAddFlow: () => void;
|
||||
connectVolcengineAccount?: () => void;
|
||||
connectingVolcengineAccount?: boolean;
|
||||
openExternalLinkFlow: () => void;
|
||||
handleOpenCommandCodeConnect: () => void;
|
||||
commandCodeAuthState: { phase: string };
|
||||
@@ -86,6 +88,8 @@ export default function ConnectionsHeaderToolbar({
|
||||
gateConnectionFlow,
|
||||
openApiKeyAddFlow,
|
||||
openPrimaryAddFlow,
|
||||
connectVolcengineAccount,
|
||||
connectingVolcengineAccount,
|
||||
openExternalLinkFlow,
|
||||
handleOpenCommandCodeConnect,
|
||||
commandCodeAuthState,
|
||||
@@ -303,6 +307,19 @@ export default function ConnectionsHeaderToolbar({
|
||||
<Button size="sm" icon="add" onClick={() => gateConnectionFlow(openPrimaryAddFlow)}>
|
||||
{providerSupportsPat ? providerText(t, "addPat", "Add PAT") : t("add")}
|
||||
</Button>
|
||||
{(providerId === "volcengine-agent-plan" ||
|
||||
providerId === "volcengine-coding-plan") &&
|
||||
connectVolcengineAccount && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="login"
|
||||
loading={connectingVolcengineAccount}
|
||||
onClick={() => gateConnectionFlow(connectVolcengineAccount)}
|
||||
>
|
||||
{providerText(t, "connectVolcengineAccount", "Connect Volcano Account")}
|
||||
</Button>
|
||||
)}
|
||||
{providerId === "qoder" && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Input, Modal } from "@/shared/components";
|
||||
import { providerText, type ProviderMessageTranslator } from "../providerPageHelpers";
|
||||
|
||||
/**
|
||||
* VolcengineConnectModal — phone/SMS-code login for the Volcano Engine console.
|
||||
*
|
||||
* Drives the session-based auto login API:
|
||||
* POST /api/providers/volcengine-plan/connect {phone}
|
||||
* POST /api/providers/volcengine-plan/connect/{id}/code {code, captcha?}
|
||||
* GET /api/providers/volcengine-plan/connect/{id}/status
|
||||
* POST /api/providers/volcengine-plan/connect/{id}/resend
|
||||
* POST /api/providers/volcengine-plan/connect/{id}/cancel
|
||||
*
|
||||
* Falls back to the legacy manual headful-browser flow (same POST /connect
|
||||
* endpoint without a phone) when risk control or a layout change degrades
|
||||
* the headless session.
|
||||
*/
|
||||
|
||||
type SessionPhase =
|
||||
| "starting"
|
||||
| "sending_code"
|
||||
| "waiting_code"
|
||||
| "captcha_required"
|
||||
| "submitting"
|
||||
| "mfa_waiting"
|
||||
| "identity_required"
|
||||
| "success"
|
||||
| "error"
|
||||
| "timeout"
|
||||
| "cancelled"
|
||||
| "fallback_manual";
|
||||
|
||||
interface SessionView {
|
||||
sessionId: string;
|
||||
phase: SessionPhase;
|
||||
phoneMasked: string;
|
||||
error: string | null;
|
||||
captchaImage: string | null;
|
||||
resendAvailableAt: number;
|
||||
mfaRequired?: boolean;
|
||||
identityOptions?: Array<{ index: number; label: string }>;
|
||||
binding?: {
|
||||
results?: Array<{
|
||||
plan: string;
|
||||
available: boolean;
|
||||
ok: boolean;
|
||||
error?: string | null;
|
||||
}>;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const PHONE_STORAGE_KEY = "omniroute.volcengine.phone";
|
||||
const TERMINAL_PHASES: SessionPhase[] = [
|
||||
"success",
|
||||
"error",
|
||||
"timeout",
|
||||
"cancelled",
|
||||
"fallback_manual",
|
||||
];
|
||||
|
||||
function isTerminal(phase: SessionPhase | undefined): boolean {
|
||||
return !!phase && TERMINAL_PHASES.includes(phase);
|
||||
}
|
||||
|
||||
type VolcengineConnectModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** Legacy headful-browser login (opens on the server machine) */
|
||||
onFallbackManual: () => void;
|
||||
/** Refresh connections after a successful bind */
|
||||
onConnected: () => void | Promise<void>;
|
||||
notify: {
|
||||
success: (message: string, title?: string) => void;
|
||||
error: (message: string, title?: string) => void;
|
||||
};
|
||||
t: ProviderMessageTranslator;
|
||||
};
|
||||
|
||||
export default function VolcengineConnectModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onFallbackManual,
|
||||
onConnected,
|
||||
notify,
|
||||
t,
|
||||
}: VolcengineConnectModalProps) {
|
||||
const [phone, setPhone] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [captcha, setCaptcha] = useState("");
|
||||
const [session, setSession] = useState<SessionView | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [submittingCode, setSubmittingCode] = useState(false);
|
||||
const [resending, setResending] = useState(false);
|
||||
const [selectingIdentity, setSelectingIdentity] = useState(false);
|
||||
const [resendCountdown, setResendCountdown] = useState(0);
|
||||
|
||||
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// ── lifecycle ────────────────────────────────────────────────────────────
|
||||
|
||||
const stopTimers = useCallback(() => {
|
||||
if (pollTimer.current) {
|
||||
clearInterval(pollTimer.current);
|
||||
pollTimer.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
stopTimers();
|
||||
setSession(null);
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
setResendCountdown(0);
|
||||
}, [stopTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Leaving the modal cancels an in-flight session server-side.
|
||||
const active = session && !isTerminal(session.phase) ? session : null;
|
||||
if (active) {
|
||||
void fetch(`/api/providers/volcengine-plan/connect/${active.sessionId}/cancel`, {
|
||||
method: "POST",
|
||||
}).catch(() => {});
|
||||
}
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const saved = typeof window !== "undefined" ? localStorage.getItem(PHONE_STORAGE_KEY) : null;
|
||||
if (saved) setPhone(saved);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => stopTimers, [stopTimers]);
|
||||
|
||||
// resend countdown ticker
|
||||
const resendAvailableAt = session?.resendAvailableAt ?? 0;
|
||||
const sessionId = session?.sessionId;
|
||||
const sessionPhase = session?.phase;
|
||||
useEffect(() => {
|
||||
if (!sessionId || isTerminal(sessionPhase)) return;
|
||||
const tick = () => {
|
||||
setResendCountdown(Math.max(0, Math.ceil((resendAvailableAt - Date.now()) / 1000)));
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [sessionId, sessionPhase, resendAvailableAt]);
|
||||
|
||||
// ── status polling ──────────────────────────────────────────────────────
|
||||
|
||||
const startPolling = useCallback(
|
||||
(sessionId: string) => {
|
||||
stopTimers();
|
||||
pollTimer.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${sessionId}/status`
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
if (isTerminal(data.session.phase)) {
|
||||
stopTimers();
|
||||
if (data.session.phase === "success") void onConnected();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// transient network error — keep polling until phase resolves
|
||||
}
|
||||
}, 1500);
|
||||
},
|
||||
[stopTimers, onConnected]
|
||||
);
|
||||
|
||||
// ── actions ─────────────────────────────────────────────────────────────
|
||||
|
||||
const handleStart = useCallback(async () => {
|
||||
const trimmed = phone.trim();
|
||||
if (!trimmed) return;
|
||||
setStarting(true);
|
||||
try {
|
||||
const response = await fetch("/api/providers/volcengine-plan/connect", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone: trimmed }),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !data?.success || !data?.session) {
|
||||
throw new Error(data?.error || "Failed to start Volcano login");
|
||||
}
|
||||
setSession(data.session);
|
||||
setResendCountdown(
|
||||
Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000))
|
||||
);
|
||||
localStorage.setItem(PHONE_STORAGE_KEY, trimmed);
|
||||
if (data.session.phase === "starting" || data.session.phase === "sending_code") {
|
||||
startPolling(data.session.sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to start Volcano login");
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [phone, notify, startPolling]);
|
||||
|
||||
const handleSubmitCode = useCallback(async () => {
|
||||
if (!session) return;
|
||||
setSubmittingCode(true);
|
||||
try {
|
||||
const payload: { code: string; captcha?: string } = { code: code.trim() };
|
||||
if (session.phase === "captcha_required" && captcha.trim()) {
|
||||
payload.captcha = captcha.trim();
|
||||
}
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${session.sessionId}/code`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
if (data.session.phase === "mfa_waiting") {
|
||||
// A NEW code is required for the MFA step — clear the stale input.
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
}
|
||||
if (
|
||||
data.session.phase === "starting" ||
|
||||
data.session.phase === "sending_code" ||
|
||||
data.session.phase === "submitting"
|
||||
) {
|
||||
startPolling(data.session.sessionId);
|
||||
} else if (data.session.phase === "success") {
|
||||
void onConnected();
|
||||
}
|
||||
} else {
|
||||
throw new Error(data?.error || "Failed to submit verification code");
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to submit verification code");
|
||||
} finally {
|
||||
setSubmittingCode(false);
|
||||
}
|
||||
}, [session, code, captcha, notify, startPolling, onConnected]);
|
||||
|
||||
const handleSelectIdentity = useCallback(
|
||||
async (index: number) => {
|
||||
if (!session) return;
|
||||
setSelectingIdentity(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${session.sessionId}/identity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ index }),
|
||||
}
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
if (
|
||||
data.session.phase === "starting" ||
|
||||
data.session.phase === "sending_code" ||
|
||||
data.session.phase === "submitting"
|
||||
) {
|
||||
startPolling(data.session.sessionId);
|
||||
} else if (data.session.phase === "success") {
|
||||
void onConnected();
|
||||
}
|
||||
} else {
|
||||
throw new Error(data?.error || "Failed to select identity");
|
||||
}
|
||||
} catch (error) {
|
||||
notify.error(error instanceof Error ? error.message : "Failed to select identity");
|
||||
} finally {
|
||||
setSelectingIdentity(false);
|
||||
}
|
||||
},
|
||||
[session, notify, startPolling, onConnected]
|
||||
);
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
if (!session || resendCountdown > 0) return;
|
||||
setResending(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/providers/volcengine-plan/connect/${session.sessionId}/resend`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (data?.session) {
|
||||
setSession((prev) => (prev ? { ...prev, ...data.session } : data.session));
|
||||
setResendCountdown(
|
||||
Math.max(0, Math.ceil((data.session.resendAvailableAt - Date.now()) / 1000))
|
||||
);
|
||||
setCode("");
|
||||
setCaptcha("");
|
||||
}
|
||||
} catch {
|
||||
notify.error("Failed to resend verification code");
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
}, [session, resendCountdown, notify]);
|
||||
|
||||
const handleCancelSession = useCallback(async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await fetch(`/api/providers/volcengine-plan/connect/${session.sessionId}/cancel`, {
|
||||
method: "POST",
|
||||
});
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
reset();
|
||||
}, [session, reset]);
|
||||
|
||||
// ── derived UI state ────────────────────────────────────────────────────
|
||||
|
||||
const phase = session?.phase;
|
||||
const showPhoneStep = !session;
|
||||
const showCodeStep =
|
||||
phase === "waiting_code" ||
|
||||
phase === "captcha_required" ||
|
||||
phase === "mfa_waiting" ||
|
||||
phase === "identity_required";
|
||||
const showPolling = phase === "starting" || phase === "sending_code" || phase === "submitting";
|
||||
const done = isTerminal(phase);
|
||||
const mfaStep = phase === "mfa_waiting";
|
||||
|
||||
const bindingResults = session?.binding?.results || [];
|
||||
const connectedPlans = bindingResults.filter((r) => r?.ok);
|
||||
const bindingError = session?.binding?.error;
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// ── render ──────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title={providerText(t, "connectVolcengineAccount", "Connect Volcano Account")}
|
||||
size="md"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{showPhoneStep && (
|
||||
<>
|
||||
<p className="text-sm text-text-muted">
|
||||
{providerText(
|
||||
t,
|
||||
"volcAutoLoginDesc",
|
||||
"Enter your phone number. OmniRoute sends a verification code via the Volcano Engine console and extracts the session cookies automatically — no browser interaction needed."
|
||||
)}
|
||||
</p>
|
||||
<Input
|
||||
label={providerText(t, "volcPhoneLabel", "Phone number")}
|
||||
placeholder="13800000000"
|
||||
value={phone}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPhone(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") void handleStart();
|
||||
}}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleClose}>
|
||||
{providerText(t, "cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button size="sm" loading={starting} disabled={!phone.trim()} onClick={handleStart}>
|
||||
{providerText(t, "volcSendCode", "Send verification code")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showCodeStep && (
|
||||
<>
|
||||
<p className="text-sm text-text-muted">
|
||||
{mfaStep
|
||||
? providerText(
|
||||
t,
|
||||
"volcMfaDesc",
|
||||
"Additional verification required (MFA). A NEW 6-digit code was sent to {phone} — enter it below to finish login.",
|
||||
{ phone: session?.phoneMasked || "your phone" }
|
||||
)
|
||||
: phase === "identity_required"
|
||||
? providerText(
|
||||
t,
|
||||
"volcIdentityDesc",
|
||||
"Your phone number is linked to multiple Volcano Engine identities. Pick the one you want to log in with:"
|
||||
)
|
||||
: providerText(
|
||||
t,
|
||||
"volcCodeSent",
|
||||
"A verification code was sent to {phone}. Enter it below to finish login.",
|
||||
{ phone: session?.phoneMasked || "your phone" }
|
||||
)}
|
||||
</p>
|
||||
|
||||
{phase === "identity_required" && session?.identityOptions?.length ? (
|
||||
<div className="space-y-2">
|
||||
{session.identityOptions.map((option) => (
|
||||
<button
|
||||
key={option.index}
|
||||
type="button"
|
||||
disabled={selectingIdentity}
|
||||
onClick={() => handleSelectIdentity(option.index)}
|
||||
className="w-full rounded-lg border border-border p-3 text-left text-sm transition-colors hover:bg-sidebar disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{selectingIdentity ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
{option.label}
|
||||
</span>
|
||||
) : (
|
||||
option.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{phase === "captcha_required" && session?.captchaImage && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">
|
||||
{providerText(
|
||||
t,
|
||||
"volcCaptchaLabel",
|
||||
"Image captcha (required by the console)"
|
||||
)}
|
||||
</p>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={session.captchaImage}
|
||||
alt="captcha"
|
||||
className="max-h-40 rounded border border-border"
|
||||
/>
|
||||
<Input
|
||||
placeholder={providerText(t, "volcCaptchaPlaceholder", "Captcha characters")}
|
||||
value={captcha}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCaptcha(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={
|
||||
mfaStep
|
||||
? providerText(t, "volcMfaCodeLabel", "MFA verification code")
|
||||
: providerText(t, "volcCodeLabel", "Verification code")
|
||||
}
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCode(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") void handleSubmitCode();
|
||||
}}
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
/>
|
||||
|
||||
{session?.error && <p className="text-sm text-red-500">{session.error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={resending}
|
||||
disabled={resendCountdown > 0}
|
||||
onClick={handleResend}
|
||||
>
|
||||
{resendCountdown > 0
|
||||
? providerText(t, "volcResendIn", "Resend in {s}s", { s: resendCountdown })
|
||||
: providerText(t, "volcResend", "Resend code")}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleCancelSession}>
|
||||
{providerText(t, "back", "Back")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
loading={submittingCode}
|
||||
disabled={code.trim().length < 4}
|
||||
onClick={handleSubmitCode}
|
||||
>
|
||||
{providerText(t, "volcLogin", "Log in")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{showPolling && (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-text-muted">
|
||||
{phase === "submitting"
|
||||
? providerText(
|
||||
t,
|
||||
"volcSubmitting",
|
||||
"Submitting code and extracting console cookies..."
|
||||
)
|
||||
: providerText(t, "volcStarting", "Starting Volcano login...")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && phase === "success" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-green-600">
|
||||
{providerText(t, "volcLoginSuccess", "Logged in to the Volcano Engine console")}
|
||||
</p>
|
||||
{bindingError ? (
|
||||
<p className="text-sm text-red-500">
|
||||
{providerText(t, "volcBindError", "Plan binding failed: {error}", {
|
||||
error: bindingError,
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 text-sm">
|
||||
{connectedPlans.length > 0 ? (
|
||||
connectedPlans.map((item) => (
|
||||
<p key={item.plan} className="text-green-600">
|
||||
✓ {item.plan} plan connected
|
||||
</p>
|
||||
))
|
||||
) : (
|
||||
<p className="text-text-muted">
|
||||
{providerText(
|
||||
t,
|
||||
"volcNoPlans",
|
||||
"No Agent/Coding plans were detected on this account."
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleClose}>
|
||||
{providerText(t, "done", "Done")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{done && phase !== "success" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-red-500">
|
||||
{session?.error ||
|
||||
(phase === "timeout"
|
||||
? providerText(t, "volcTimeout", "Login timed out")
|
||||
: phase === "cancelled"
|
||||
? providerText(t, "volcCancelled", "Login cancelled")
|
||||
: providerText(t, "volcFailed", "Login failed"))}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={reset}>
|
||||
{providerText(t, "retry", "Retry")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onFallbackManual();
|
||||
}}
|
||||
>
|
||||
{providerText(t, "volcManualLogin", "Manual browser login")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,46 @@ interface SyncResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Slider works in "checkpoint space": position p ∈ [0, 3] maps linearly onto
|
||||
// these hour values, so the evenly spaced tick labels always match the thumb.
|
||||
const INTERVAL_CHECKPOINTS = [1, 6, 24, 168];
|
||||
const SNAP_THRESHOLD = 0.15;
|
||||
|
||||
function positionToHours(pos: number): number {
|
||||
const p = Math.min(INTERVAL_CHECKPOINTS.length - 1, Math.max(0, pos));
|
||||
const lower = Math.floor(p);
|
||||
const upper = Math.ceil(p);
|
||||
if (lower === upper) return INTERVAL_CHECKPOINTS[lower];
|
||||
const t = p - lower;
|
||||
return Math.round(
|
||||
INTERVAL_CHECKPOINTS[lower] + (INTERVAL_CHECKPOINTS[upper] - INTERVAL_CHECKPOINTS[lower]) * t
|
||||
);
|
||||
}
|
||||
|
||||
function hoursToPosition(hours: number): number {
|
||||
const cps = INTERVAL_CHECKPOINTS;
|
||||
if (hours <= cps[0]) return 0;
|
||||
for (let i = 0; i < cps.length - 1; i++) {
|
||||
if (hours <= cps[i + 1]) {
|
||||
return i + (hours - cps[i]) / (cps[i + 1] - cps[i]);
|
||||
}
|
||||
}
|
||||
return cps.length - 1;
|
||||
}
|
||||
|
||||
// Magnetic checkpoints: snap to a reference point when released nearby,
|
||||
// otherwise keep the freely chosen position.
|
||||
function snapPosition(pos: number): number {
|
||||
for (let i = 0; i < INTERVAL_CHECKPOINTS.length; i++) {
|
||||
if (Math.abs(pos - i) <= SNAP_THRESHOLD) return i;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
function formatInterval(hours: number): string {
|
||||
return hours === 168 ? "7d" : `${hours}h`;
|
||||
}
|
||||
|
||||
export default function ModelsDevSyncTab() {
|
||||
const t = useTranslations("settings");
|
||||
const [status, setStatus] = useState<ModelsDevStatus | null>(null);
|
||||
@@ -32,7 +72,7 @@ export default function ModelsDevSyncTab() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalHours, setIntervalHours] = useState(24);
|
||||
const [draftIntervalHours, setDraftIntervalHours] = useState(24);
|
||||
const [draftPos, setDraftPos] = useState(2);
|
||||
const [feedback, setFeedback] = useState<{ type: "success" | "error"; message: string } | null>(
|
||||
null
|
||||
);
|
||||
@@ -58,7 +98,7 @@ export default function ModelsDevSyncTab() {
|
||||
const intervalMs = settingsData.modelsDevSyncInterval || 86400000;
|
||||
const hours = Math.round(intervalMs / 3600000);
|
||||
setIntervalHours(hours);
|
||||
setDraftIntervalHours(hours);
|
||||
setDraftPos(hoursToPosition(hours));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -126,7 +166,7 @@ export default function ModelsDevSyncTab() {
|
||||
const updateInterval = async (hours: number) => {
|
||||
const oldInterval = intervalHours;
|
||||
setIntervalHours(hours);
|
||||
setDraftIntervalHours(hours);
|
||||
setDraftPos(hoursToPosition(hours));
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
@@ -135,20 +175,27 @@ export default function ModelsDevSyncTab() {
|
||||
});
|
||||
if (!res.ok) {
|
||||
setIntervalHours(oldInterval);
|
||||
setDraftIntervalHours(oldInterval);
|
||||
setDraftPos(hoursToPosition(oldInterval));
|
||||
setFeedback({ type: "error", message: t("enableSyncError") });
|
||||
} else {
|
||||
setFeedback({ type: "success", message: "Interval updated" });
|
||||
}
|
||||
} catch {
|
||||
setIntervalHours(oldInterval);
|
||||
setDraftIntervalHours(oldInterval);
|
||||
setDraftPos(hoursToPosition(oldInterval));
|
||||
setFeedback({ type: "error", message: "Network error" });
|
||||
} finally {
|
||||
setTimeout(() => setFeedback(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// Commit on release: snap to a checkpoint when near one, else keep free value.
|
||||
const commitDraftInterval = () => {
|
||||
const snapped = snapPosition(draftPos);
|
||||
if (snapped !== draftPos) setDraftPos(snapped);
|
||||
updateInterval(positionToHours(snapped));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -238,18 +285,20 @@ export default function ModelsDevSyncTab() {
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm font-medium">{t("modelsDevInterval")}</p>
|
||||
<span className="text-sm font-mono tabular-nums text-blue-400">
|
||||
{draftIntervalHours}h
|
||||
{formatInterval(positionToHours(draftPos))}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="168"
|
||||
step="1"
|
||||
value={draftIntervalHours}
|
||||
onChange={(e) => setDraftIntervalHours(parseInt(e.target.value))}
|
||||
onMouseUp={(e) => updateInterval(parseInt((e.target as HTMLInputElement).value))}
|
||||
onBlur={(e) => updateInterval(parseInt(e.target.value))}
|
||||
min="0"
|
||||
max={INTERVAL_CHECKPOINTS.length - 1}
|
||||
step="any"
|
||||
value={draftPos}
|
||||
onChange={(e) => setDraftPos(parseFloat(e.target.value))}
|
||||
onMouseUp={commitDraftInterval}
|
||||
onTouchEnd={commitDraftInterval}
|
||||
onBlur={commitDraftInterval}
|
||||
aria-label={t("modelsDevInterval")}
|
||||
className="w-full accent-blue-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-text-muted mt-1">
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { isValidGheUrl } from "@/shared/validation/providerSpecificData";
|
||||
import { AWS_REGION_PATTERN } from "@/lib/oauth/constants/oauth";
|
||||
import { antigravityDegradedProjectState } from "@/lib/oauth/antigravityProjectGate";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { startLocalServer } from "@/lib/oauth/utils/server";
|
||||
import { runWithProxyContextOrDirect } from "@omniroute/open-sse/utils/proxyFetch.ts";
|
||||
@@ -520,6 +521,12 @@ export async function POST(
|
||||
exchangeTokens(provider, code, redirectUri, codeVerifier, normalizedState)
|
||||
);
|
||||
|
||||
// #11284: when Cloud Code projectId discovery failed at connect time,
|
||||
// SAVE the connection but mark it degraded (maintainer direction on
|
||||
// #11284) — the refresh token stays stored and request-time bootstrap
|
||||
// self-heals the row once Google assigns a project.
|
||||
const degradedProject = antigravityDegradedProjectState(provider, tokenData);
|
||||
|
||||
// Normalize: if name is missing, use email or displayName as fallback so accounts
|
||||
// always show a real label (e.g. user@gmail.com) instead of "Account #abc123"
|
||||
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
|
||||
@@ -542,14 +549,15 @@ export async function POST(
|
||||
connection = await updateProviderConnection(matchId, {
|
||||
...tokenData,
|
||||
expiresAt,
|
||||
testStatus: "active",
|
||||
testStatus: degradedProject?.testStatus ?? "active",
|
||||
...(degradedProject ?? {}),
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!connection) {
|
||||
connection = await createProviderConnection(
|
||||
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
|
||||
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -558,6 +566,7 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...(degradedProject ? { warning: degradedProject.warning } : {}),
|
||||
connection: {
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
@@ -739,6 +748,10 @@ export async function POST(
|
||||
exchangeTokens(provider, params.code, redirectUri, codeVerifier, params.state)
|
||||
);
|
||||
|
||||
// #11284: when Cloud Code projectId discovery failed at connect time,
|
||||
// SAVE the connection but mark it degraded (maintainer direction).
|
||||
const degradedProject = antigravityDegradedProjectState(provider, tokenData);
|
||||
|
||||
// Normalize: if name is missing, use email as fallback display label
|
||||
if (!tokenData.name && (tokenData.email || tokenData.displayName)) {
|
||||
tokenData.name = tokenData.email || tokenData.displayName;
|
||||
@@ -765,14 +778,15 @@ export async function POST(
|
||||
connection = await updateProviderConnection(matchId, {
|
||||
...tokenData,
|
||||
expiresAt,
|
||||
testStatus: "active",
|
||||
testStatus: degradedProject?.testStatus ?? "active",
|
||||
...(degradedProject ?? {}),
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!connection) {
|
||||
connection = await createProviderConnection(
|
||||
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt)
|
||||
buildOAuthConnectionCreatePayload(provider, tokenData, expiresAt, degradedProject)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -780,6 +794,7 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...(degradedProject ? { warning: degradedProject.warning } : {}),
|
||||
connection: {
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
import { autoSyncCodexProfilesFromLiveCatalog } from "@/lib/cli-helper/codexProfileAutoSync";
|
||||
import { autoSyncClaudeProfilesFromLiveCatalog } from "@/lib/cli-helper/claudeProfileAutoSync";
|
||||
import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability";
|
||||
import {
|
||||
fetchVolcPlanModels,
|
||||
providerToVolcPlanKind,
|
||||
} from "@/lib/providers/volcenginePlanModelDiscovery";
|
||||
import { replaceSyncedAvailableModelsForConnection } from "@/lib/db/models";
|
||||
import { GET as getProviderModels } from "../models/route";
|
||||
import { isDegradedDiscovery } from "./degradedLocalCatalog";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
|
||||
@@ -423,6 +428,84 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
|
||||
|
||||
logProvider = toNonEmptyString(connection.provider) || "unknown";
|
||||
channelLabel = getModelSyncChannelLabel(connection);
|
||||
|
||||
// Volcano Ark plan providers: discover models live from the console API
|
||||
// (cookie+csrf captured at bind time). The chat API has no /models
|
||||
// endpoint, so the default discovery path below cannot serve them.
|
||||
const volcPlanKind = providerToVolcPlanKind(logProvider);
|
||||
if (volcPlanKind) {
|
||||
const psd =
|
||||
connection.providerSpecificData && typeof connection.providerSpecificData === "object"
|
||||
? (connection.providerSpecificData as JsonRecord)
|
||||
: {};
|
||||
const cookie = toNonEmptyString(psd.volcConsoleCookie) || "";
|
||||
const csrf = toNonEmptyString(psd.volcCsrfToken) || "";
|
||||
const duration = Date.now() - start;
|
||||
let discovered;
|
||||
try {
|
||||
discovered = await fetchVolcPlanModels(volcPlanKind, cookie, csrf);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await saveCallLog({
|
||||
method: "POST",
|
||||
path: `/api/providers/${id}/sync-models`,
|
||||
status: 401,
|
||||
model: "model-sync",
|
||||
provider: logProvider,
|
||||
sourceFormat: "-",
|
||||
connectionId: id,
|
||||
duration,
|
||||
error: message,
|
||||
requestType: "model-sync",
|
||||
...(channelLabel ? { responseBody: { channel: channelLabel } } : {}),
|
||||
}).catch(() => undefined);
|
||||
return NextResponse.json(
|
||||
{ error: sanitizeErrorMessage(message) || "Volcano plan discovery failed" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
const previous = await getSyncedAvailableModelsForConnection(logProvider, id);
|
||||
const synced = await replaceSyncedAvailableModelsForConnection(logProvider, id, discovered);
|
||||
const prevIds = new Set(previous.map((m) => String(m.id)));
|
||||
const added = synced.filter((m) => !prevIds.has(String(m.id))).length;
|
||||
const removed = previous.filter(
|
||||
(m) => !synced.some((n) => String(n.id) === String(m.id))
|
||||
).length;
|
||||
await saveCallLog({
|
||||
method: "GET",
|
||||
path: `/api/providers/${id}/models`,
|
||||
status: 200,
|
||||
model: "model-sync",
|
||||
provider: logProvider,
|
||||
sourceFormat: "console-discovery",
|
||||
connectionId: id,
|
||||
duration: Date.now() - start,
|
||||
requestType: "model-sync",
|
||||
responseBody: {
|
||||
source: "volcengine-plan-console-discovery",
|
||||
plan: volcPlanKind,
|
||||
syncedModels: synced.length,
|
||||
added,
|
||||
removed,
|
||||
provider: logProvider,
|
||||
channel: channelLabel,
|
||||
mode,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
provider: logProvider,
|
||||
connectionId: id,
|
||||
source: "volcengine-plan-console-discovery",
|
||||
plan: volcPlanKind,
|
||||
mode,
|
||||
syncedModels: synced.length,
|
||||
availableModelsCount: synced.length,
|
||||
modelChanges: { added, removed, total: added + removed },
|
||||
models: synced,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerUsesCuratedModelsOnly(logProvider)) {
|
||||
const [removedSyncedLists, removedImportedModelIds] = await Promise.all([
|
||||
deleteSyncedAvailableModelsForProvider(logProvider),
|
||||
|
||||
@@ -30,6 +30,7 @@ import { providerAllowsOptionalApiKey } from "@/shared/constants/providers";
|
||||
import { shouldUseApiKeyConnectionTest } from "./webSessionTestDispatch";
|
||||
import { testCodexAppServerConnection, makeDiagnosis } from "./codexAppServerHealth";
|
||||
import { removeConnectionHealth } from "@omniroute/open-sse/services/apiKeyRotator.ts";
|
||||
import { shouldClearErrorStateOnValidProbe } from "@/lib/usage/providerLimits";
|
||||
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
|
||||
import { classifyAmbiguousOrAuthError, type ClassifyFailureArgs } from "./mistralAmbiguousAuth";
|
||||
import { buildApiKeyConnectionTestResult } from "./apiKeyTestResult";
|
||||
@@ -1082,23 +1083,46 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase());
|
||||
const testFailureCooldownMs = result.valid ? 0 : 30_000; // 30s retry window
|
||||
|
||||
// A successful credential probe proves the KEY is valid. It does NOT prove the
|
||||
// quota window reopened: the probe is a cheap auth/models call that never touches
|
||||
// the chat quota a weekly cap applies to. Clearing an ACTIVE cooldown here — which
|
||||
// the credential-health scheduler triggers for every connection every 300s — put
|
||||
// `zai/glm-5.3` back to `active` / `rate_limited_until = NULL` within 30s of every
|
||||
// restart, so combo dispatched it straight into the same weekly 429. Same rule as
|
||||
// maybeClearRecoveredQuotaState: a future rateLimitedUntil is the 429 handler's
|
||||
// hard statement and no poller may overrule it. Once it elapses, the next probe
|
||||
// clears it normally.
|
||||
const clearErrorState = shouldClearErrorStateOnValidProbe(
|
||||
connection as { rateLimitedUntil?: string | null },
|
||||
result.valid
|
||||
);
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
testStatus: result.valid ? "active" : "error",
|
||||
lastError: result.valid ? null : result.error,
|
||||
lastErrorAt: result.valid ? null : now,
|
||||
testStatus: clearErrorState ? "active" : result.valid ? connection.testStatus : "error",
|
||||
lastError: clearErrorState ? null : result.valid ? connection.lastError : result.error,
|
||||
lastErrorAt: clearErrorState ? null : result.valid ? connection.lastErrorAt : now,
|
||||
lastTested: now,
|
||||
lastErrorType: result.valid ? null : diagnosis.type,
|
||||
lastErrorSource: result.valid ? null : diagnosis.source,
|
||||
errorCode: result.valid ? null : diagnosis.code || result.statusCode || null,
|
||||
rateLimitedUntil:
|
||||
result.valid || isTerminalFailure
|
||||
? result.valid
|
||||
? null
|
||||
: connection.rateLimitedUntil || null
|
||||
: new Date(Date.now() + testFailureCooldownMs).toISOString(),
|
||||
lastErrorType: clearErrorState ? null : result.valid ? connection.lastErrorType : diagnosis.type,
|
||||
lastErrorSource: clearErrorState
|
||||
? null
|
||||
: result.valid
|
||||
? connection.lastErrorSource
|
||||
: diagnosis.source,
|
||||
errorCode: clearErrorState
|
||||
? null
|
||||
: result.valid
|
||||
? connection.errorCode
|
||||
: diagnosis.code || result.statusCode || null,
|
||||
rateLimitedUntil: clearErrorState
|
||||
? null
|
||||
: isTerminalFailure
|
||||
? connection.rateLimitedUntil || null
|
||||
: result.valid
|
||||
? connection.rateLimitedUntil || null
|
||||
: new Date(Date.now() + testFailureCooldownMs).toISOString(),
|
||||
};
|
||||
|
||||
if (result.valid) {
|
||||
if (clearErrorState) {
|
||||
updateData.backoffLevel = 0;
|
||||
|
||||
const psd = connection?.providerSpecificData as Record<string, unknown> | undefined;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/cancel
|
||||
* Cancel an auto phone login session and close its headless browser.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
const session = await volcengineConsoleAutoLoginService.cancel(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, session });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "Cancel failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/code
|
||||
* Submit the SMS verification code (plus image captcha when required) for an
|
||||
* auto phone login session. Returns the session view; binding runs lazily on
|
||||
* the next status poll once credentials are extracted.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
|
||||
if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const session = await volcengineConsoleAutoLoginService.submitCode(
|
||||
sessionId,
|
||||
String(body.code ?? ""),
|
||||
typeof body.captcha === "string" ? body.captcha : undefined,
|
||||
{ timeout }
|
||||
);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Credentials ready → bind immediately so the response carries the outcome.
|
||||
if (session.phase === "success") {
|
||||
const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
|
||||
bindVolcenginePlansFromConsoleCredentials(credentials)
|
||||
);
|
||||
return NextResponse.json({ success: true, session: bound ?? session });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano code submission failed: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/identity
|
||||
* Pick an identity on the console's select_identity page (the phone maps to
|
||||
* multiple accounts) and finish the login + plan binding.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
const body = await request.json().catch(() => ({}));
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
|
||||
if (!volcengineConsoleAutoLoginService.getStatus(sessionId)) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const index = Number(body.index);
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Invalid identity index" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
const session = await volcengineConsoleAutoLoginService.selectIdentity(sessionId, index, {
|
||||
timeout,
|
||||
});
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Credentials ready → bind immediately so the response carries the outcome.
|
||||
if (session.phase === "success") {
|
||||
const bound = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
|
||||
bindVolcenginePlansFromConsoleCredentials(credentials)
|
||||
);
|
||||
return NextResponse.json({ success: true, session: bound ?? session });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: false, session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano identity selection failed: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
/**
|
||||
* POST /api/providers/volcengine-plan/connect/[sessionId]/resend
|
||||
* Re-trigger the SMS verification code for an active login session.
|
||||
*/
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
const session = await volcengineConsoleAutoLoginService.resendCode(sessionId);
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, session });
|
||||
} catch {
|
||||
return NextResponse.json({ success: false, error: "Resend failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
/**
|
||||
* GET /api/providers/volcengine-plan/connect/[sessionId]/status
|
||||
* Poll an auto phone login session. When credentials have been extracted, the
|
||||
* plan binding runs lazily (deduped) and its result is attached to the view.
|
||||
*/
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ sessionId: string }> }
|
||||
): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const { sessionId } = await params;
|
||||
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
|
||||
const session = await volcengineConsoleAutoLoginService.withBinding(sessionId, (credentials) =>
|
||||
bindVolcenginePlansFromConsoleCredentials(credentials)
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "Unknown or expired Volcano login session" },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: session.phase === "success", session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano login status failed: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
52
src/app/api/providers/volcengine-plan/connect/route.ts
Normal file
52
src/app/api/providers/volcengine-plan/connect/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
import { bindVolcenginePlansFromConsoleCredentials } from "@/lib/providers/volcenginePlanBinding";
|
||||
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const auth = await requireManagementAuth(request);
|
||||
if (auth) return auth;
|
||||
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const timeout = typeof body.timeout === "number" ? body.timeout : undefined;
|
||||
|
||||
// Auto flow: phone present → start a session-based headless phone/SMS login.
|
||||
if (typeof body.phone === "string" && body.phone.trim()) {
|
||||
try {
|
||||
const { volcengineConsoleAutoLoginService } =
|
||||
await import("@omniroute/open-sse/services/volcengineConsoleAutoLogin.ts");
|
||||
const started = await volcengineConsoleAutoLoginService.startLogin(body.phone, { timeout });
|
||||
if (!started.ok) {
|
||||
return NextResponse.json({ success: false, error: started.error }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json({ success: true, session: started.session });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano auto login failed to start: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy manual flow: headful browser login on the server machine.
|
||||
try {
|
||||
const { inAppLoginService } = await import("@omniroute/open-sse/services/inAppLoginService.ts");
|
||||
const login = await inAppLoginService.startLogin("volcengine-console", { timeout });
|
||||
if (!login.success || !login.credentials) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: login.error || "Volcano console login failed" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const binding = await bindVolcenginePlansFromConsoleCredentials(login.credentials);
|
||||
return NextResponse.json({ success: true, binding });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(error instanceof Error ? error.message : error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Volcano account binding failed: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CORS_HEADERS } from "@/shared/utils/cors";
|
||||
import { getLiveWsPath } from "@/shared/utils/wsPath";
|
||||
import { getLiveWsPath, resolveLiveWsPublicUrl } from "@/shared/utils/wsPath";
|
||||
import { authorizeWebSocketHandshake } from "@/lib/ws/handshake";
|
||||
|
||||
const WS_HANDSHAKE_HEADERS = {
|
||||
@@ -13,9 +13,9 @@ const WS_HANDSHAKE_HEADERS = {
|
||||
* env changes are honored, and only echoed when it is a ws:// or wss:// URL.
|
||||
*/
|
||||
function getLivePublicUrl(): string | null {
|
||||
const publicUrl = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL;
|
||||
if (!publicUrl) return null;
|
||||
return publicUrl.startsWith("ws://") || publicUrl.startsWith("wss://") ? publicUrl : null;
|
||||
// Runtime-resolved: a prebuilt image never carries a build-time NEXT_PUBLIC_*
|
||||
// value, and this handshake is what the browser reads instead (#11331).
|
||||
return resolveLiveWsPublicUrl();
|
||||
}
|
||||
|
||||
function getWsProtocol() {
|
||||
|
||||
@@ -58,6 +58,7 @@ export const DEFAULT_INTELLIGENT_WEIGHTS: IntelligentRoutingWeights = {
|
||||
};
|
||||
|
||||
export const MODE_PACK_OPTIONS = [
|
||||
{ id: "custom", label: "Custom / None (Use Sliders)", emoji: "tune" },
|
||||
{ id: "ship-fast", label: "Ship Fast", emoji: "rocket_launch" },
|
||||
{ id: "cost-saver", label: "Cost Saver", emoji: "savings" },
|
||||
{ id: "quality-first", label: "Quality First", emoji: "target" },
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
// Build-time stub for better-sqlite3 (#10060).
|
||||
//
|
||||
// Aliased in for the Next.js production build (turbopack + webpack) so the
|
||||
// bundler never pulls the real native addon into a build worker. The native
|
||||
// Statement destructor aborts with SIGABRT when a build worker thread exits
|
||||
// OPT-IN ONLY — set OMNIROUTE_BETTER_SQLITE3_STUB=1 to alias it in, and only on
|
||||
// a build host that actually hits the SIGABRT worker teardown: the native
|
||||
// Statement destructor aborts when a Next.js build worker thread exits
|
||||
// (assertion in node::RemoveEnvironmentCleanupHook, env == nullptr), which can
|
||||
// leave the build with no standalone output. At runtime the real package is
|
||||
// used (it is listed in serverExternalPackages, so it is require()'d natively,
|
||||
// not bundled); this stub only stands in during the build, where the DB is
|
||||
// never actually queried.
|
||||
// leave the build with no standalone output.
|
||||
//
|
||||
// It is NOT a build-only stand-in. A Turbopack resolveAlias rewrites the
|
||||
// request before the externals check, so aliasing `better-sqlite3` here also
|
||||
// removes it from serverExternalPackages' reach and bakes THIS FILE into the
|
||||
// shipped bundle. An artifact built with the flag on cannot open a database:
|
||||
// the sync driver chain fails with "r(...) is not a constructor", falls through
|
||||
// node:sqlite and sql.js, and the instrumentation hook aborts at boot, so every
|
||||
// route answers HTTP 500. That is exactly what an unconditional alias shipped
|
||||
// in #11343. See scripts/build/better-sqlite3-stub-flag.mjs.
|
||||
class Database {
|
||||
constructor() {}
|
||||
prepare() {
|
||||
|
||||
@@ -124,6 +124,27 @@ export function getEffectiveQuotaUsage(
|
||||
return used;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a persisted `rate_limited_until` to epoch ms.
|
||||
*
|
||||
* The column is written in two shapes: epoch ms by `setConnectionRateLimitUntil`
|
||||
* (the chat path) and an ISO-8601 string by `updateProviderConnection` (the
|
||||
* dashboard/AUTH path). Returns null when the value is absent or unparseable —
|
||||
* callers treat that as "no usable deadline".
|
||||
*/
|
||||
function parseCooldownUntilMs(value: string | number | null | undefined): number | null {
|
||||
if (value == null || value === "") return null;
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
const raw = String(value).trim();
|
||||
if (raw === "") return null;
|
||||
if (/^\d+$/.test(raw)) {
|
||||
const numeric = Number(raw);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
const parsed = Date.parse(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* T05: Startup crash-recovery — clear stale transient connection cooldowns.
|
||||
*
|
||||
@@ -138,9 +159,19 @@ export function getEffectiveQuotaUsage(
|
||||
* - Only connections with `rate_limited_until IS NOT NULL` are touched.
|
||||
* - Terminal states (`banned`, `expired`, `credits_exhausted`) are skipped —
|
||||
* those require a deliberate credential change or operator reset.
|
||||
* - Past timestamps are also cleared: they are already expired in the lazy
|
||||
* - Past timestamps are cleared: they are already expired in the lazy
|
||||
* expiry sense, but clearing them resets `backoffLevel` / transient error
|
||||
* fields so the connection gets a clean slate on this fresh process.
|
||||
* fields so the connection gets a clean slate on this fresh process. An
|
||||
* unparseable timestamp is treated the same way — it can never expire
|
||||
* lazily, so leaving it would strand the connection forever.
|
||||
* - FUTURE timestamps are NEVER cleared. Clearing them was the original
|
||||
* behaviour and it wiped legitimate multi-day quota cooldowns on every
|
||||
* container recreate: a GLM weekly cap persisted until 2026-08-29 came
|
||||
* back `active` with `rate_limited_until = NULL`, combo dispatched it
|
||||
* immediately, and the connection re-earned a real upstream 429. A stale
|
||||
* crash-backoff value is bounded by the engine's own cooldown cap, so
|
||||
* honouring it costs at most that window — far less than burning quota
|
||||
* against an upstream that is provably exhausted.
|
||||
*
|
||||
* Must be called once, early in the startup sequence, before any request
|
||||
* is handled. Returns the number of connections that were cleared.
|
||||
@@ -148,6 +179,7 @@ export function getEffectiveQuotaUsage(
|
||||
export function clearStaleCrashCooldowns(): { cleared: number } {
|
||||
const db = getDbInstance() as unknown as DbLike;
|
||||
const now = new Date().toISOString();
|
||||
const nowMs = Date.now();
|
||||
|
||||
// Fetch all connections that have a rate_limited_until set and are NOT in
|
||||
// a terminal state. We do the terminal-status filter in JS to reuse the
|
||||
@@ -156,13 +188,20 @@ export function clearStaleCrashCooldowns(): { cleared: number } {
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, test_status FROM provider_connections WHERE rate_limited_until IS NOT NULL`
|
||||
`SELECT id, test_status, rate_limited_until FROM provider_connections WHERE rate_limited_until IS NOT NULL`
|
||||
)
|
||||
.all() as Array<{ id: string; test_status: string | null }>;
|
||||
.all() as Array<{
|
||||
id: string;
|
||||
test_status: string | null;
|
||||
rate_limited_until: string | number | null;
|
||||
}>;
|
||||
|
||||
const toReset = rows.filter((r) => {
|
||||
const status = (r.test_status || "").trim().toLowerCase();
|
||||
return !TERMINAL_STATUSES.has(status);
|
||||
if (TERMINAL_STATUSES.has(status)) return false;
|
||||
const untilMs = parseCooldownUntilMs(r.rate_limited_until);
|
||||
// Unparseable → clear (cannot expire lazily). Future → keep.
|
||||
return untilMs === null || untilMs <= nowMs;
|
||||
});
|
||||
|
||||
if (toReset.length === 0) return { cleared: 0 };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isAutomatedTestProcess } from "@/shared/utils/testProcess";
|
||||
import { getJobRegistry } from "@/lib/jobRegistry";
|
||||
import { registerBudgetResetJob } from "@/lib/jobs/budgetResetJob";
|
||||
import { registerTokenHealthCheck } from "@/lib/jobs/tokenHealthCheckJob";
|
||||
import { backfillVolcPlanAutoSync } from "@/lib/providers/volcPlanAutoSyncBackfill";
|
||||
|
||||
// Initialize runtime background sync services once per server process.
|
||||
let initialized = false;
|
||||
@@ -31,6 +32,7 @@ export async function ensureCloudSyncInitialized() {
|
||||
if (!initialized) {
|
||||
try {
|
||||
await initializeCloudSync();
|
||||
await backfillVolcPlanAutoSync();
|
||||
startModelSyncScheduler();
|
||||
|
||||
// startAll() runs each interval job's first tick synchronously, so it has to
|
||||
|
||||
61
src/lib/oauth/antigravityProjectGate.ts
Normal file
61
src/lib/oauth/antigravityProjectGate.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* #11284 — Antigravity OAuth connect-time DEGRADE marking for accounts without
|
||||
* a Cloud Code projectId. Shared helper used by the OAuth route's `exchange`,
|
||||
* `poll-callback`, and the shared persistOAuthConnection path.
|
||||
*
|
||||
* Maintainer direction on #11284: do NOT reject the connect — SAVE the
|
||||
* connection but mark it degraded, so the refresh token stays stored and the
|
||||
* request-time bootstrap can self-heal it (persistDiscoveredAntigravityProjectId
|
||||
* flips the row back to active). Confirmed-BYOP accounts get disabled by
|
||||
* markAntigravityMissingCloudCodeProject() on the first dispatch instead.
|
||||
*/
|
||||
|
||||
export type AntigravityDegradedProjectState = {
|
||||
/** Persist with this status instead of "active". */
|
||||
testStatus: "degraded";
|
||||
errorCode: string;
|
||||
lastErrorType: string;
|
||||
lastError: string;
|
||||
/** Non-fatal warning surfaced in the connect response for the dashboard. */
|
||||
warning: string;
|
||||
};
|
||||
|
||||
/** Providers whose Cloud Code projectId is expected at connect time. */
|
||||
const PROJECT_EXPECTED_PROVIDERS = new Set(["antigravity", "agy"]);
|
||||
|
||||
const BYOP_WARNING =
|
||||
"Connected, but Google did not assign a Cloud Code project to this account (BYOP). " +
|
||||
"Create a GCP Project at console.cloud.google.com and complete Gemini Code Assist onboarding; " +
|
||||
"the account is marked degraded until then and cannot serve requests.";
|
||||
|
||||
const DISCOVERY_FAILED_WARNING =
|
||||
"Connected, but the Google Cloud Code projectId could not be discovered during login " +
|
||||
"(loadCodeAssist/onboardUser failed). The account is marked degraded; discovery retries " +
|
||||
"automatically on the first request.";
|
||||
|
||||
/**
|
||||
* #11284: when projectId discovery failed at connect time, return the degrade
|
||||
* fields to persist (testStatus:"degraded" + typed error markers) instead of
|
||||
* silently saving a false "active". Returns null for healthy payloads.
|
||||
*/
|
||||
export function antigravityDegradedProjectState(
|
||||
provider: string,
|
||||
tokenData: Record<string, unknown> | null | undefined
|
||||
): AntigravityDegradedProjectState | null {
|
||||
if (!PROJECT_EXPECTED_PROVIDERS.has(provider)) return null;
|
||||
const outcome = tokenData?.projectDiscoveryOutcome;
|
||||
if (!outcome) return null;
|
||||
console.warn(
|
||||
`[oauth] ${provider}: marking connection degraded — no Cloud Code projectId (${String(outcome)}) (#11284)`
|
||||
);
|
||||
return {
|
||||
testStatus: "degraded",
|
||||
errorCode: "missing_project_id",
|
||||
lastErrorType: "oauth_missing_project_id",
|
||||
lastError:
|
||||
outcome === "requires_manual_project"
|
||||
? BYOP_WARNING
|
||||
: DISCOVERY_FAILED_WARNING,
|
||||
warning: outcome === "requires_manual_project" ? BYOP_WARNING : DISCOVERY_FAILED_WARNING,
|
||||
};
|
||||
}
|
||||
@@ -96,7 +96,13 @@ export function findExistingOAuthConnectionMatch(
|
||||
export function buildOAuthConnectionCreatePayload(
|
||||
provider: string,
|
||||
tokenData: Record<string, any>,
|
||||
expiresAt: string | null
|
||||
expiresAt: string | null,
|
||||
degradedProject?: {
|
||||
testStatus: "degraded";
|
||||
errorCode: string;
|
||||
lastErrorType: string;
|
||||
lastError: string;
|
||||
} | null
|
||||
) {
|
||||
return {
|
||||
provider,
|
||||
@@ -104,7 +110,17 @@ export function buildOAuthConnectionCreatePayload(
|
||||
...tokenData,
|
||||
expiresAt,
|
||||
tokenExpiresAt: expiresAt,
|
||||
testStatus: "active" as const,
|
||||
// #11284: degraded when Cloud Code projectId discovery failed at connect
|
||||
// time — the row is saved (refresh token stored, request-time bootstrap
|
||||
// can self-heal) but visibly NOT active.
|
||||
testStatus: degradedProject?.testStatus ?? ("active" as const),
|
||||
...(degradedProject
|
||||
? {
|
||||
errorCode: degradedProject.errorCode,
|
||||
lastErrorType: degradedProject.lastErrorType,
|
||||
lastError: degradedProject.lastError,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,20 @@ type AntigravityTokenPayload = {
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
};
|
||||
/**
|
||||
* Why no Cloud Code projectId was discovered at connect time (#11284).
|
||||
* - "requires_manual_project": Google answered onboardUser with 200 but no
|
||||
* cloudaicompanionProject in the body — the account must bring its own GCP
|
||||
* project (BYOP, #8491). Retrying can never succeed.
|
||||
* - "discovery_failed": loadCodeAssist/onboardUser errored, timed out, or
|
||||
* still returned empty after a successful onboarding round-trip.
|
||||
*/
|
||||
type AntigravityProjectDiscoveryOutcome = "requires_manual_project" | "discovery_failed";
|
||||
type AntigravityPostExchange = {
|
||||
projectId: string;
|
||||
tierId: string;
|
||||
userInfo: { email?: string };
|
||||
projectDiscoveryOutcome?: AntigravityProjectDiscoveryOutcome;
|
||||
};
|
||||
|
||||
async function fetchFirstOk(endpoints: string[], init: RequestInit, timeoutMs?: number) {
|
||||
@@ -150,6 +160,8 @@ async function postExchangeAntigravity(
|
||||
|
||||
let projectId = "";
|
||||
let tierId = "legacy-tier";
|
||||
// #11284: classify WHY discovery fails instead of silently swallowing it.
|
||||
let loadFailed = false;
|
||||
try {
|
||||
const response = await fetchFirstOk(
|
||||
config.loadCodeAssistEndpoints,
|
||||
@@ -160,6 +172,7 @@ async function postExchangeAntigravity(
|
||||
projectId = extractProjectId(data);
|
||||
tierId = extractCodeAssistOnboardTierId(data);
|
||||
} catch (error) {
|
||||
loadFailed = true;
|
||||
console.log("Failed to load code assist:", error);
|
||||
}
|
||||
|
||||
@@ -168,21 +181,57 @@ async function postExchangeAntigravity(
|
||||
} else if (config.onboardUserEndpoints.length > 0) {
|
||||
// Accounts without an existing Cloud Code project need one bounded inline
|
||||
// onboarding attempt before loadCodeAssist can discover their project.
|
||||
let onboardedWithoutProject = false;
|
||||
try {
|
||||
await fetchFirstOk(
|
||||
const response = await fetchFirstOk(
|
||||
config.onboardUserEndpoints,
|
||||
{ method: "POST", headers, body: JSON.stringify({ tier_id: tierId, metadata }) },
|
||||
POSTEXCHANGE_TIMEOUT_MS
|
||||
);
|
||||
const retryResponse = await fetchFirstOk(
|
||||
config.loadCodeAssistEndpoints,
|
||||
{ method: "POST", headers, body: JSON.stringify({ metadata }) },
|
||||
POSTEXCHANGE_TIMEOUT_MS
|
||||
);
|
||||
projectId = extractProjectId((await retryResponse.json()) as Record<string, unknown>);
|
||||
} catch {
|
||||
// Lazy request-time bootstrap retries if onboarding or discovery is unavailable.
|
||||
// Google BYOP (#8491): a 200 WITHOUT cloudaicompanionProject in the
|
||||
// onboardUser body means no project was created and none ever will be —
|
||||
// standard-tier/personal accounts must bring their own GCP project.
|
||||
// A body that DOES carry one (string or {id}) is a real onboarding
|
||||
// success; the retry loadCodeAssist below picks the id up (it can lag).
|
||||
const bodyText = await response.text().catch(() => "");
|
||||
if (bodyText && !bodyText.includes("cloudaicompanionProject")) {
|
||||
console.log(
|
||||
"[oauth] antigravity onboardUser succeeded without creating a project — Google BYOP (user-defined GCP project) required"
|
||||
);
|
||||
onboardedWithoutProject = true;
|
||||
}
|
||||
if (!onboardedWithoutProject) {
|
||||
const retryResponse = await fetchFirstOk(
|
||||
config.loadCodeAssistEndpoints,
|
||||
{ method: "POST", headers, body: JSON.stringify({ metadata }) },
|
||||
POSTEXCHANGE_TIMEOUT_MS
|
||||
);
|
||||
projectId = extractProjectId((await retryResponse.json()) as Record<string, unknown>);
|
||||
// Prefer the id straight from the onboarding response when discovery
|
||||
// lags behind server-side project creation.
|
||||
if (!projectId) {
|
||||
projectId = extractProjectId(
|
||||
(await new Response(bodyText).json().catch(() => ({}))) as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("[oauth] antigravity inline onboarding/discovery failed:", error);
|
||||
}
|
||||
if (!projectId) {
|
||||
return {
|
||||
userInfo,
|
||||
projectId,
|
||||
tierId,
|
||||
projectDiscoveryOutcome: onboardedWithoutProject
|
||||
? "requires_manual_project"
|
||||
: "discovery_failed",
|
||||
};
|
||||
}
|
||||
} else if (loadFailed) {
|
||||
// No onboarding path configured and discovery hard-failed — do not report
|
||||
// this account as healthy-with-no-project (#11284).
|
||||
return { userInfo, projectId, tierId, projectDiscoveryOutcome: "discovery_failed" };
|
||||
}
|
||||
return { userInfo, projectId, tierId };
|
||||
}
|
||||
@@ -199,6 +248,9 @@ function mapAntigravityTokens(
|
||||
scope: tokens.scope,
|
||||
email: extra?.userInfo?.email,
|
||||
projectId: extra?.projectId,
|
||||
// #11284: let the OAuth route reject connects that ended without a Cloud
|
||||
// Code project instead of persisting a dead "active" row.
|
||||
projectDiscoveryOutcome: extra?.projectDiscoveryOutcome,
|
||||
providerSpecificData: {
|
||||
clientProfile,
|
||||
projectId: extra?.projectId,
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
/** Service kinds that, on their own, mean the provider lists no models. */
|
||||
const TOOL_ONLY_SERVICE_KINDS = new Set<string>(["webSearch", "webFetch"]);
|
||||
|
||||
/** Providers whose registry catalog is the complete, intentional model list. */
|
||||
/** Providers whose registry catalog is the complete, intentional model list.
|
||||
*
|
||||
* Volcano Ark plan providers (`volcengine-agent-plan` / `volcengine-coding-plan`)
|
||||
* are intentionally NOT curated: their model list is discovered live from the
|
||||
* console API (see volcenginePlanModelDiscovery.ts) and merged into the synced
|
||||
* catalog, so the static registry only acts as a capability-seed fallback. */
|
||||
const CURATED_MODEL_ONLY_PROVIDERS = new Set<string>(["chatgpt-web", "kimi-web", "zai-web"]);
|
||||
|
||||
export function providerUsesCuratedModelsOnly(providerId: string): boolean {
|
||||
|
||||
44
src/lib/providers/volcPlanAutoSyncBackfill.ts
Normal file
44
src/lib/providers/volcPlanAutoSyncBackfill.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* One-time, idempotent backfill: ensure Volcano Ark plan connections carry
|
||||
* `autoSync:true` so the 24h modelSyncScheduler picks them up.
|
||||
*
|
||||
* Plan connections created before volcenginePlanBinding set `autoSync` do not
|
||||
* have the flag, so the scheduler (which only syncs connections whose
|
||||
* providerSpecificData.autoSync === true) silently skipped them. This runs
|
||||
* once per boot, patches any missing flag in place, and exits. It is safe to
|
||||
* re-run — updateProviderConnection merges the patch.
|
||||
*/
|
||||
|
||||
import { getProviderConnections, updateProviderConnection } from "@/lib/db/providers";
|
||||
|
||||
const VOLC_PLAN_PROVIDERS = new Set(["volcengine-agent-plan", "volcengine-coding-plan"]);
|
||||
|
||||
let backfilled = false;
|
||||
|
||||
export async function backfillVolcPlanAutoSync(): Promise<void> {
|
||||
if (backfilled) return;
|
||||
backfilled = true;
|
||||
try {
|
||||
const connections = await getProviderConnections();
|
||||
for (const conn of connections) {
|
||||
const provider = typeof conn.provider === "string" ? conn.provider : "";
|
||||
if (!VOLC_PLAN_PROVIDERS.has(provider)) continue;
|
||||
const psd =
|
||||
conn.providerSpecificData && typeof conn.providerSpecificData === "object"
|
||||
? (conn.providerSpecificData as Record<string, unknown>)
|
||||
: {};
|
||||
if (psd.autoSync === true) continue;
|
||||
const merged = { ...psd, autoSync: true };
|
||||
if (typeof conn.id !== "string" || !conn.id) continue;
|
||||
await updateProviderConnection(conn.id, {
|
||||
providerSpecificData: merged,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
backfilled = false; // allow retry on next boot if this boot failed
|
||||
console.warn(
|
||||
"[VolcPlanAutoSync] backfill failed — will retry next boot:",
|
||||
(error as Error).message
|
||||
);
|
||||
}
|
||||
}
|
||||
279
src/lib/providers/volcenginePlanBinding.ts
Normal file
279
src/lib/providers/volcenginePlanBinding.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
createProviderConnection,
|
||||
getProviderConnections,
|
||||
updateProviderConnection,
|
||||
} from "@/models";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export const CONSOLE_TOP_BASE = "https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01";
|
||||
const CODING_PLAN_PROVIDER = "volcengine-coding-plan";
|
||||
const AGENT_PLAN_PROVIDER = "volcengine-agent-plan";
|
||||
|
||||
const PLAN_CONFIG = {
|
||||
coding: {
|
||||
provider: CODING_PLAN_PROVIDER,
|
||||
name: "Volcano Ark Coding Plan",
|
||||
usageAction: "GetCodingPlanUsage",
|
||||
listModelAction: "ListArkCodeLatestModel",
|
||||
listModelPayload: {},
|
||||
referer: "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan",
|
||||
listApiKeysPayload: { ProjectName: "default" },
|
||||
},
|
||||
agent: {
|
||||
provider: AGENT_PLAN_PROVIDER,
|
||||
name: "Volcano Ark Agent Plan",
|
||||
usageAction: "GetAgentPlanAFPUsage",
|
||||
listModelAction: "GetAgentPlanModelMappingMeta",
|
||||
listModelPayload: { Edition: "agent_plan_personal" },
|
||||
referer: "https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan",
|
||||
listApiKeysPayload: {
|
||||
ProjectName: "default",
|
||||
Filter: { Scene: "RealAgentPlanPersonal" },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
type PlanKind = keyof typeof PLAN_CONFIG;
|
||||
|
||||
export interface ConsoleApiResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: JsonRecord;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function record(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function buildCookieHeader(credentials: JsonRecord): string {
|
||||
const rawCookie = stringField(credentials.volcConsoleCookie);
|
||||
if (rawCookie) return rawCookie;
|
||||
|
||||
const names = ["digest", "AccountID", "csrfToken", "userInfo"];
|
||||
return names
|
||||
.map((name) => {
|
||||
const value = stringField(credentials[name]);
|
||||
return value ? `${name}=${value}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function extractCsrf(credentials: JsonRecord, cookieHeader: string): string {
|
||||
const explicit = stringField(credentials.volcCsrfToken) || stringField(credentials.csrfToken);
|
||||
if (explicit) return explicit;
|
||||
return cookieHeader.match(/(?:^|;\s*)csrfToken=([^;]+)/)?.[1]?.trim() || "";
|
||||
}
|
||||
|
||||
export async function callConsoleApi(
|
||||
action: string,
|
||||
payload: JsonRecord,
|
||||
cookieHeader: string,
|
||||
csrfToken: string,
|
||||
referer: string
|
||||
): Promise<ConsoleApiResult> {
|
||||
const response = await fetch(`${CONSOLE_TOP_BASE}/${action}?`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"content-type": "application/json",
|
||||
cookie: cookieHeader,
|
||||
origin: "https://console.volcengine.com",
|
||||
referer,
|
||||
"x-csrf-token": csrfToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await response.text();
|
||||
let json: JsonRecord = {};
|
||||
try {
|
||||
json = record(JSON.parse(text));
|
||||
} catch {
|
||||
// Non-JSON console failures are reported through `error` below.
|
||||
}
|
||||
const meta = record(json.ResponseMetadata);
|
||||
const err = record(meta.Error);
|
||||
const message = stringField(err.Message);
|
||||
return {
|
||||
ok: response.ok && !message,
|
||||
status: response.status,
|
||||
json,
|
||||
error: message || (response.ok ? null : text.slice(0, 200)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectPlan(
|
||||
kind: PlanKind,
|
||||
cookieHeader: string,
|
||||
csrfToken: string
|
||||
): Promise<{ available: boolean; usage: JsonRecord; error: string | null }> {
|
||||
const cfg = PLAN_CONFIG[kind];
|
||||
const result = await callConsoleApi(cfg.usageAction, {}, cookieHeader, csrfToken, cfg.referer);
|
||||
if (!result.ok) {
|
||||
return { available: false, usage: {}, error: result.error };
|
||||
}
|
||||
return { available: true, usage: record(result.json.Result), error: null };
|
||||
}
|
||||
|
||||
function firstApiKeyItem(result: JsonRecord): JsonRecord | null {
|
||||
const items = record(result.Result).Items;
|
||||
if (!Array.isArray(items)) return null;
|
||||
return record(items[0]);
|
||||
}
|
||||
|
||||
async function fetchRawApiKey(
|
||||
kind: PlanKind,
|
||||
cookieHeader: string,
|
||||
csrfToken: string
|
||||
): Promise<{ apiKey: string; id: number | null; maskedKey: string | null; error: string | null }> {
|
||||
const cfg = PLAN_CONFIG[kind];
|
||||
const list = await callConsoleApi(
|
||||
"ListApiKeys",
|
||||
cfg.listApiKeysPayload,
|
||||
cookieHeader,
|
||||
csrfToken,
|
||||
cfg.referer
|
||||
);
|
||||
if (!list.ok) {
|
||||
return { apiKey: "", id: null, maskedKey: null, error: list.error || "ListApiKeys failed" };
|
||||
}
|
||||
|
||||
const item = firstApiKeyItem(list.json);
|
||||
const id = Number(item?.Id);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return { apiKey: "", id: null, maskedKey: null, error: "No API key found for this plan" };
|
||||
}
|
||||
|
||||
const raw = await callConsoleApi(
|
||||
"GetRawApiKey",
|
||||
{ Id: id },
|
||||
cookieHeader,
|
||||
csrfToken,
|
||||
cfg.referer
|
||||
);
|
||||
if (!raw.ok) {
|
||||
return { apiKey: "", id, maskedKey: stringField(item?.Key) || null, error: raw.error };
|
||||
}
|
||||
|
||||
const apiKey = stringField(record(raw.json.Result).ApiKey);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
apiKey: "",
|
||||
id,
|
||||
maskedKey: stringField(item?.Key) || null,
|
||||
error: "Raw API key missing",
|
||||
};
|
||||
}
|
||||
return { apiKey, id, maskedKey: stringField(item?.Key) || null, error: null };
|
||||
}
|
||||
|
||||
async function upsertConnection(
|
||||
kind: PlanKind,
|
||||
apiKey: string,
|
||||
cookieHeader: string,
|
||||
csrfToken: string,
|
||||
apiKeyId: number | null,
|
||||
usage: JsonRecord
|
||||
) {
|
||||
const cfg = PLAN_CONFIG[kind];
|
||||
const providerSpecificData = {
|
||||
volcConsoleCookie: cookieHeader,
|
||||
volcCsrfToken: csrfToken,
|
||||
volcApiKeyId: apiKeyId,
|
||||
volcPlanKind: kind,
|
||||
volcLastUsage: usage,
|
||||
// Enable 24h model auto-sync (modelSyncScheduler picks up autoSync:true).
|
||||
autoSync: true,
|
||||
};
|
||||
|
||||
const existing = (await getProviderConnections({ provider: cfg.provider })).find(
|
||||
(conn: JsonRecord) => stringField(conn.name) === cfg.name
|
||||
);
|
||||
|
||||
if (existing?.id) {
|
||||
return await updateProviderConnection(stringField(existing.id), {
|
||||
apiKey,
|
||||
name: cfg.name,
|
||||
providerSpecificData,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
}
|
||||
|
||||
return await createProviderConnection({
|
||||
provider: cfg.provider,
|
||||
authType: "apikey",
|
||||
name: cfg.name,
|
||||
apiKey,
|
||||
providerSpecificData,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
});
|
||||
}
|
||||
|
||||
export async function bindVolcenginePlansFromConsoleCredentials(credentials: JsonRecord) {
|
||||
const cookieHeader = buildCookieHeader(credentials);
|
||||
const csrfToken = extractCsrf(credentials, cookieHeader);
|
||||
if (!cookieHeader || !csrfToken) {
|
||||
throw new Error("Volcano console cookie or csrfToken is missing");
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
plan: PlanKind;
|
||||
available: boolean;
|
||||
ok: boolean;
|
||||
connectionId?: string;
|
||||
apiKeyId?: number | null;
|
||||
maskedKey?: string | null;
|
||||
error?: string | null;
|
||||
}> = [];
|
||||
|
||||
for (const kind of ["coding", "agent"] as PlanKind[]) {
|
||||
const detected = await detectPlan(kind, cookieHeader, csrfToken);
|
||||
if (!detected.available) {
|
||||
results.push({ plan: kind, available: false, ok: false, error: detected.error });
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = await fetchRawApiKey(kind, cookieHeader, csrfToken);
|
||||
if (!key.apiKey) {
|
||||
results.push({
|
||||
plan: kind,
|
||||
available: true,
|
||||
ok: false,
|
||||
apiKeyId: key.id,
|
||||
maskedKey: key.maskedKey,
|
||||
error: key.error,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const connection = await upsertConnection(
|
||||
kind,
|
||||
key.apiKey,
|
||||
cookieHeader,
|
||||
csrfToken,
|
||||
key.id,
|
||||
detected.usage
|
||||
);
|
||||
results.push({
|
||||
plan: kind,
|
||||
available: true,
|
||||
ok: Boolean(connection?.id),
|
||||
connectionId: stringField(connection?.id),
|
||||
apiKeyId: key.id,
|
||||
maskedKey: key.maskedKey,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
cookieCaptured: true,
|
||||
results,
|
||||
};
|
||||
}
|
||||
400
src/lib/providers/volcenginePlanModelDiscovery.ts
Normal file
400
src/lib/providers/volcenginePlanModelDiscovery.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Volcano Ark Plan — live model discovery via console APIs.
|
||||
*
|
||||
* Both Plan subscriptions have NO usable `/models` endpoint on the chat API
|
||||
* (`/api/plan/v3` returns 404; coding `/api/coding/v3/models` is unreliable).
|
||||
* The authoritative model catalog is instead exposed by the console's
|
||||
* top-level Ark actions, authenticated by the same console cookie + csrf
|
||||
* token already captured during plan binding (see volcenginePlanBinding.ts).
|
||||
*
|
||||
* - Agent Plan: `ListAgentPlanLatestModel` → Result.Data[]
|
||||
* id : ModelId (version-suffixed, matches chat endpoint)
|
||||
* - Coding Plan: `ListArkCodeLatestModel` → Result.Data[]
|
||||
* id : ModelId (version-suffixed)
|
||||
*
|
||||
* Both APIs return the same response shape (ModelId / OutputName / Enabled /
|
||||
* Description / EnabledThinking). We keep ALL entries — the chat endpoint
|
||||
* accepts every listed ModelId, and `Enabled` only reflects console visibility.
|
||||
*
|
||||
* The console API returns only id/name/description — NOT capabilities
|
||||
* (contextLength, toolCalling, vision, reasoning). We enrich each discovered
|
||||
* model from a static family→capability map keyed by the OutputName/ModelName
|
||||
* prefix, falling back to conservative defaults so new families stay usable
|
||||
* without a code change.
|
||||
*
|
||||
* Output shape matches SyncedAvailableModelInput so the sync-models route can
|
||||
* persist it via replaceSyncedAvailableModelsForConnection.
|
||||
*/
|
||||
|
||||
import type { SyncedAvailableModelInput } from "@/lib/db/models/synced";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type VolcPlanKind = "agent" | "coding";
|
||||
|
||||
export interface DiscoveredVolcModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabledThinking?: boolean;
|
||||
}
|
||||
|
||||
const CONSOLE_TOP_BASE = "https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01";
|
||||
const AGENT_PLAN_REFERER =
|
||||
"https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan";
|
||||
const CODING_PLAN_REFERER =
|
||||
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan";
|
||||
|
||||
function stringField(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
function record(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
interface ConsoleApiResult {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: JsonRecord;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hit the Volcano console API directly via undici, BYPASSING OmniRoute's
|
||||
* global fetch patch (open-sse/utils/proxyFetch.ts) which is built for LLM
|
||||
* provider traffic and reroutes/rewrites requests to console.volcengine.com.
|
||||
* Dynamic import so the build cannot extern/strip the dependency.
|
||||
*/
|
||||
async function callConsoleApiDirect(
|
||||
action: string,
|
||||
payload: JsonRecord,
|
||||
cookieHeader: string,
|
||||
csrfToken: string,
|
||||
referer: string
|
||||
): Promise<ConsoleApiResult> {
|
||||
const { fetch: pristineFetch } = await import("undici");
|
||||
const response = await pristineFetch(`${CONSOLE_TOP_BASE}/${action}?`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
"content-type": "application/json",
|
||||
cookie: cookieHeader,
|
||||
origin: "https://console.volcengine.com",
|
||||
referer,
|
||||
"x-csrf-token": csrfToken,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await response.text();
|
||||
let json: JsonRecord = {};
|
||||
try {
|
||||
json = record(JSON.parse(text));
|
||||
} catch {
|
||||
// Non-JSON console failures are reported through `error` below.
|
||||
}
|
||||
const meta = record(json.ResponseMetadata);
|
||||
const err = record(meta.Error);
|
||||
const message = stringField(err.Message);
|
||||
return {
|
||||
ok: response.ok && !message,
|
||||
status: response.status,
|
||||
json,
|
||||
error: message || (response.ok ? null : text.slice(0, 200)),
|
||||
};
|
||||
}
|
||||
|
||||
async function detectPlan(
|
||||
kind: VolcPlanKind,
|
||||
cookieHeader: string,
|
||||
csrfToken: string
|
||||
): Promise<{ available: boolean; error: string | null }> {
|
||||
const action = kind === "agent" ? "GetAgentPlanAFPUsage" : "GetCodingPlanUsage";
|
||||
const referer = kind === "agent" ? AGENT_PLAN_REFERER : CODING_PLAN_REFERER;
|
||||
const result = await callConsoleApiDirect(action, {}, cookieHeader, csrfToken, referer);
|
||||
if (!result.ok) {
|
||||
return { available: false, error: result.error };
|
||||
}
|
||||
return { available: true, error: null };
|
||||
}
|
||||
|
||||
const PLAN_DISCOVERY_CONFIG: Record<
|
||||
VolcPlanKind,
|
||||
{
|
||||
action: string;
|
||||
/** Base payload; coding plan needs AccountId injected per-request. */
|
||||
payload: JsonRecord;
|
||||
referer: string;
|
||||
/** Whether the listing API requires the console AccountId in the body. */
|
||||
requiresAccountId: boolean;
|
||||
}
|
||||
> = {
|
||||
agent: {
|
||||
action: "ListAgentPlanLatestModel",
|
||||
payload: {},
|
||||
referer: AGENT_PLAN_REFERER,
|
||||
requiresAccountId: false,
|
||||
},
|
||||
coding: {
|
||||
action: "ListArkCodeLatestModel",
|
||||
payload: {},
|
||||
referer: CODING_PLAN_REFERER,
|
||||
requiresAccountId: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the numeric `AccountID` from the console cookie jar. The Coding Plan
|
||||
* listing API requires `{AccountId: <number>}` in the body (string is rejected
|
||||
* with InvalidParameter). The AccountID is always present in an authenticated
|
||||
* console cookie, so this avoids a separate binding field / DB migration.
|
||||
*/
|
||||
function extractAccountId(cookieHeader: string): number | null {
|
||||
const raw = cookieHeader.match(/(?:^|;\s*)AccountID=([^;]+)/i)?.[1]?.trim();
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Family→capability enrichment. The console API does not return context
|
||||
* window / tool / vision / reasoning flags, so we seed them from the model
|
||||
* family. Keyed by the canonical model name (RespModelName / OutputName /
|
||||
* ModelName) lowercased; a `*`-prefixed entry matches by prefix.
|
||||
*
|
||||
* Values mirror the curated static registry (volcengine/{agent,coding}-plan)
|
||||
* so behavior is unchanged for known models; unknown families fall back to
|
||||
* `enrichWithDefaults`.
|
||||
*/
|
||||
const FAMILY_CAPABILITY_MAP: Array<{
|
||||
match: string;
|
||||
contextLength: number;
|
||||
toolCalling: boolean;
|
||||
supportsVision: boolean;
|
||||
supportsReasoning: boolean;
|
||||
}> = [
|
||||
// Doubao Seed 2.x turbo / mini — 256K, multimodal
|
||||
{
|
||||
match: "doubao-seed-2-1-turbo",
|
||||
contextLength: 262144,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
match: "doubao-seed-2-0-mini",
|
||||
contextLength: 262144,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
// Doubao Seed 2.0 lite — 256K, multimodal
|
||||
{
|
||||
match: "doubao-seed-2-0-lite",
|
||||
contextLength: 262144,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
// Doubao Seed Evolving — 1M
|
||||
{
|
||||
match: "doubao-seed-evolving",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
// DeepSeek V4 family — 1M, text-only reasoning
|
||||
{
|
||||
match: "deepseek-v4",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
// GLM 5.x — 1M
|
||||
{
|
||||
match: "glm-5",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
// Kimi K3 / K2.7 code — 1M, multimodal
|
||||
{
|
||||
match: "kimi-k3",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
match: "kimi-k2.7-code",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
match: "kimi-k2-7-code",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: true,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
// Kimi K2.6 — 1M
|
||||
{
|
||||
match: "kimi-k2.6",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
// MiniMax M3 / M2.7 — 1M
|
||||
{
|
||||
match: "minimax-m3",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
{
|
||||
match: "minimax-m2.7",
|
||||
contextLength: 1048576,
|
||||
toolCalling: true,
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_CAPABILITY = {
|
||||
contextLength: 131072,
|
||||
toolCalling: true,
|
||||
supportsVision: false,
|
||||
supportsReasoning: true,
|
||||
};
|
||||
|
||||
function matchFamily(name: string) {
|
||||
const lower = name.trim().toLowerCase();
|
||||
if (!lower) return null;
|
||||
// Prefer exact match, then prefix match.
|
||||
for (const entry of FAMILY_CAPABILITY_MAP) {
|
||||
if (entry.match === lower) return entry;
|
||||
}
|
||||
for (const entry of FAMILY_CAPABILITY_MAP) {
|
||||
if (lower.startsWith(entry.match)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function enrichModel(model: DiscoveredVolcModel): SyncedAvailableModelInput {
|
||||
const family = matchFamily(model.name) ?? matchFamily(model.id) ?? DEFAULT_CAPABILITY;
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name || model.id,
|
||||
source: "imported",
|
||||
apiFormat: "chat-completions",
|
||||
supportedEndpoints: ["chat"],
|
||||
inputTokenLimit: family.contextLength,
|
||||
supportsTools: family.toolCalling,
|
||||
supportsVision: family.supportsVision,
|
||||
supportsThinking: model.enabledThinking ?? family.supportsReasoning,
|
||||
...(model.description ? { description: model.description } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `ListAgentPlanLatestModel` / `ListArkCodeLatestModel` Result.Data[].
|
||||
*
|
||||
* Both console APIs return the same response shape: each entry has
|
||||
* `ModelId` (the version-suffixed ID accepted by the chat endpoint),
|
||||
* `OutputName` / `ModelName` (the canonical family name used for capability
|
||||
* enrichment), `Enabled` (console visibility — not API availability), and
|
||||
* optional `Description` / `EnabledThinking`.
|
||||
*
|
||||
* We keep ALL entries with a non-empty `ModelId`. The chat endpoint accepts
|
||||
* every listed model; `Enabled` only controls whether the model appears in
|
||||
* the console's model picker, so filtering on it would hide callable models.
|
||||
*/
|
||||
export function parseLatestModelList(json: JsonRecord): DiscoveredVolcModel[] {
|
||||
const data = record(json.Result).Data;
|
||||
const arr = Array.isArray(data) ? data : [];
|
||||
const out: DiscoveredVolcModel[] = [];
|
||||
for (const raw of arr) {
|
||||
const item = record(raw);
|
||||
const id = stringField(item.ModelId);
|
||||
if (!id) continue;
|
||||
const name = stringField(item.OutputName) || stringField(item.ModelName) || id;
|
||||
const enabledThinking = item.EnabledThinking === true || item.EnabledThinking === "true";
|
||||
const desc = stringField(item.Description);
|
||||
out.push({
|
||||
id,
|
||||
name,
|
||||
...(desc ? { description: desc } : {}),
|
||||
...(enabledThinking ? { enabledThinking: true } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the live model list for a Volcano Ark plan subscription using the
|
||||
* console cookie + csrf token stored on the connection's providerSpecificData.
|
||||
*
|
||||
* Verifies the plan subscription is still active (detectPlan) before listing,
|
||||
* so an expired/disabled plan returns a clear error instead of a stale/empty
|
||||
* catalog that would erase the user's synced models.
|
||||
*/
|
||||
export async function fetchVolcPlanModels(
|
||||
kind: VolcPlanKind,
|
||||
cookieHeader: string,
|
||||
csrfToken: string
|
||||
): Promise<SyncedAvailableModelInput[]> {
|
||||
if (!cookieHeader || !csrfToken) {
|
||||
throw new Error("Volcano console cookie or csrfToken is missing — re-bind the plan");
|
||||
}
|
||||
|
||||
// Validate the subscription/credentials are still live.
|
||||
const detected = await detectPlan(kind, cookieHeader, csrfToken);
|
||||
if (!detected.available) {
|
||||
throw new Error(
|
||||
`Volcano ${kind} plan unavailable${detected.error ? `: ${detected.error}` : ""} — re-bind the plan`
|
||||
);
|
||||
}
|
||||
|
||||
const cfg = PLAN_DISCOVERY_CONFIG[kind];
|
||||
const payload: JsonRecord = { ...cfg.payload };
|
||||
if (cfg.requiresAccountId) {
|
||||
const accountId = extractAccountId(cookieHeader);
|
||||
if (accountId === null) {
|
||||
throw new Error(
|
||||
`Volcano ${kind} plan discovery requires AccountId, but none found in console cookie — re-bind the plan`
|
||||
);
|
||||
}
|
||||
payload.AccountId = accountId;
|
||||
}
|
||||
const result = await callConsoleApiDirect(
|
||||
cfg.action,
|
||||
payload,
|
||||
cookieHeader,
|
||||
csrfToken,
|
||||
cfg.referer
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`Volcano ${kind} plan model discovery (${cfg.action}) failed${result.error ? `: ${result.error}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
const discovered = parseLatestModelList(result.json);
|
||||
if (discovered.length === 0) {
|
||||
throw new Error(`Volcano ${kind} plan returned no usable models`);
|
||||
}
|
||||
return discovered.map(enrichModel);
|
||||
}
|
||||
|
||||
export function providerToVolcPlanKind(providerId: string): VolcPlanKind | null {
|
||||
const id = providerId.trim().toLowerCase();
|
||||
if (id === "volcengine-agent-plan") return "agent";
|
||||
if (id === "volcengine-coding-plan") return "coding";
|
||||
return null;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
calculateFactors,
|
||||
calculateScore,
|
||||
DEFAULT_WEIGHTS,
|
||||
normalizeScoringWeights,
|
||||
type ProviderCandidate,
|
||||
type ScoringFactors,
|
||||
type ScoringWeights,
|
||||
@@ -122,8 +123,11 @@ function resolveModePackName(config: Record<string, unknown>): string | null {
|
||||
|
||||
/** Resolves an explicit, validated `weights` object from the config, if present. */
|
||||
function resolveExplicitWeights(config: Record<string, unknown>): ScoringWeights | undefined {
|
||||
const explicitWeights = isRecord(config.weights) ? (config.weights as ScoringWeights) : undefined;
|
||||
return explicitWeights && validateWeights(explicitWeights) ? explicitWeights : undefined;
|
||||
if (!isRecord(config.weights)) return undefined;
|
||||
const explicitWeights = config.weights as ScoringWeights;
|
||||
if (validateWeights(explicitWeights)) return explicitWeights;
|
||||
const normalized = normalizeScoringWeights(config.weights as Partial<ScoringWeights>);
|
||||
return validateWeights(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function resolveInspectorWeights(combo: ComboRecord | undefined): InspectorWeights {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
import { mergeProviderLimitsCacheEntry, toProviderLimitsCacheEntry } from "./providerLimitsCache";
|
||||
import { getExecutor } from "@omniroute/open-sse/executors/index.ts";
|
||||
import { getUsageForProvider } from "@omniroute/open-sse/services/usage.ts";
|
||||
import { cooldownUntilMs } from "@omniroute/open-sse/services/accountFallback.ts";
|
||||
import {
|
||||
rotationGroupFor,
|
||||
serializeRefresh,
|
||||
@@ -99,6 +100,9 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
||||
"hyperagent",
|
||||
"ha",
|
||||
"firecrawl",
|
||||
// Volcano Ark Plan subscriptions (agent-plan / coding-plan)
|
||||
"volcengine-agent-plan",
|
||||
"volcengine-coding-plan",
|
||||
// Command Code API key → /alpha/billing/credits + windowLimits
|
||||
"command-code",
|
||||
"conol-web",
|
||||
@@ -459,66 +463,57 @@ function windowStillExhaustedAfterRealReset(value: unknown, nowMs: number): bool
|
||||
return resetMs > nowMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is an explicit cooldown still in the future?
|
||||
*
|
||||
* A rateLimitedUntil set by the upstream 429 handler is a hard statement and
|
||||
* must never be overruled by a quota poll.
|
||||
*
|
||||
* Gate on the timestamp alone; lastErrorType stays irrelevant here.
|
||||
*/
|
||||
export function hasActiveCooldown(
|
||||
connection: Pick<ProviderConnectionLike, "rateLimitedUntil">,
|
||||
now: number = Date.now()
|
||||
): boolean {
|
||||
if (!connection.rateLimitedUntil) return false;
|
||||
// #3954: the rate_limited_until TEXT column holds an ISO string (dashboard/AUTH
|
||||
// path) OR numeric epoch ms (setConnectionRateLimitUntil, the chat path). A bare
|
||||
// `new Date(String(...))` yields Invalid Date for the numeric form, which read as
|
||||
// "no cooldown" and let every poller wipe a chat-path-written lockout. Use the
|
||||
// canonical parser connectionRecovery.ts already relies on.
|
||||
const until = cooldownUntilMs(connection.rateLimitedUntil as string | number | null | undefined);
|
||||
return Number.isFinite(until) && until > now;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a connection test may wipe the persisted error/cooldown state.
|
||||
*
|
||||
* A successful probe proves the CREDENTIAL is valid; it does not prove an
|
||||
* exhausted quota window reopened — the probe is a cheap auth/models call that
|
||||
* never touches the chat quota a weekly cap applies to. The credential-health
|
||||
* scheduler runs that probe against every connection every 300s, so without this
|
||||
* gate a weekly-capped connection was reset to `active` / `rateLimitedUntil=null`
|
||||
* within 30s of every restart and dispatched straight back into the same 429.
|
||||
*
|
||||
* Same rule as `maybeClearRecoveredQuotaState`: a future `rateLimitedUntil` is
|
||||
* the 429 handler's hard statement and no poller may overrule it. Once the
|
||||
* window elapses, the next probe clears the state normally.
|
||||
*/
|
||||
export function shouldClearErrorStateOnValidProbe(
|
||||
connection: Pick<ProviderConnectionLike, "rateLimitedUntil">,
|
||||
probeValid: boolean,
|
||||
now: number = Date.now()
|
||||
): boolean {
|
||||
return probeValid && !hasActiveCooldown(connection, now);
|
||||
}
|
||||
|
||||
export async function maybeClearRecoveredQuotaState(
|
||||
connection: ProviderConnectionLike,
|
||||
usage: JsonRecord
|
||||
): Promise<ProviderConnectionLike> {
|
||||
if (!hasUsableQuota(usage)) return connection;
|
||||
if (isTerminalStatusForQuotaRecovery(connection.testStatus)) return connection;
|
||||
if (connection.lastErrorType === "quota_exhausted") {
|
||||
if (
|
||||
connection.lastErrorSource === CLAUDE_EXTRA_USAGE_ERROR_SOURCE &&
|
||||
isClaudeExtraUsageBlockEnabled(connection.provider, connection.providerSpecificData) &&
|
||||
isClaudeExtraUsageQueued(usage)
|
||||
) {
|
||||
// Claude's pay-as-you-go extra-usage block is orthogonal to the
|
||||
// session/weekly quota windows checked below: the upstream can report a
|
||||
// fully recovered quota window while extraUsage.queued is still true.
|
||||
// Only syncClaudeExtraUsageStateIfNeeded (buildClaudeExtraUsageConnectionUpdate)
|
||||
// owns clearing this specific state — the general window-recovery logic
|
||||
// below must not release it just because some quota window looks fresh.
|
||||
return connection;
|
||||
}
|
||||
|
||||
const quotas = usage?.quotas;
|
||||
if (isRecord(quotas)) {
|
||||
// Honor the REAL per-window resetAt from the freshly fetched quota
|
||||
// instead of the synthetic cooldown persisted at failure time (e.g.
|
||||
// Claude's flat 1h SUBSCRIPTION_QUOTA_COOLDOWN_MS when no upstream
|
||||
// reset was parseable). Only stay locked if some window that governs
|
||||
// this connection's quota is still demonstrably exhausted.
|
||||
const anyStillBlocking = Object.values(quotas).some((value) =>
|
||||
windowStillExhaustedAfterRealReset(value, Date.now())
|
||||
);
|
||||
if (anyStillBlocking) return connection;
|
||||
} else if (
|
||||
connection.rateLimitedUntil &&
|
||||
new Date(connection.rateLimitedUntil).getTime() > Date.now()
|
||||
) {
|
||||
// No quota object at all (degraded/failed fetch shape) — fall back to
|
||||
// the previous synthetic-cooldown guard.
|
||||
return connection;
|
||||
}
|
||||
} else if (
|
||||
connection.rateLimitedUntil &&
|
||||
new Date(connection.rateLimitedUntil).getTime() > Date.now()
|
||||
) {
|
||||
// Universal fallback guard for every lastErrorType other than
|
||||
// "quota_exhausted" (which gets the more precise per-window check above,
|
||||
// and may legitimately release early once the REAL window has reset even
|
||||
// while a synthetic rateLimitedUntil is still in the future). A future
|
||||
// rateLimitedUntil is a hard statement made by the 429/error handler that
|
||||
// persisted it (src/sse/services/auth.ts, src/app/api/providers/[id]/test/
|
||||
// route.ts) — no quota poll finding *some* usable window elsewhere should
|
||||
// be able to overrule it. Before this fix, ANY lastErrorType other than
|
||||
// "quota_exhausted" skipped straight to hasTransientState/
|
||||
// clearRecoveredProviderState() below with no rateLimitedUntil check at
|
||||
// all, so a multi-day cooldown (observed: 146h, Z.AI weekly quota) got
|
||||
// cleared on the very next quota sync a few minutes later — a
|
||||
// self-restart/burn loop that kept burning real upstream calls against a
|
||||
// known-exhausted connection (#11277).
|
||||
return connection;
|
||||
}
|
||||
if (hasActiveCooldown(connection)) return connection;
|
||||
|
||||
const hasTransientState =
|
||||
connection.testStatus === "unavailable" ||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
PUBLIC_READONLY_API_ROUTE_PREFIXES,
|
||||
PUBLIC_READONLY_METHODS,
|
||||
isPublicApiRoute,
|
||||
isPublicReadonlyCorsRoute,
|
||||
} from "../../shared/constants/publicApiRoutes";
|
||||
import type { ClassificationReason, RouteClassification } from "./types";
|
||||
|
||||
@@ -135,8 +134,9 @@ export function classifyRoute(rawPath: string, method: string = "GET"): RouteCla
|
||||
}
|
||||
|
||||
function matchesReadonlyPublic(path: string, method: string): boolean {
|
||||
if (!PUBLIC_READONLY_METHODS.has(String(method).toUpperCase())) return false;
|
||||
return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((p) => path.startsWith(p));
|
||||
// Exact match, not startsWith: a prefix here would hand the CORS origin
|
||||
// relaxation to every adjacent path too (GHSA-74g9-q8f6-793h).
|
||||
return isPublicReadonlyCorsRoute(path, method);
|
||||
}
|
||||
|
||||
function isClassifiedAsPublic(path: string, method: string): boolean {
|
||||
|
||||
@@ -95,6 +95,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
|
||||
*/
|
||||
export const LOCAL_ONLY_API_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^\/api\/providers\/[^/]+\/login\/?$/,
|
||||
/^\/api\/providers\/volcengine-plan\/connect(\/.*)?$/, // manual headful flow + session-based phone/SMS auto-login (both spawn Playwright)
|
||||
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/,
|
||||
/^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/,
|
||||
];
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AUDIO_ONLY_PROVIDERS } from "./providers/audio";
|
||||
import { UPSTREAM_PROXY_PROVIDERS } from "./providers/upstream-proxy";
|
||||
import { CLOUD_AGENT_PROVIDERS } from "./providers/cloud-agent";
|
||||
import { SYSTEM_PROVIDERS } from "./providers/system";
|
||||
import { validateProviders } from "../validation/providerSchema";
|
||||
|
||||
export const FREE_PROVIDERS = {};
|
||||
|
||||
@@ -74,6 +75,7 @@ export function getProviderConnectionFamilyIds(providerId: unknown): readonly st
|
||||
|
||||
// Web / Cookie Providers
|
||||
|
||||
|
||||
// API Key Providers
|
||||
|
||||
// Sub-categories within APIKEY_PROVIDERS (used by dashboard and catalog views).
|
||||
@@ -142,6 +144,7 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([
|
||||
"helixmind",
|
||||
"tabitoken",
|
||||
"logfare",
|
||||
|
||||
]);
|
||||
|
||||
export const ENTERPRISE_CLOUD_PROVIDER_IDS = new Set([
|
||||
@@ -307,10 +310,27 @@ const _PROVIDER_SECTIONS = [
|
||||
SYSTEM_PROVIDERS,
|
||||
] as const;
|
||||
|
||||
let _validated = false;
|
||||
|
||||
function ensureProvidersValidated() {
|
||||
if (_validated) return;
|
||||
validateProviders(NOAUTH_PROVIDERS, "NOAUTH_PROVIDERS");
|
||||
validateProviders(OAUTH_PROVIDERS, "OAUTH_PROVIDERS");
|
||||
validateProviders(APIKEY_PROVIDERS, "APIKEY_PROVIDERS");
|
||||
validateProviders(WEB_COOKIE_PROVIDERS, "WEB_COOKIE_PROVIDERS");
|
||||
validateProviders(LOCAL_PROVIDERS, "LOCAL_PROVIDERS");
|
||||
validateProviders(SEARCH_PROVIDERS, "SEARCH_PROVIDERS");
|
||||
validateProviders(AUDIO_ONLY_PROVIDERS, "AUDIO_ONLY_PROVIDERS");
|
||||
validateProviders(UPSTREAM_PROXY_PROVIDERS, "UPSTREAM_PROXY_PROVIDERS");
|
||||
validateProviders(CLOUD_AGENT_PROVIDERS, "CLOUD_AGENT_PROVIDERS");
|
||||
_validated = true;
|
||||
}
|
||||
|
||||
let _aiProviders: Record<string, any> | null = null;
|
||||
|
||||
function getOrCreateAiProviders(): Record<string, any> {
|
||||
if (!_aiProviders) {
|
||||
ensureProvidersValidated();
|
||||
_aiProviders = {};
|
||||
for (const section of _PROVIDER_SECTIONS) {
|
||||
Object.assign(_aiProviders, section);
|
||||
@@ -505,6 +525,9 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"grok-cli",
|
||||
// Firecrawl team credits (GET /v2/team/credit-usage)
|
||||
"firecrawl",
|
||||
// Volcano Ark Plan subscriptions (agent-plan / coding-plan)
|
||||
"volcengine-agent-plan",
|
||||
"volcengine-coding-plan",
|
||||
// Command Code credits + 5h/weekly rolling windows
|
||||
"command-code",
|
||||
"conol-web",
|
||||
@@ -517,7 +540,8 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"agentrouter",
|
||||
];
|
||||
|
||||
// ── Zod validation at module load (Phase 7.2) ──
|
||||
// ── Zod validation, lazily on first AI_PROVIDERS access (perf: skips the walk
|
||||
// for processes that never touch AI_PROVIDERS, e.g. short-lived CLI commands) ──
|
||||
|
||||
// Re-export the extracted data catalogs so external importers of providers.ts are unchanged.
|
||||
export {
|
||||
@@ -532,15 +556,3 @@ export {
|
||||
CLOUD_AGENT_PROVIDERS,
|
||||
SYSTEM_PROVIDERS,
|
||||
};
|
||||
|
||||
import { validateProviders } from "../validation/providerSchema";
|
||||
|
||||
validateProviders(NOAUTH_PROVIDERS, "NOAUTH_PROVIDERS");
|
||||
validateProviders(OAUTH_PROVIDERS, "OAUTH_PROVIDERS");
|
||||
validateProviders(APIKEY_PROVIDERS, "APIKEY_PROVIDERS");
|
||||
validateProviders(WEB_COOKIE_PROVIDERS, "WEB_COOKIE_PROVIDERS");
|
||||
validateProviders(LOCAL_PROVIDERS, "LOCAL_PROVIDERS");
|
||||
validateProviders(SEARCH_PROVIDERS, "SEARCH_PROVIDERS");
|
||||
validateProviders(AUDIO_ONLY_PROVIDERS, "AUDIO_ONLY_PROVIDERS");
|
||||
validateProviders(UPSTREAM_PROXY_PROVIDERS, "UPSTREAM_PROXY_PROVIDERS");
|
||||
validateProviders(CLOUD_AGENT_PROVIDERS, "CLOUD_AGENT_PROVIDERS");
|
||||
|
||||
@@ -199,6 +199,26 @@ export const APIKEY_PROVIDERS_REGIONAL = {
|
||||
textIcon: "VE",
|
||||
website: "https://www.volcengine.com",
|
||||
},
|
||||
"volcengine-agent-plan": {
|
||||
id: "volcengine-agent-plan",
|
||||
alias: "veap",
|
||||
name: "Volcengine Ark Agent Plan",
|
||||
icon: "local_fire_department",
|
||||
color: "#DC2626",
|
||||
textIcon: "VA",
|
||||
website: "https://console.volcengine.com/ark/region:cn-beijing/subscription/agent-plan",
|
||||
authHint: "Connect your Volcano Engine account or use an Ark Agent Plan subscription API key.",
|
||||
},
|
||||
"volcengine-coding-plan": {
|
||||
id: "volcengine-coding-plan",
|
||||
alias: "vecp",
|
||||
name: "Volcengine Ark Coding Plan",
|
||||
icon: "code",
|
||||
color: "#FF6A00",
|
||||
textIcon: "VC",
|
||||
website: "https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan",
|
||||
authHint: "Connect your Volcano Engine account or use an Ark Coding Plan subscription API key.",
|
||||
},
|
||||
gigachat: {
|
||||
id: "gigachat",
|
||||
alias: "gigachat",
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
// Public API surface, split by SHAPE — this file is matched two different ways
|
||||
// and the distinction is load-bearing (GHSA-74g9-q8f6-793h).
|
||||
//
|
||||
// A prefix is matched with `startsWith()`, so it also matches every adjacent
|
||||
// path that merely shares its leading characters. `/api/usage/om-usage` as a
|
||||
// prefix marked `/api/usage/om-usage<anything>` PUBLIC — and Next resolves that
|
||||
// to the dynamic route `/api/usage/[connectionId]`, whose handler carries no
|
||||
// auth of its own because it relies on being classified MANAGEMENT. Ten other
|
||||
// entries had no shadowing sibling in the route tree today, but any route added
|
||||
// later under a dynamic segment adjacent to one of them would inherit the same
|
||||
// bypass silently.
|
||||
//
|
||||
// So: PREFIXES are genuine subtrees and MUST end in "/" (asserted by
|
||||
// tests/unit/authz/public-route-exact-match.test.ts); single routes live in an
|
||||
// EXACT set instead.
|
||||
|
||||
// Genuine subtrees. Every entry MUST end in "/".
|
||||
const PUBLIC_API_ROUTE_PREFIXES = [
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/status",
|
||||
"/api/auth/oidc/",
|
||||
"/api/init",
|
||||
"/api/v1/",
|
||||
"/api/sync/bundle",
|
||||
"/api/oauth/",
|
||||
// Public, ticket-gated Codex device-flow completion (validate + persist).
|
||||
// The handler enforces its own single-use ticket check; no dashboard auth.
|
||||
"/api/codex/connect/",
|
||||
// Remote-mode bootstrap: exchange the management password for a scoped CLI
|
||||
// access token. The handler enforces its own password check + lockout — there
|
||||
// is no token yet at this point, so it cannot require management auth.
|
||||
"/api/cli/connect",
|
||||
// Terminal-friendly @@om-usage equivalent for CLI clients (Claude Code/Codex).
|
||||
// The handler enforces its own auth via extractUsageCommandApiKey/isValidApiKey
|
||||
// and the allowUsageCommand flag — it must not be gated by management auth.
|
||||
"/api/usage/om-usage",
|
||||
// Chaos Mode external dispatch endpoint (POST /api/skills/collect/chaos).
|
||||
// This entry only bypasses the dashboard requireLogin (cookie) gate — the
|
||||
// handler enforces its own Bearer-token auth (validateApiKey +
|
||||
// chaosModeEnabled check) before doing any work. See src/app/api/skills/
|
||||
// collect/chaos/route.ts. Do not widen this prefix to cover other
|
||||
// /api/skills/collect/* routes without the same per-handler auth.
|
||||
"/api/skills/collect/chaos",
|
||||
// Telegram Bot API update webhook + Mini App proxy. Telegram POSTs updates
|
||||
// here without any dashboard cookie/API key; the handler enforces its own
|
||||
// auth (503 when TELEGRAM_BOT_TOKEN is unset; 401 on invalid initData
|
||||
@@ -38,18 +35,45 @@ const PUBLIC_API_ROUTE_PREFIXES = [
|
||||
"/api/cursor-cli/",
|
||||
];
|
||||
|
||||
const PUBLIC_READONLY_API_ROUTE_PREFIXES = [
|
||||
// Single routes, public by EXACT path (both spellings) — never by prefix.
|
||||
const PUBLIC_API_ROUTES_EXACT = new Set([
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/status",
|
||||
"/api/init",
|
||||
"/api/sync/bundle",
|
||||
// Remote-mode bootstrap: exchange the management password for a scoped CLI
|
||||
// access token. The handler enforces its own password check + lockout — there
|
||||
// is no token yet at this point, so it cannot require management auth.
|
||||
"/api/cli/connect",
|
||||
// Terminal-friendly @@om-usage equivalent for CLI clients (Claude Code/Codex).
|
||||
// The handler enforces its own auth via extractUsageCommandApiKey/isValidApiKey
|
||||
// and the allowUsageCommand flag — it must not be gated by management auth.
|
||||
// EXACT: the sibling `/api/usage/[connectionId]` has no auth of its own.
|
||||
"/api/usage/om-usage",
|
||||
// Chaos Mode external dispatch endpoint (POST /api/skills/collect/chaos).
|
||||
// This entry only bypasses the dashboard requireLogin (cookie) gate — the
|
||||
// handler enforces its own Bearer-token auth (validateApiKey +
|
||||
// chaosModeEnabled check) before doing any work. See src/app/api/skills/
|
||||
// collect/chaos/route.ts. Do not widen it to other /api/skills/collect/*
|
||||
// routes without the same per-handler auth.
|
||||
"/api/skills/collect/chaos",
|
||||
]);
|
||||
|
||||
// Read-only single routes that ALSO take the CORS origin relaxation: they
|
||||
// classify as `public_readonly_prefix`, which authz/pipeline.ts keys on.
|
||||
const PUBLIC_READONLY_CORS_API_ROUTES = [
|
||||
"/api/health/ping",
|
||||
"/api/monitoring/health",
|
||||
"/api/settings/require-login",
|
||||
];
|
||||
|
||||
// Read-only routes public by EXACT path, never by prefix.
|
||||
// Read-only routes public by EXACT path, WITHOUT the CORS relaxation.
|
||||
//
|
||||
// `/api/health` has to be reachable without a key — a probe has none, and a 401 there is
|
||||
// indistinguishable from a wrong key or a missing route. It cannot go in the prefix list
|
||||
// above: `startsWith("/api/health")` would also expose `/api/health/degradation`, which is
|
||||
// authenticated today.
|
||||
// indistinguishable from a wrong key or a missing route. It stays in its own set (rather than
|
||||
// joining PUBLIC_READONLY_CORS_API_ROUTES) so it keeps classifying as `public_prefix`: moving it
|
||||
// would silently widen CORS on it.
|
||||
const PUBLIC_READONLY_API_ROUTES_EXACT = new Set(["/api/health"]);
|
||||
|
||||
const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
@@ -64,6 +88,13 @@ function pathMatchesExactRoute(pathname: string, routePath: string): boolean {
|
||||
return pathname === routePath || pathname === `${routePath}/`;
|
||||
}
|
||||
|
||||
function matchesAnyExactRoute(pathname: string, routes: Iterable<string>): boolean {
|
||||
for (const route of routes) {
|
||||
if (pathMatchesExactRoute(pathname, route)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPublicCloudApiRoute(pathname: string, method: string): boolean {
|
||||
const normalizedMethod = String(method).toUpperCase();
|
||||
return PUBLIC_CLOUD_API_ROUTES.some(
|
||||
@@ -82,6 +113,17 @@ const LOCAL_ONLY_OAUTH_IMPORT_ROUTES = [
|
||||
"/api/oauth/raycast/auto-import",
|
||||
];
|
||||
|
||||
/**
|
||||
* Whether the route classifies as read-only PUBLIC *with* the CORS origin
|
||||
* relaxation (authz/classify.ts reason `public_readonly_prefix`). Exported as a
|
||||
* predicate rather than as the raw list so a caller cannot reintroduce the
|
||||
* prefix match this file exists to prevent.
|
||||
*/
|
||||
export function isPublicReadonlyCorsRoute(pathname: string, method = "GET"): boolean {
|
||||
if (!PUBLIC_READONLY_METHODS.has(String(method).toUpperCase())) return false;
|
||||
return matchesAnyExactRoute(pathname, PUBLIC_READONLY_CORS_API_ROUTES);
|
||||
}
|
||||
|
||||
export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
|
||||
if (
|
||||
LOCAL_ONLY_OAUTH_IMPORT_ROUTES.some(
|
||||
@@ -95,6 +137,10 @@ export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (matchesAnyExactRoute(pathname, PUBLIC_API_ROUTES_EXACT)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (PUBLIC_API_ROUTE_PREFIXES.some((route) => pathname.startsWith(route))) {
|
||||
return true;
|
||||
}
|
||||
@@ -103,18 +149,17 @@ export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const route of PUBLIC_READONLY_API_ROUTES_EXACT) {
|
||||
if (pathMatchesExactRoute(pathname, route)) {
|
||||
return true;
|
||||
}
|
||||
if (matchesAnyExactRoute(pathname, PUBLIC_READONLY_API_ROUTES_EXACT)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((route) => pathname.startsWith(route));
|
||||
return isPublicReadonlyCorsRoute(pathname, method);
|
||||
}
|
||||
|
||||
export {
|
||||
PUBLIC_API_ROUTE_PREFIXES,
|
||||
PUBLIC_READONLY_API_ROUTE_PREFIXES,
|
||||
PUBLIC_API_ROUTES_EXACT,
|
||||
PUBLIC_READONLY_CORS_API_ROUTES,
|
||||
PUBLIC_READONLY_API_ROUTES_EXACT,
|
||||
PUBLIC_READONLY_METHODS,
|
||||
};
|
||||
|
||||
70
src/shared/constants/reservedProviderPrefixes.ts
Normal file
70
src/shared/constants/reservedProviderPrefixes.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Reserved provider prefixes — single source of truth shared by:
|
||||
//
|
||||
// 1. The runtime model resolver guard (src/sse/services/model.ts): user-defined
|
||||
// compatible-node prefixes must not be allowed to shadow built-in provider
|
||||
// ids/aliases, otherwise a node with prefix="cf" would hijack cloudflare-ai
|
||||
// requests (ported from upstream 9router 047fdc89).
|
||||
// 2. The write-path validation schemas (createProviderNodeSchema /
|
||||
// updateProviderNodeSchema in src/shared/validation/schemas/provider.ts):
|
||||
// a prefix that the runtime will never honor must be rejected at creation
|
||||
// time with a clear message instead of silently routing to the built-in
|
||||
// provider (tokenrouter bug: "No active credentials for provider:
|
||||
// tokenrouter" despite a fully configured compatible node).
|
||||
//
|
||||
// Semantics (mirror the original inline runtime guard exactly):
|
||||
// - REGISTRY entry ids + aliases only. Manual alias ids outside REGISTRY
|
||||
// (xiaomi/llamacpp/aq) do NOT intercept nodes at runtime and are therefore
|
||||
// deliberately NOT reserved — including them would cause false-positive
|
||||
// rejections.
|
||||
// - Case-sensitive: mixed-case input like "TokenRouter" does not collide with
|
||||
// the runtime lookup (`Set.has` is exact-match), so it stays allowed.
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
|
||||
let _reserved: Set<string> | null = null;
|
||||
|
||||
function buildReservedProviderPrefixes(): Set<string> {
|
||||
if (_reserved) return _reserved;
|
||||
const reserved = new Set<string>();
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
if (entry?.id) reserved.add(entry.id);
|
||||
if (entry?.alias) reserved.add(entry.alias);
|
||||
}
|
||||
_reserved = reserved;
|
||||
return reserved;
|
||||
}
|
||||
|
||||
/**
|
||||
* All reserved provider prefixes (REGISTRY ids + aliases). Built lazily so the
|
||||
* registry is only walked once per process.
|
||||
*/
|
||||
export function getReservedProviderPrefixes(): ReadonlySet<string> {
|
||||
return buildReservedProviderPrefixes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of unique reserved prefixes (ids + aliases deduplicated). Exposed for
|
||||
* tests/docs so counts are measured, not memorized.
|
||||
*/
|
||||
export const RESERVED_PREFIX_COUNT = buildReservedProviderPrefixes().size;
|
||||
|
||||
/**
|
||||
* Frozen snapshot of the reserved set (test/documentation convenience). Prefer
|
||||
* `isReservedProviderPrefix` / `getReservedProviderPrefixes` on hot paths.
|
||||
*/
|
||||
export const RESERVED_PROVIDER_PREFIXES: ReadonlySet<string> = getReservedProviderPrefixes();
|
||||
|
||||
/**
|
||||
* True when `value` is a reserved provider prefix. Non-strings are never
|
||||
* reserved (mirrors the runtime guard's typeof check).
|
||||
*/
|
||||
export function isReservedProviderPrefix(value: unknown): boolean {
|
||||
return typeof value === "string" && buildReservedProviderPrefixes().has(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod-friendly rejection message for a reserved prefix. Names the colliding
|
||||
* prefix and tells the operator what to pick instead.
|
||||
*/
|
||||
export function reservedProviderPrefixMessage(value: string): string {
|
||||
return `"${value}" is a reserved provider prefix — choose a different prefix (reserved ids/aliases cannot be used for custom nodes because requests like <prefix>/model would always route to the built-in provider)`;
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
|
||||
*/
|
||||
export const SPAWN_CAPABLE_PATTERNS: ReadonlyArray<RegExp> = [
|
||||
/^\/api\/providers\/[^/]+\/login\/?$/, // pre-existing gap: in LOCAL_ONLY_API_PATTERNS today but never in a spawn-capable deny-list
|
||||
/^\/api\/providers\/volcengine-plan\/connect(\/.*)?$/, // launches Playwright to bind a Volcano Engine console session — covers the manual headful flow AND the session-based phone/SMS auto-login sub-routes (/code, /status, /cancel, /resend)
|
||||
/^\/api\/providers\/[^/]+\/refresh-cursor\/?$/, // spawns cursor-agent via renewal.ts (Hard Rules #15 + #17)
|
||||
/^\/api\/providers\/cursor\/agent-availability\/?$/, // static path (no dynamic segment), but kept in this array alongside its /api/providers/ siblings rather than the flat SPAWN_CAPABLE_PREFIXES array — spawns cursor-agent status via checkCursorAgentAvailability()/getCachedCursorAgentAvailability() (Hard Rules #15 + #17)
|
||||
/^\/api\/providers\/[^/]+\/chatgpt-web-codex-doctor\/?$/, // spawns via getTunnelRuntimeStatus() → spawnSync("...","runtimes status") (open-sse/executors/chatgpt-web-codex/tunnelClient.ts). Mirrors LOCAL_ONLY_API_PATTERNS in routeGuard.ts; keep the two in sync (GHSA-9q3h-mjm5-f4gj).
|
||||
|
||||
@@ -59,8 +59,8 @@ export interface LoginShellPathOptions {
|
||||
*/
|
||||
export function getLoginShellPath(opts: LoginShellPathOptions = {}): string | null {
|
||||
const platform = opts.platform ?? process.platform;
|
||||
if (platform !== "darwin") return null;
|
||||
const shell = opts.shell || process.env.SHELL || "/bin/zsh";
|
||||
if (platform !== "darwin" && platform !== "linux") return null;
|
||||
const shell = opts.shell || process.env.SHELL || (platform === "darwin" ? "/bin/zsh" : "/bin/bash");
|
||||
if (!/^[\w./-]+$/.test(shell)) return null;
|
||||
const run =
|
||||
opts.runShell ||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf";
|
||||
import { PUBLIC_API_ROUTE_PREFIXES } from "@/shared/constants/publicApiRoutes";
|
||||
import { isPublicApiRoute } from "@/shared/constants/publicApiRoutes";
|
||||
|
||||
interface CachedDashboardCsrfToken {
|
||||
token: string;
|
||||
@@ -113,11 +113,7 @@ function isClientApiPath(pathname: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function isPublicApiPath(pathname: string): boolean {
|
||||
return PUBLIC_API_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
|
||||
function shouldAttachDashboardCsrf(url: URL): boolean {
|
||||
function shouldAttachDashboardCsrf(url: URL, method: string): boolean {
|
||||
if (
|
||||
TOP_LEVEL_MANAGEMENT_PATH_PREFIXES.some(
|
||||
(prefix) => url.pathname === prefix || url.pathname.startsWith(prefix + "/")
|
||||
@@ -129,7 +125,10 @@ function shouldAttachDashboardCsrf(url: URL): boolean {
|
||||
return (
|
||||
url.pathname.startsWith("/api/") &&
|
||||
url.pathname !== "/api/auth/csrf" &&
|
||||
!isPublicApiPath(url.pathname) &&
|
||||
// Share the server's PUBLIC classification instead of re-scanning the
|
||||
// prefix list here — a second copy is a second chance to disagree with the
|
||||
// authz pipeline (GHSA-74g9-q8f6-793h).
|
||||
!isPublicApiRoute(url.pathname, method) &&
|
||||
!isClientApiPath(url.pathname)
|
||||
);
|
||||
}
|
||||
@@ -150,7 +149,7 @@ function sameOriginDashboardMutation(input: RequestInfo | URL, init?: RequestIni
|
||||
return false;
|
||||
}
|
||||
|
||||
return url.origin === window.location.origin && shouldAttachDashboardCsrf(url);
|
||||
return url.origin === window.location.origin && shouldAttachDashboardCsrf(url, method);
|
||||
}
|
||||
|
||||
function mergedHeaders(input: RequestInfo | URL, init?: RequestInit): Headers {
|
||||
|
||||
@@ -79,7 +79,7 @@ export function toJsonErrorPayload(rawError: unknown, fallbackMessage = "Upstrea
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function extractErrorMessage(value: unknown): string | null {
|
||||
export function extractErrorMessage(value: unknown): string | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as JsonRecord;
|
||||
|
||||
@@ -110,3 +110,48 @@ function extractErrorMessage(value: unknown): string | null {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One-line reason for an upstream failure, for `lastError` and the console.
|
||||
*
|
||||
* A non-string used to collapse to the bare fallback, which is what an operator
|
||||
* then reads in the dashboard. The case that matters most is not a string: a
|
||||
* failed `fetch` arrives as `TypeError: fetch failed` with the actionable part on
|
||||
* `error.cause.code` (ECONNREFUSED, ENOTFOUND, ETIMEDOUT), so a wrong port, a
|
||||
* firewall and a blocked proxy all looked identical.
|
||||
*
|
||||
* Only message-shaped fields and transport codes are read — the value is never
|
||||
* serialized wholesale, so a request body or header attached to an error cannot
|
||||
* leak into the stored reason.
|
||||
*/
|
||||
export function describeUpstreamFailure(
|
||||
value: unknown,
|
||||
fallback = "Provider error",
|
||||
maxLength = 100
|
||||
): string {
|
||||
const clamp = (text: string) => text.replace(/\s+/g, " ").trim().slice(0, maxLength);
|
||||
|
||||
if (typeof value === "string") return value.slice(0, maxLength);
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
|
||||
const record = value as JsonRecord;
|
||||
const cause = record.cause as JsonRecord | undefined;
|
||||
const code =
|
||||
typeof record.code === "string" && record.code
|
||||
? record.code
|
||||
: cause && typeof cause === "object" && typeof cause.code === "string" && cause.code
|
||||
? cause.code
|
||||
: null;
|
||||
|
||||
const nestedError = record.error;
|
||||
const message =
|
||||
extractErrorMessage(value) ??
|
||||
(typeof nestedError === "string" && nestedError.trim()
|
||||
? nestedError.trim()
|
||||
: extractErrorMessage(nestedError));
|
||||
|
||||
if (message) {
|
||||
return code && !message.includes(code) ? clamp(`${message} (${code})`) : clamp(message);
|
||||
}
|
||||
return code ? clamp(`${fallback} (${code})`) : fallback;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,33 @@ export function deriveLiveWsPath(publicUrl?: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The operator-declared public WebSocket URL, resolved at RUNTIME.
|
||||
*
|
||||
* `NEXT_PUBLIC_*` is inlined into the client bundle at BUILD time, so a prebuilt
|
||||
* Docker or npm image can never carry an operator's value — which is exactly why
|
||||
* the server echoes this in `/api/v1/ws?handshake=1` for the client to discover.
|
||||
* Reading only the `NEXT_PUBLIC_`-prefixed name on the server made that echo
|
||||
* unreachable too: behind a reverse proxy the dashboard kept dialling
|
||||
* `wss://<host>:20132/live-ws` and reported "Live disabled" (#11331).
|
||||
*
|
||||
* `LIVE_WS_PUBLIC_URL` is the runtime name, alongside the existing runtime
|
||||
* `LIVE_WS_HOST` / `LIVE_WS_PORT`. The prefixed name still wins nothing and loses
|
||||
* nothing — it stays supported as the fallback so existing deployments that set it
|
||||
* (build-time or in the container) keep working.
|
||||
*/
|
||||
export function resolveLiveWsPublicUrl(env: NodeJS.ProcessEnv = process.env): string | null {
|
||||
const candidates = [env.LIVE_WS_PUBLIC_URL, env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") continue;
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith("ws://") || trimmed.startsWith("wss://")) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Convenience: read the env var at call time and derive the path. */
|
||||
export function getLiveWsPath(): string {
|
||||
return deriveLiveWsPath(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL);
|
||||
return deriveLiveWsPath(resolveLiveWsPublicUrl() ?? undefined);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
} from "@/shared/constants/upstreamHeaders";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
||||
import { validateProviderSpecificData } from "@/shared/validation/providerSpecificData";
|
||||
import {
|
||||
isReservedProviderPrefix,
|
||||
reservedProviderPrefixMessage,
|
||||
} from "@/shared/constants/reservedProviderPrefixes";
|
||||
|
||||
import {
|
||||
upstreamHeadersRecordSchema,
|
||||
@@ -367,6 +371,17 @@ export const createProviderNodeSchema = z
|
||||
message: "Prefix is required",
|
||||
path: ["prefix"],
|
||||
});
|
||||
} else if (isReservedProviderPrefix(value.prefix.trim())) {
|
||||
// Reserved-prefix guard (tokenrouter bug): the runtime model resolver skips
|
||||
// compatible-node lookup for built-in registry ids/aliases, so a node
|
||||
// created with such a prefix could never be reached by it and silently
|
||||
// routed requests to the built-in provider instead. Reject at the write
|
||||
// path. Case-sensitive to match the runtime guard exactly.
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: reservedProviderPrefixMessage(value.prefix.trim()),
|
||||
path: ["prefix"],
|
||||
});
|
||||
}
|
||||
if (nodeType === "openai-compatible" && !value.apiType) {
|
||||
ctx.addIssue({
|
||||
@@ -377,27 +392,40 @@ export const createProviderNodeSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
export const updateProviderNodeSchema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required"),
|
||||
prefix: z.string().trim().min(1, "Prefix is required"),
|
||||
apiType: z
|
||||
.enum([
|
||||
"chat",
|
||||
"responses",
|
||||
"embeddings",
|
||||
"audio-transcriptions",
|
||||
"audio-speech",
|
||||
"images-generations",
|
||||
])
|
||||
.optional(),
|
||||
baseUrl: z.string().trim().min(1, "Base URL is required"),
|
||||
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
// #2166: same optional remote icon URL as createProviderNodeSchema — empty string
|
||||
// clears a previously stored custom icon.
|
||||
iconUrl: providerNodeIconUrlSchema,
|
||||
customHeaders: customHeadersSchema,
|
||||
});
|
||||
export const updateProviderNodeSchema = z
|
||||
.object({
|
||||
name: z.string().trim().min(1, "Name is required"),
|
||||
prefix: z.string().trim().min(1, "Prefix is required"),
|
||||
apiType: z
|
||||
.enum([
|
||||
"chat",
|
||||
"responses",
|
||||
"embeddings",
|
||||
"audio-transcriptions",
|
||||
"audio-speech",
|
||||
"images-generations",
|
||||
])
|
||||
.optional(),
|
||||
baseUrl: z.string().trim().min(1, "Base URL is required"),
|
||||
chatPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
modelsPath: z.string().trim().startsWith("/").max(500).optional().or(z.literal("")),
|
||||
// #2166: same optional remote icon URL as createProviderNodeSchema — empty string
|
||||
// clears a previously stored custom icon.
|
||||
iconUrl: providerNodeIconUrlSchema,
|
||||
customHeaders: customHeadersSchema,
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
// Reserved-prefix guard (tokenrouter bug) — same rationale as the guard in
|
||||
// createProviderNodeSchema: renaming a node's prefix onto a built-in
|
||||
// registry id/alias would make it unreachable via that prefix.
|
||||
if (isReservedProviderPrefix(value.prefix)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: reservedProviderPrefixMessage(value.prefix),
|
||||
path: ["prefix"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const providerNodeValidateSchema = z.object({
|
||||
baseUrl: z.string().trim().min(1, "Base URL and API key required"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID, createHash } from "crypto";
|
||||
import { nodeTypeFromId } from "@/lib/db/providerNodeSelect";
|
||||
import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts";
|
||||
import { describeUpstreamFailure } from "@/shared/utils/upstreamError";
|
||||
import { buildAllExpiredCredentials } from "./authExpiredCredentials.ts";
|
||||
import {
|
||||
getCachedRawProviderConnections,
|
||||
@@ -1710,7 +1711,8 @@ export async function getProviderCredentials(
|
||||
if (terminalConnections.length === connections.length) {
|
||||
return buildAllExpiredCredentials(terminalConnections);
|
||||
}
|
||||
invalidateManagedLease(options, "CONNECTION_INELIGIBLE"); log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`);
|
||||
invalidateManagedLease(options, "CONNECTION_INELIGIBLE");
|
||||
log.warn("AUTH", `${provider} | all ${connections.length} accounts unavailable`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3063,7 +3065,7 @@ export async function markAccountUnavailable(
|
||||
return { shouldFallback: true, cooldownMs: lockout.cooldownMs };
|
||||
}
|
||||
|
||||
const errorMsg = typeof errorText === "string" ? errorText.slice(0, 100) : "Provider error";
|
||||
const errorMsg = describeUpstreamFailure(errorText);
|
||||
|
||||
// T09: Codex per-scope lockout (do not block the whole account globally).
|
||||
if (
|
||||
|
||||
@@ -20,29 +20,10 @@ import {
|
||||
import { getLearnedReasoningEffortForModel } from "@omniroute/open-sse/services/learnedReasoningEffortCaps.ts";
|
||||
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
|
||||
import { getRegisteredProviderEffortBaseModelId } from "@omniroute/open-sse/utils/registeredEffortVariants.ts";
|
||||
import { getReservedProviderPrefixes } from "@/shared/constants/reservedProviderPrefixes";
|
||||
|
||||
export { parseModel, stripContextWindowSuffix };
|
||||
|
||||
/**
|
||||
* Reserved provider prefixes — built-in provider ids + aliases. User-defined
|
||||
* compatible-node prefixes must not be allowed to shadow these, otherwise a
|
||||
* node with prefix="cf" would hijack cloudflare-ai requests (and similar for
|
||||
* every built-in provider). Ported from upstream 9router 047fdc89.
|
||||
*
|
||||
* Built lazily so the registry is only walked once per process.
|
||||
*/
|
||||
let _reservedProviderPrefixes: Set<string> | null = null;
|
||||
function getReservedProviderPrefixes(): Set<string> {
|
||||
if (_reservedProviderPrefixes) return _reservedProviderPrefixes;
|
||||
const reserved = new Set<string>();
|
||||
for (const entry of Object.values(REGISTRY)) {
|
||||
if (entry?.id) reserved.add(entry.id);
|
||||
if (entry?.alias) reserved.add(entry.alias);
|
||||
}
|
||||
_reservedProviderPrefixes = reserved;
|
||||
return reserved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold `settings.wildcardAliases` ({pattern,target}[]) — the store the Settings
|
||||
* UI's "Wildcard Pattern" mode writes to (ModelAliasesUnified.tsx::addWildcardAlias
|
||||
@@ -460,9 +441,11 @@ export async function getModelInfo(modelStr) {
|
||||
// node prefix lookup so the request still routes to the built-in provider.
|
||||
// Internal UUID-prefixed node ids (e.g. "openai-compatible-responses-...")
|
||||
// are never in the reserved set, so the #2778 combo path still works.
|
||||
// Ported from upstream 9router 047fdc89.
|
||||
const reserved = getReservedProviderPrefixes();
|
||||
const isReservedPrefix = typeof prefixToCheck === "string" && reserved.has(prefixToCheck);
|
||||
// Ported from upstream 9router 047fdc89. Set shared with the write-path
|
||||
// validation guard (src/shared/constants/reservedProviderPrefixes.ts) so
|
||||
// both sides can never drift apart.
|
||||
const isReservedPrefix =
|
||||
typeof prefixToCheck === "string" && getReservedProviderPrefixes().has(prefixToCheck);
|
||||
|
||||
if (!isReservedPrefix) {
|
||||
// Check OpenAI Compatible nodes
|
||||
|
||||
@@ -139,8 +139,8 @@ test("BUG #8370: priority combo keeps its declared model-1-first order despite c
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldProtectOriginalFirst covers priority, fill-first, and lkgp", () => {
|
||||
for (const strategy of ["priority", "fill-first", "lkgp"]) {
|
||||
test("shouldProtectOriginalFirst covers auto, priority, fill-first, and lkgp", () => {
|
||||
for (const strategy of ["auto", "priority", "fill-first", "lkgp"]) {
|
||||
assert.equal(
|
||||
shouldProtectOriginalFirst(false, false, strategy),
|
||||
true,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { runWithProxyContext, resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts";
|
||||
import {
|
||||
runWithDirectFetchContext,
|
||||
runWithProxyContext,
|
||||
resolveProxyForRequest,
|
||||
} from "../../open-sse/utils/proxyFetch.ts";
|
||||
|
||||
async function withEnv(
|
||||
overrides: Record<string, string | undefined>,
|
||||
@@ -59,3 +63,13 @@ test("[9551] resolveProxyForRequest: context-proxy respects NO_PROXY=*", async (
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("direct fetch context overrides an inherited proxy context", async () => {
|
||||
await runWithProxyContext({ type: "http", host: "127.0.0.1", port: 7897 }, () =>
|
||||
runWithDirectFetchContext(() => {
|
||||
const resolved = resolveProxyForRequest("https://api.commandcode.ai/alpha/generate");
|
||||
assert.equal(resolved.source, "direct");
|
||||
assert.equal(resolved.proxyUrl, null);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
isAdobeRiskCookieName,
|
||||
resolveAdobeAccountLabel,
|
||||
resolveSystemBrowserExecutable,
|
||||
killProcessTree,
|
||||
} from "../../open-sse/services/adobeFireflyBrowserLogin.ts";
|
||||
|
||||
test("clampAdobeFireflyLoginTimeout defaults and clamps", () => {
|
||||
@@ -237,3 +238,114 @@ test("error path does not mention Playwright (packaged backend has no Playwright
|
||||
else process.env.OMNIROUTE_LOGIN_BROWSER_PATH = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test("killProcessTree on Linux targets process group (-pid) with SIGTERM and schedules SIGKILL", () => {
|
||||
const killedSignals: Array<{ pid: number; signal: NodeJS.Signals | string }> = [];
|
||||
const mockProcessKill = (pid: number, signal?: NodeJS.Signals | string) => {
|
||||
if (signal) killedSignals.push({ pid, signal });
|
||||
};
|
||||
let procKillCalled = false;
|
||||
const fakeChild = {
|
||||
pid: 54321,
|
||||
kill: (_sig?: NodeJS.Signals | number | string) => {
|
||||
procKillCalled = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
killProcessTree(fakeChild, {
|
||||
platform: "linux",
|
||||
processKill: mockProcessKill,
|
||||
});
|
||||
|
||||
assert.equal(killedSignals.length, 1, "expected immediate SIGTERM call to process group");
|
||||
assert.equal(killedSignals[0].pid, -54321, "Linux must target process group with negative PID");
|
||||
assert.equal(killedSignals[0].signal, "SIGTERM");
|
||||
assert.equal(procKillCalled, false, "should not call direct child.kill when process group kill succeeds");
|
||||
});
|
||||
|
||||
test("killProcessTree falls back to child.kill on Linux when process group kill fails", () => {
|
||||
let childKilledWith: string | undefined;
|
||||
const fakeChild = {
|
||||
pid: 54322,
|
||||
kill: (sig?: NodeJS.Signals | number | string) => {
|
||||
childKilledWith = typeof sig === "string" ? sig : undefined;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const mockProcessKill = () => {
|
||||
throw new Error("ESRCH: no such process group");
|
||||
};
|
||||
|
||||
killProcessTree(fakeChild, {
|
||||
platform: "linux",
|
||||
processKill: mockProcessKill,
|
||||
});
|
||||
|
||||
assert.equal(childKilledWith, "SIGTERM", "must fall back to direct child.kill('SIGTERM')");
|
||||
});
|
||||
|
||||
test("killProcessTree ignores self PID and parent PID to prevent killing backend", () => {
|
||||
let killCalled = false;
|
||||
const selfChild = {
|
||||
pid: process.pid,
|
||||
kill: () => {
|
||||
killCalled = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
killProcessTree(selfChild, { platform: "linux" });
|
||||
assert.equal(killCalled, false, "must never kill own process.pid");
|
||||
|
||||
if (process.ppid) {
|
||||
const parentChild = {
|
||||
pid: process.ppid,
|
||||
kill: () => {
|
||||
killCalled = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
killProcessTree(parentChild, { platform: "linux" });
|
||||
assert.equal(killCalled, false, "must never kill process.ppid");
|
||||
}
|
||||
});
|
||||
|
||||
test("killProcessTree on win32 uses taskkill /pid <pid> /T /F with detached and windowsHide", () => {
|
||||
const spawnCalls: Array<{ cmd: string; args: readonly string[]; opts: unknown }> = [];
|
||||
let unrefCalled = false;
|
||||
const mockSpawn = ((cmd: string, args: readonly string[], opts: unknown) => {
|
||||
spawnCalls.push({ cmd, args, opts });
|
||||
return {
|
||||
unref: () => {
|
||||
unrefCalled = true;
|
||||
},
|
||||
};
|
||||
}) as unknown as typeof import("node:child_process").spawn;
|
||||
|
||||
const fakeChild = {
|
||||
pid: 7788,
|
||||
kill: () => true,
|
||||
};
|
||||
|
||||
killProcessTree(fakeChild, {
|
||||
platform: "win32",
|
||||
spawnFn: mockSpawn,
|
||||
});
|
||||
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.equal(spawnCalls[0].cmd, "taskkill");
|
||||
assert.deepEqual(spawnCalls[0].args, ["/pid", "7788", "/T", "/F"]);
|
||||
const opts = spawnCalls[0].opts as { windowsHide?: boolean; detached?: boolean };
|
||||
assert.equal(opts.windowsHide, true);
|
||||
assert.equal(opts.detached, true);
|
||||
assert.equal(unrefCalled, true);
|
||||
});
|
||||
|
||||
test("killProcessTree handles null / undefined / pid-less gracefully without throwing", () => {
|
||||
assert.doesNotThrow(() => killProcessTree(null));
|
||||
assert.doesNotThrow(() => killProcessTree(undefined));
|
||||
assert.doesNotThrow(() => killProcessTree({}));
|
||||
assert.doesNotThrow(() => killProcessTree({ pid: undefined }));
|
||||
});
|
||||
|
||||
|
||||
|
||||
64
tests/unit/antigravity-empty-project-selection.test.ts
Normal file
64
tests/unit/antigravity-empty-project-selection.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* #11284 — Selection-side safety net for Antigravity accounts with no stored
|
||||
* Cloud Code projectId.
|
||||
*
|
||||
* Production evidence (VPS docker `omniroute`, 2026-08-24): a pool can hold
|
||||
* healthy accounts WITH projectIds alongside accounts whose projectId is
|
||||
* empty and which were never confirmed missing (no errorCode) — those
|
||||
* empty-but-unconfirmed rows still win round-robin slots, burn the request on
|
||||
* loadCodeAssist discovery + 422, and drag the whole combo circuit down.
|
||||
*
|
||||
* Contract pinned here (`antigravityProjectPersist.ts`, quota-strategy copy):
|
||||
* - connections with an EMPTY stored projectId are skipped whenever at
|
||||
* least one sibling carries one;
|
||||
* - when NO connection has a stored project the pool passes through
|
||||
* unchanged (fresh installs keep their lazy-discovery path — #2334);
|
||||
* - confirmed-missing rows (errorCode="missing_project_id") stay excluded
|
||||
* even when they carry a stale stored id (regression guard for the
|
||||
* persistence-module twin `antigravityProjectPersistence.ts`).
|
||||
*
|
||||
* Run: node --import tsx/esm --test tests/unit/antigravity-empty-project-selection.test.ts
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { preferAntigravityConnectionsWithStoredProject } from "../../open-sse/services/antigravityProjectPersist.ts";
|
||||
|
||||
const withProject = { id: "a", projectId: "proj-1" };
|
||||
const withoutProject = { id: "d", projectId: null, providerSpecificData: {} };
|
||||
const confirmedMissingWithStaleId = {
|
||||
id: "f",
|
||||
errorCode: "missing_project_id",
|
||||
projectId: "stale-proj",
|
||||
};
|
||||
|
||||
test("#11284: skips empty-projectId siblings when a healthier account exists", () => {
|
||||
const pool = [withoutProject, withProject];
|
||||
assert.deepEqual(
|
||||
preferAntigravityConnectionsWithStoredProject(pool).map((c) => c.id),
|
||||
["a"]
|
||||
);
|
||||
});
|
||||
|
||||
test("#11284: skips confirmed-missing rows even with a stale stored id", () => {
|
||||
const pool = [confirmedMissingWithStaleId, withProject];
|
||||
assert.deepEqual(
|
||||
preferAntigravityConnectionsWithStoredProject(pool).map((c) => c.id),
|
||||
["a"]
|
||||
);
|
||||
});
|
||||
|
||||
test("#11284: keeps the full pool when ONLY confirmed-missing rows exist (never empty)", () => {
|
||||
const pool = [confirmedMissingWithStaleId];
|
||||
assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool);
|
||||
});
|
||||
|
||||
test("#11284: never empties the pool when every row lacks a projectId", () => {
|
||||
const pool = [withoutProject, { id: "e", providerSpecificData: {} }];
|
||||
assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool);
|
||||
});
|
||||
|
||||
test("#11284: single connection passes through untouched (lazy discovery still applies)", () => {
|
||||
const pool = [withoutProject];
|
||||
assert.deepEqual(preferAntigravityConnectionsWithStoredProject(pool), pool);
|
||||
});
|
||||
90
tests/unit/antigravity-missing-project-autodisable.test.ts
Normal file
90
tests/unit/antigravity-missing-project-autodisable.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* #11284 — Auto-disable Antigravity connections whose Cloud Code project is
|
||||
* confirmed missing, so credential selection rotates to healthy siblings
|
||||
* instead of re-dispatching into a guaranteed 422 on every request.
|
||||
*
|
||||
* Production evidence (VPS docker `omniroute`, 2026-08-24): five rows carried
|
||||
* project_id="" with NO missing-project marker — nothing excluded them from
|
||||
* selection, so each dispatch paid the discovery round-trip and failed.
|
||||
*
|
||||
* Contract: `markAntigravityMissingCloudCodeProject()` must persist the
|
||||
* typed marker (errorCode/lastErrorType) AND `isActive: false` +
|
||||
* `testStatus: "unavailable"` (recoverable — NOT a terminal status), while
|
||||
* `persistDiscoveredAntigravityProjectId()` re-enables the row when a project
|
||||
* is later discovered at request time.
|
||||
*
|
||||
* Run: node --import tsx/esm --test tests/unit/antigravity-missing-project-autodisable.test.ts
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ag-11284-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "ag-11284-test-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const {
|
||||
markAntigravityMissingCloudCodeProject,
|
||||
persistDiscoveredAntigravityProjectId,
|
||||
} = await import("../../open-sse/services/antigravityProjectPersistence.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
async function createConnection() {
|
||||
return providersDb.createProviderConnection({
|
||||
provider: "antigravity",
|
||||
authType: "oauth",
|
||||
name: "autodisable-test",
|
||||
email: `autodisable-${Date.now()}@example.test`,
|
||||
accessToken: "token",
|
||||
refreshToken: "refresh",
|
||||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||
providerSpecificData: { tier: "g1-pro-tier" },
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
}) as Promise<{ id: string; providerSpecificData: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
test("confirmed-missing project disables the connection for selection", async () => {
|
||||
const connection = await createConnection();
|
||||
|
||||
markAntigravityMissingCloudCodeProject(connection.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const updated = await providersDb.getProviderConnectionById(connection.id);
|
||||
assert.equal(updated?.isActive, false, "selection must skip disabled accounts");
|
||||
assert.equal(updated?.testStatus, "unavailable");
|
||||
assert.equal(updated?.errorCode, "missing_project_id");
|
||||
assert.equal(updated?.lastErrorType, "oauth_missing_project_id");
|
||||
});
|
||||
|
||||
test("discovery of a projectId later re-enables the connection", async () => {
|
||||
const connection = await createConnection();
|
||||
|
||||
markAntigravityMissingCloudCodeProject(connection.id);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
persistDiscoveredAntigravityProjectId(
|
||||
connection.id,
|
||||
"recovered-project-99",
|
||||
connection.providerSpecificData as Record<string, unknown>
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const healed = await providersDb.getProviderConnectionById(connection.id);
|
||||
assert.equal(healed?.projectId, "recovered-project-99");
|
||||
assert.equal(healed?.isActive, true, "healthy accounts return to rotation");
|
||||
assert.equal(healed?.testStatus, "active");
|
||||
assert.ok(!healed?.errorCode);
|
||||
});
|
||||
@@ -78,7 +78,11 @@ test("Antigravity missing-project 422 stays fail-closed without account cooldown
|
||||
assert.equal(payload.error?.code, "missing_project_id");
|
||||
assert.equal(payload.error?.type, "oauth_missing_project_id");
|
||||
assert.equal(bootstrapCalls, 1);
|
||||
assert.equal(persisted?.testStatus, "active");
|
||||
// #11284: a CONFIRMED missing project disables the account (recoverable,
|
||||
// not terminal) so selection rotates to healthy siblings — and
|
||||
// persistDiscoveredAntigravityProjectId re-enables it on recovery.
|
||||
assert.equal(persisted?.isActive, false);
|
||||
assert.equal(persisted?.testStatus, "unavailable");
|
||||
assert.equal(persisted?.rateLimitedUntil, undefined);
|
||||
assert.equal(persisted?.errorCode, "missing_project_id");
|
||||
assert.equal(persisted?.lastErrorType, "oauth_missing_project_id");
|
||||
|
||||
197
tests/unit/antigravity-oauth-empty-project-rejection.test.ts
Normal file
197
tests/unit/antigravity-oauth-empty-project-rejection.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* #11284 — Antigravity OAuth must never persist a connection without a Cloud
|
||||
* Code projectId, and the connect-time post-exchange must detect Google's
|
||||
* BYOP ("bring your own project") behavior instead of silently swallowing it.
|
||||
*
|
||||
* Production evidence (VPS docker `omniroute`, 2026-08-24): five antigravity
|
||||
* connections were persisted with project_id="" and
|
||||
* providerSpecificData.projectId="" while tier/subscriptionTier were fully
|
||||
* populated (g1-pro-tier / "Google AI Pro") — proof the token exchange and
|
||||
* loadCodeAssist round-trips SUCCEEDED but Google returned no
|
||||
* cloudaicompanionProject (BYOP accounts, #8491). The old postExchange
|
||||
* swallowed that outcome and the route marked the rows testStatus="active",
|
||||
* so the dashboard showed "Connected" while every model call failed.
|
||||
*
|
||||
* Contract pinned here:
|
||||
* 1. postExchange reports WHY no project was found:
|
||||
* - "requires_manual_project" → onboardUser answered 200 without a
|
||||
* cloudaicompanionProject in the body (Google BYOP).
|
||||
* - "discovery_failed" → loadCodeAssist/onboardUser errored or timed out.
|
||||
* - absent/undefined → projectId discovered normally.
|
||||
* 2. mapTokens surfaces that outcome as tokenData.projectDiscoveryOutcome so
|
||||
* the OAuth route can mark the connection degraded (saved, not active)
|
||||
* instead of silently persisting a false "Connected" row.
|
||||
*
|
||||
* Run: node --import tsx/esm --test tests/unit/antigravity-oauth-empty-project-rejection.test.ts
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { antigravity } from "../../src/lib/oauth/providers/antigravity.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function jsonRes(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("postExchange reports requires_manual_project when onboardUser answers 200 without a project (Google BYOP)", async () => {
|
||||
// Fresh account: loadCodeAssist has no project; onboardUser "succeeds" (200)
|
||||
// but its body carries NO cloudaicompanionProject — Google now expects the
|
||||
// user to bring their own GCP project (#8491). The retry loadCodeAssist
|
||||
// still finds nothing. Outcome must be surfaced, not swallowed.
|
||||
let onboardCalls = 0;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("userinfo")) return jsonRes({ email: "byop@example.com" });
|
||||
if (u.includes("loadCodeAssist")) {
|
||||
return jsonRes({
|
||||
allowedTiers: [{ id: "g1-pro-tier", isDefault: true }],
|
||||
});
|
||||
}
|
||||
if (u.includes("onboardUser")) {
|
||||
onboardCalls++;
|
||||
// BYOP shape: 200 OK, body without cloudaicompanionProject.
|
||||
return jsonRes({ done: true });
|
||||
}
|
||||
return jsonRes({});
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||
|
||||
assert.ok(onboardCalls >= 1, "onboarding attempt must run");
|
||||
assert.equal(result.projectId, "", "no project exists for BYOP accounts");
|
||||
assert.equal(
|
||||
result.projectDiscoveryOutcome,
|
||||
"requires_manual_project",
|
||||
"BYOP outcome must be reported so the route marks the connection degraded"
|
||||
);
|
||||
});
|
||||
|
||||
test("postExchange reports discovery_failed when loadCodeAssist errors (was silently swallowed)", async () => {
|
||||
// Upstream hard-fails: previously this collapsed to console.log + empty
|
||||
// projectId with zero signal. Now it must be classified discovery_failed.
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("userinfo")) return jsonRes({ email: "err@example.com" });
|
||||
if (u.includes("loadCodeAssist")) return jsonRes({ error: "boom" }, 500);
|
||||
if (u.includes("onboardUser")) return jsonRes({ error: "boom" }, 500);
|
||||
return jsonRes({});
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||
|
||||
assert.equal(result.projectId, "");
|
||||
assert.equal(
|
||||
result.projectDiscoveryOutcome,
|
||||
"discovery_failed",
|
||||
"upstream failures must be classified instead of silently dropped"
|
||||
);
|
||||
});
|
||||
|
||||
test("postExchange omits projectDiscoveryOutcome when a project is discovered (happy path unchanged)", async () => {
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("userinfo")) return jsonRes({ email: "ok@example.com" });
|
||||
if (u.includes("loadCodeAssist")) {
|
||||
return jsonRes({
|
||||
cloudaicompanionProject: "happy-path-project",
|
||||
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
|
||||
});
|
||||
}
|
||||
if (u.includes("onboardUser")) return jsonRes({ done: true });
|
||||
return jsonRes({});
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||
|
||||
assert.equal(result.projectId, "happy-path-project");
|
||||
assert.equal(
|
||||
result.projectDiscoveryOutcome,
|
||||
undefined,
|
||||
"successful discovery must not carry an outcome flag"
|
||||
);
|
||||
});
|
||||
|
||||
test("postExchange reports discovery_failed when onboarding succeeds but retry still finds nothing (propagation/transient)", async () => {
|
||||
// onboardUser returns 200 WITHOUT cloudaicompanionProject in the body but
|
||||
// the retry loadCodeAssist eventually surfaces it — recovery wins, no
|
||||
// outcome flag. (The pure-lag case is covered by the onboard-body fallback.)
|
||||
let lcaCalls = 0;
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("userinfo")) return jsonRes({ email: "lag@example.com" });
|
||||
if (u.includes("loadCodeAssist")) {
|
||||
lcaCalls++;
|
||||
return jsonRes({
|
||||
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
|
||||
});
|
||||
}
|
||||
if (u.includes("onboardUser")) {
|
||||
// Real onboarding success shape: project id present in body.
|
||||
return jsonRes({ done: true, cloudaicompanionProject: { id: "late-project" } });
|
||||
}
|
||||
return jsonRes({});
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||
|
||||
assert.equal(result.projectId, "late-project");
|
||||
assert.equal(
|
||||
result.projectDiscoveryOutcome,
|
||||
undefined,
|
||||
"recovered projectId means healthy connection"
|
||||
);
|
||||
void lcaCalls;
|
||||
});
|
||||
|
||||
test("postExchange still fails when onboarding carries a project but every discovery path stays empty", async () => {
|
||||
// Degenerate upstream: onboardUser body has a project but retry loadCodeAssist
|
||||
// errors — must NOT persist as silently-empty; classify discovery_failed.
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("userinfo")) return jsonRes({ email: "lag2@example.com" });
|
||||
if (u.includes("loadCodeAssist")) return jsonRes({ error: "boom" }, 500);
|
||||
if (u.includes("onboardUser")) {
|
||||
return new Response(null, { status: 500 });
|
||||
}
|
||||
return jsonRes({});
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||
|
||||
assert.equal(result.projectId, "");
|
||||
assert.equal(result.projectDiscoveryOutcome, "discovery_failed");
|
||||
});
|
||||
|
||||
test("mapTokens surfaces projectDiscoveryOutcome for the OAuth route degrade gate", async () => {
|
||||
// The route can only act on what mapTokens hands it — the outcome must
|
||||
// survive into tokenData.
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("userinfo")) return jsonRes({ email: "map@example.com" });
|
||||
if (u.includes("loadCodeAssist")) {
|
||||
return jsonRes({ allowedTiers: [{ id: "legacy-tier", isDefault: true }] });
|
||||
}
|
||||
if (u.includes("onboardUser")) return jsonRes({ done: true });
|
||||
return jsonRes({});
|
||||
}) as typeof fetch;
|
||||
|
||||
const tokens = { access_token: "tok" } as never;
|
||||
const extra = await antigravity.postExchange(tokens);
|
||||
const mapped = antigravity.mapTokens(tokens, extra);
|
||||
|
||||
assert.equal(mapped.projectId, "");
|
||||
assert.equal(
|
||||
mapped.projectDiscoveryOutcome,
|
||||
"requires_manual_project",
|
||||
"degrade gate needs the outcome on the mapped payload"
|
||||
);
|
||||
});
|
||||
113
tests/unit/authz/public-route-exact-match.test.ts
Normal file
113
tests/unit/authz/public-route-exact-match.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
PUBLIC_API_ROUTE_PREFIXES,
|
||||
PUBLIC_API_ROUTES_EXACT,
|
||||
PUBLIC_READONLY_API_ROUTES_EXACT,
|
||||
PUBLIC_READONLY_CORS_API_ROUTES,
|
||||
isPublicApiRoute,
|
||||
} from "../../../src/shared/constants/publicApiRoutes.ts";
|
||||
import { classifyRoute } from "../../../src/server/authz/classify.ts";
|
||||
|
||||
// GHSA-74g9-q8f6-793h — `isPublicApiRoute()` matched every entry of
|
||||
// PUBLIC_API_ROUTE_PREFIXES with startsWith(), but most entries name ONE exact
|
||||
// route, not a subtree. As prefixes they also marked every adjacent path
|
||||
// sharing the same leading characters as PUBLIC, skipping the MANAGEMENT auth
|
||||
// gate. `/api/usage/om-usage<suffix>` resolves to the dynamic route
|
||||
// `/api/usage/[connectionId]`, whose handler carries no auth of its own.
|
||||
|
||||
test("every prefix entry is a genuine subtree (ends in a slash)", () => {
|
||||
for (const prefix of PUBLIC_API_ROUTE_PREFIXES) {
|
||||
assert.equal(
|
||||
prefix.endsWith("/"),
|
||||
true,
|
||||
`${prefix} is matched with startsWith(): a prefix that does not end in "/" also ` +
|
||||
`matches every adjacent path sharing its leading characters (GHSA-74g9-q8f6-793h)`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("exact public routes stay public in both spellings", () => {
|
||||
for (const route of PUBLIC_API_ROUTES_EXACT) {
|
||||
assert.equal(isPublicApiRoute(route, "POST"), true, route);
|
||||
assert.equal(isPublicApiRoute(`${route}/`, "POST"), true, `${route}/`);
|
||||
}
|
||||
for (const route of [...PUBLIC_READONLY_API_ROUTES_EXACT, ...PUBLIC_READONLY_CORS_API_ROUTES]) {
|
||||
assert.equal(isPublicApiRoute(route, "GET"), true, route);
|
||||
assert.equal(isPublicApiRoute(`${route}/`, "GET"), true, `${route}/`);
|
||||
}
|
||||
});
|
||||
|
||||
test("sibling paths shadowed by an exact route are NOT public", () => {
|
||||
const shadowed = [
|
||||
"/api/auth/login-as",
|
||||
"/api/auth/logout-all",
|
||||
"/api/auth/status-page",
|
||||
"/api/init-db",
|
||||
"/api/sync/bundle-export",
|
||||
"/api/cli/connect-token",
|
||||
"/api/usage/om-usage-x",
|
||||
"/api/usage/om-usageZZZ",
|
||||
"/api/skills/collect/chaos-report",
|
||||
"/api/health/pings",
|
||||
"/api/monitoring/health-detail",
|
||||
"/api/settings/require-login-policy",
|
||||
];
|
||||
for (const path of shadowed) {
|
||||
assert.equal(isPublicApiRoute(path, "GET"), false, `${path} (GET)`);
|
||||
assert.equal(isPublicApiRoute(path, "POST"), false, `${path} (POST)`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the reported bypass: /api/usage/om-usage<suffix> classifies MANAGEMENT", () => {
|
||||
// The live one — Next resolves it to /api/usage/[connectionId], a handler
|
||||
// with no auth of its own that reaches fetchAndPersistProviderLimits().
|
||||
assert.equal(classifyRoute("/api/usage/om-usage-x", "GET").routeClass, "MANAGEMENT");
|
||||
assert.equal(classifyRoute("/api/usage/om-usageZZZ", "GET").routeClass, "MANAGEMENT");
|
||||
// The real CLI route keeps its PUBLIC classification (it enforces its own key).
|
||||
assert.equal(classifyRoute("/api/usage/om-usage", "GET").routeClass, "PUBLIC");
|
||||
assert.equal(classifyRoute("/api/usage/om-usage/", "GET").routeClass, "PUBLIC");
|
||||
});
|
||||
|
||||
test("genuine subtrees stay public all the way down", () => {
|
||||
assert.equal(isPublicApiRoute("/api/v1/chat/completions", "POST"), true);
|
||||
assert.equal(isPublicApiRoute("/api/oauth/cursor/callback", "GET"), true);
|
||||
assert.equal(isPublicApiRoute("/api/auth/oidc/callback", "GET"), true);
|
||||
assert.equal(isPublicApiRoute("/api/codex/connect/complete", "POST"), true);
|
||||
assert.equal(isPublicApiRoute("/api/telegram/update", "POST"), true);
|
||||
assert.equal(isPublicApiRoute("/api/cursor-cli/auth/exchange_user_api_key", "POST"), true);
|
||||
});
|
||||
|
||||
test("read-only method gate is unchanged", () => {
|
||||
for (const route of [...PUBLIC_READONLY_API_ROUTES_EXACT, ...PUBLIC_READONLY_CORS_API_ROUTES]) {
|
||||
assert.equal(isPublicApiRoute(route, "GET"), true, `${route} GET`);
|
||||
assert.equal(isPublicApiRoute(route, "HEAD"), true, `${route} HEAD`);
|
||||
assert.equal(isPublicApiRoute(route, "OPTIONS"), true, `${route} OPTIONS`);
|
||||
assert.equal(isPublicApiRoute(route, "POST"), false, `${route} POST`);
|
||||
assert.equal(isPublicApiRoute(route, "DELETE"), false, `${route} DELETE`);
|
||||
}
|
||||
});
|
||||
|
||||
test("CORS relaxation reason set is unchanged", () => {
|
||||
// pipeline.ts keys its CORS origin relaxation off `public_readonly_prefix`.
|
||||
for (const route of PUBLIC_READONLY_CORS_API_ROUTES) {
|
||||
assert.equal(classifyRoute(route, "GET").reason, "public_readonly_prefix", route);
|
||||
}
|
||||
// /api/health deliberately stays `public_prefix` — folding it into the
|
||||
// read-only set would silently widen CORS on it.
|
||||
assert.equal(classifyRoute("/api/health", "GET").reason, "public_prefix");
|
||||
// ...and a shadowed sibling must not inherit the relaxation either.
|
||||
assert.equal(classifyRoute("/api/monitoring/health-detail", "GET").routeClass, "MANAGEMENT");
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY oauth auto-import exclusions still win over the /api/oauth/ subtree", () => {
|
||||
for (const route of [
|
||||
"/api/oauth/cursor/auto-import",
|
||||
"/api/oauth/kiro/auto-import",
|
||||
"/api/oauth/raycast/auto-import",
|
||||
]) {
|
||||
assert.equal(isPublicApiRoute(route, "POST"), false, route);
|
||||
assert.equal(classifyRoute(route, "POST").routeClass, "MANAGEMENT", route);
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const intelligentRouting = await import("../../src/lib/combos/intelligentRouting.ts");
|
||||
const { getModePack } = await import("../../open-sse/services/autoCombo/modePacks.ts");
|
||||
|
||||
test("getStrategyCategory classifies intelligent and deterministic strategies correctly", () => {
|
||||
assert.equal(intelligentRouting.getStrategyCategory("auto"), "intelligent");
|
||||
@@ -155,6 +156,19 @@ test("sidebar visibility excludes the removed auto-combo item", async () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("custom mode-pack selection preserves explicit slider intent", () => {
|
||||
assert.deepEqual(intelligentRouting.MODE_PACK_OPTIONS[0], {
|
||||
id: "custom",
|
||||
label: "Custom / None (Use Sliders)",
|
||||
emoji: "tune",
|
||||
});
|
||||
assert.equal(
|
||||
intelligentRouting.normalizeIntelligentRoutingConfig({ modePack: "custom" }).modePack,
|
||||
"custom"
|
||||
);
|
||||
assert.equal(getModePack("custom"), undefined);
|
||||
});
|
||||
|
||||
test("intelligent routing helpers normalize config and build provider scores", () => {
|
||||
const normalizedConfig = intelligentRouting.normalizeIntelligentRoutingConfig({
|
||||
candidatePool: ["openai", "anthropic"],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user