diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49356f9..0860aca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,14 +46,30 @@ jobs: if-no-files-found: error build-packages: - name: Build Desktop Package (${{ matrix.os }}) + name: Build Desktop Package (${{ matrix.platform }}) needs: verify - runs-on: ${{ matrix.os }} + runs-on: ${{ matrix.runner }} timeout-minutes: 60 strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + include: + - platform: linux + runner: ubuntu-latest + artifactLabel: linux-x64 + - platform: windows + runner: windows-latest + artifactLabel: windows-x64 + - platform: mac-arm64 + runner: macos-15 + artifactLabel: mac-arm64 + packageCommand: npm run package:desktop -- --mac --arm64 + expectedMacArch: arm64 + - platform: mac-x64 + runner: macos-15-intel + artifactLabel: mac-x64 + packageCommand: npm run package:desktop:mac:intel + expectedMacArch: x64 steps: - name: Checkout @@ -183,7 +199,7 @@ jobs: done - name: Build desktop artifacts (macOS) - if: runner.os == 'macOS' + if: startsWith(matrix.platform, 'mac-') shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -195,14 +211,15 @@ jobs: APPLE_ID_INPUT: ${{ secrets.APPLE_ID }} APPLE_APP_SPECIFIC_PASSWORD_INPUT: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} APPLE_TEAM_ID_INPUT: ${{ secrets.APPLE_TEAM_ID }} + PACKAGE_COMMAND: ${{ matrix.packageCommand }} run: | set -euo pipefail if [ -n "${APPLE_ID_INPUT:-}" ]; then export APPLE_ID="$APPLE_ID_INPUT"; fi if [ -n "${APPLE_APP_SPECIFIC_PASSWORD_INPUT:-}" ]; then export APPLE_APP_SPECIFIC_PASSWORD="$APPLE_APP_SPECIFIC_PASSWORD_INPUT"; fi if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi for attempt in 1 2; do - echo "Desktop build (host arch) attempt ${attempt}/2" - if npm run package:desktop; then + echo "Desktop build (${PACKAGE_COMMAND}) attempt ${attempt}/2" + if eval "${PACKAGE_COMMAND}"; then break fi if [ "$attempt" -eq 2 ]; then @@ -210,25 +227,45 @@ jobs: fi sleep 90 done - if [ "${RUNNER_ARCH:-}" != "X64" ]; then - for attempt in 1 2; do - echo "Desktop build (mac intel x64) attempt ${attempt}/2" - if npm run package:desktop:mac:intel; then - break - fi - if [ "$attempt" -eq 2 ]; then - exit 1 - fi - sleep 90 - done - else - echo "Runner is already X64, skip extra Intel build" + + - name: Verify packaged macOS architecture + if: startsWith(matrix.platform, 'mac-') + shell: bash + env: + expectedMacArch: ${{ matrix.expectedMacArch }} + run: | + set -euo pipefail + mapfile -t app_binaries < <(find release -type f -path '*/Metapi.app/Contents/MacOS/Metapi') + if [ "${#app_binaries[@]}" -eq 0 ]; then + echo "No packaged macOS app binary found under release/" + exit 1 fi + for binary in "${app_binaries[@]}"; do + archs="$(lipo -archs "$binary" 2>/dev/null | xargs)" + echo "$binary => ${archs:-unknown}" + if [ -z "${archs}" ]; then + echo "Unable to detect architecture via lipo" + exit 1 + fi + + if [ "${expectedMacArch}" = "x64" ]; then + [ "${archs}" = "x86_64" ] || { + echo "Expected x86_64-only binary but got: ${archs}" + exit 1 + } + else + [ "${archs}" = "arm64" ] || { + echo "Expected arm64-only binary but got: ${archs}" + exit 1 + } + fi + done + - name: Upload desktop package uses: actions/upload-artifact@v7 with: - name: metapi-desktop-${{ github.ref_name }}-${{ runner.os }}-${{ runner.arch }} + name: metapi-desktop-${{ github.run_id }}-${{ matrix.artifactLabel }} path: | release/*.exe release/*.dmg diff --git a/README.md b/README.md index 34291fb..5e6c43e 100644 --- a/README.md +++ b/README.md @@ -349,6 +349,7 @@ docker run -d --name metapi \ > [!TIP] > 初始管理员令牌即启动时配置的 `AUTH_TOKEN`。 > 若在 Compose 外运行且未显式设置 `AUTH_TOKEN`,默认为 `change-me-admin-token`(仅用于本地调试)。 +> 桌面安装包首次启动也属于这类场景:如果你没有额外注入 `AUTH_TOKEN`,默认管理员令牌同样是 `change-me-admin-token`。 > 如果在「设置」面板中修改了管理员令牌,后续登录请使用新令牌。 Docker Compose、桌面安装包、反向代理、升级与数据库选项等详见 [部署指南](https://metapi.cita777.me/deployment)。 diff --git a/README_EN.md b/README_EN.md index e357345..7ba84bc 100644 --- a/README_EN.md +++ b/README_EN.md @@ -350,6 +350,7 @@ After starting, visit `http://localhost:4000` and log in with your `AUTH_TOKEN`! > [!TIP] > The initial admin token is the `AUTH_TOKEN` configured at startup. > If running outside Compose without explicitly setting `AUTH_TOKEN`, the default is `change-me-admin-token` (for local debugging only). +> The desktop installer falls into this category on first launch too: if you do not inject `AUTH_TOKEN`, the default admin token is also `change-me-admin-token`. > If you change the admin token in the Settings panel, use the new token for subsequent logins. For Docker Compose, desktop installers, reverse proxy, upgrades, and database options, see [Deployment Guide](docs/deployment.md). diff --git a/account_tokens_18.json b/account_tokens_18.json new file mode 100644 index 0000000..71bab81 --- /dev/null +++ b/account_tokens_18.json @@ -0,0 +1,14 @@ +[ + { + "id": 58, + "account_id": 18, + "name": "metapi", + "token": "MoKuoMzqsfLbPsZ4RtCJ3uLm2TZALUVwSivGatJQ8UlClPxz", + "source": "sync", + "enabled": 1, + "is_default": 1, + "created_at": "2026-03-04T13:00:10.239Z", + "updated_at": "2026-03-08T12:45:05.380Z", + "token_group": "gemini" + } +] \ No newline at end of file diff --git a/build/desktop-icon.png b/build/desktop-icon.png new file mode 100644 index 0000000..00a365e Binary files /dev/null and b/build/desktop-icon.png differ diff --git a/docs/deployment.md b/docs/deployment.md index e2418ff..9e11788 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -10,7 +10,7 @@ |------|----------|--------------|----------| | 云服务器 / NAS / 家用主机长期运行 | Docker / Docker Compose / Zeabur | 固定服务地址,例如 `http://your-host:4000` 或反向代理域名 | 你挂载的 `DATA_DIR` / 持久化卷 | | 免费云部署(24h 在线) | Render + TiDB + UptimeRobot | Render 分配的 `.onrender.com` 域名或自定义域名 | TiDB Serverless(外部 MySQL 数据库) | -| 个人电脑本地使用 | 桌面版安装包 | 桌面窗口;如需本机客户端直连,使用日志中打印的 `http://127.0.0.1:` | Electron `app.getPath('userData')/data` | +| 个人电脑本地使用 | 桌面版安装包 | 桌面窗口;默认本机客户端直连使用 `http://127.0.0.1:4000`,局域网客户端可使用当前机器 IP + `4000`;如有覆盖则按 `METAPI_DESKTOP_SERVER_PORT` 的实际端口访问 | Electron `app.getPath('userData')/data` | | 二次开发 / 调试 | 本地开发 | 前端 `http://localhost:5173`,后端默认 `http://localhost:4000` | 仓库内 `./data` 或自定义 `DATA_DIR` | > [!NOTE] @@ -191,6 +191,10 @@ docker run -d --name metapi \ - 托盘菜单支持重新打开窗口、重启后端、开机自启 - 支持基于 GitHub Releases 的应用内更新检查 +> [!IMPORTANT] +> 桌面版首次启动时,如果没有显式注入 `AUTH_TOKEN`,管理员登录令牌默认是 `change-me-admin-token`。 +> 这只适合本机初始调试使用,首次登录后应立即修改。 + > [!NOTE] > 服务器部署不再提供裸 Node.js Release 压缩包,统一推荐 Docker / Docker Compose。 @@ -211,7 +215,7 @@ docker run -d --name metapi \ ## 反向代理 -以下反向代理配置面向 Docker / 服务器模式。桌面版内置后端默认只绑定本机 `127.0.0.1`,通常不作为公网服务直接暴露。 +以下反向代理配置面向 Docker / 服务器模式。桌面版内置后端默认监听 `0.0.0.0:4000`,但通常仍作为单机桌面应用使用;如果要给局域网或公网客户端访问,请自行配置防火墙、反向代理和认证边界。若你显式设置了 `METAPI_DESKTOP_SERVER_PORT`,请把示例里的 `4000` 改成对应端口。 ### Nginx diff --git a/docs/getting-started.md b/docs/getting-started.md index 0347b11..8a34144 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -80,18 +80,22 @@ docker compose up -d | 项目 | 说明 | |------|------| | 管理界面 | 应用启动后会直接打开桌面窗口,不需要假设固定的 `http://localhost:4000` | -| 本地后端地址 | 桌面版把内置服务绑定到 `127.0.0.1`,默认会在 `4310..4399` 中挑选空闲端口;只有显式设置 `METAPI_DESKTOP_SERVER_PORT` 时才会固定 | +| 本地后端地址 | 桌面版内置服务默认监听 `0.0.0.0:4000`;桌面窗口和本机 curl 可继续使用 `http://127.0.0.1:4000`,局域网其他设备请使用当前机器的实际 IP + `4000`;如需改端口,可显式设置 `METAPI_DESKTOP_SERVER_PORT` | | 数据目录 | 保存在 `app.getPath('userData')/data`,不是仓库里的 `./data` | | 日志目录 | 保存在 `app.getPath('userData')/logs`;托盘菜单提供 `Open Logs Folder` | +> [!IMPORTANT] +> 桌面版首次启动时,如果你没有额外注入 `AUTH_TOKEN`,默认管理员令牌就是 `change-me-admin-token`。 +> 首次登录后建议立即到「设置」里改成你自己的强密码令牌。 + > [!TIP] > - Windows 下常见路径是 `%APPDATA%\Metapi\data` 和 `%APPDATA%\Metapi\logs`。 -> - 如果你要把本机其他客户端接到桌面版内置后端,先到日志里查当前端口,不要写死 `4000`。 +> - 如果没有额外覆盖端口,本机其他客户端可以直接连接 `http://127.0.0.1:4000`。 > [!WARNING] -> **端口冲突排障:** 如果桌面版启动后报端口被占用,可能是 `4310..4399` 范围内的端口全被其他应用占用了。 -> - 设置环境变量 `METAPI_DESKTOP_SERVER_PORT=<指定端口>` 固定到一个空闲端口 -> - 或关闭占用这些端口的应用后重启 Metapi Desktop +> **端口冲突排障:** 桌面版默认使用 `4000` 端口;如果该端口被其他应用占用: +> - 设置环境变量 `METAPI_DESKTOP_SERVER_PORT=<指定端口>` 改到一个空闲端口 +> - 或关闭占用 `4000` 的应用后重启 Metapi Desktop > [!NOTE] > 服务器部署统一推荐 Docker / Docker Compose,不再提供裸 Node.js 的 Release 压缩包。 @@ -196,7 +200,7 @@ npm run dev |----------|----------|----------------| | Docker / Docker Compose | `http://localhost:4000` | `http://localhost:4000` | | 本地开发 | `http://localhost:5173` | `http://localhost:4000` | -| 桌面版 | 直接使用桌面窗口 | 通常是4312,先从日志里的 `Proxy API:` 行确认当前 `http://127.0.0.1:` | +| 桌面版 | 直接使用桌面窗口 | 默认 `http://127.0.0.1:4000`;如果设置了 `METAPI_DESKTOP_SERVER_PORT`,则按日志里的实际端口访问,局域网其他设备改用当前机器 IP + 同一端口 | ### Docker / 本地开发:直接用 curl 验证 @@ -212,23 +216,25 @@ curl -sS http://localhost:4000/v1/chat/completions \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}]}' ``` -### 桌面版:先确认当前端口再验证 +### 桌面版:默认直接用 4000 验证 打开托盘菜单的 `Open Logs Folder`,在最新日志里查找类似下面的启动信息: ```text -Dashboard: http://127.0.0.1:4312 -Proxy API: http://127.0.0.1:4312/v1/chat/completions +Dashboard: http://127.0.0.1:4000 +Proxy API: http://127.0.0.1:4000/v1/chat/completions ``` -然后把日志里的实际端口替换进 curl: +如果你没有覆盖端口,可直接执行: ```bash -curl -sS http://127.0.0.1:4312/v1/models \ +curl -sS http://127.0.0.1:4000/v1/models \ -H "Authorization: Bearer your-proxy-sk-token" ``` -返回正常响应,说明代理链路已经可用。 +如果你显式设置了 `METAPI_DESKTOP_SERVER_PORT`,再把上面的 `4000` 替换成日志里的实际端口。返回正常响应,说明代理链路已经可用。 + +如果你要从同一局域网的其他设备访问桌面版,把上面的 `127.0.0.1` 替换成这台电脑的实际局域网 IP,并确认系统防火墙已放行对应端口。 ## 下一步 diff --git a/docs/operations.md b/docs/operations.md index 420f477..a3cc0f1 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -154,7 +154,7 @@ curl -sS http://localhost:4000/v1/chat/completions \ -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"ping"}]}' ``` -以上示例默认服务端部署监听 `localhost:4000`。Desktop 内置后端运行在 `127.0.0.1` 的动态端口,优先通过应用界面、托盘日志或健康检查日志排查,不要假定固定端口。 +以上示例默认服务端部署监听 `localhost:4000`。Desktop 内置后端默认监听 `0.0.0.0:4000`;本机排查通常可直接使用 `127.0.0.1:4000`,如果显式设置了 `METAPI_DESKTOP_SERVER_PORT`,则按日志里的实际端口访问;局域网排查改用当前机器的实际 IP。 ### 自动化监控建议 diff --git a/docs/plans/2026-03-16-downstream-key-ui-and-site-links.md b/docs/plans/2026-03-16-downstream-key-ui-and-site-links.md new file mode 100644 index 0000000..902228e --- /dev/null +++ b/docs/plans/2026-03-16-downstream-key-ui-and-site-links.md @@ -0,0 +1,160 @@ +# Downstream Key UI And Site Links Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Restyle the downstream key page to match the restrained management-console pages and standardize site buttons across pages using the accounts page interaction. + +**Architecture:** Introduce one shared site link component based on the accounts page behavior, then update each affected page to consume it. Refactor downstream key layout styling toward existing `card`, `badge`, and `data-table` patterns without changing backend behavior. + +**Tech Stack:** React, TypeScript, React Router, Vitest, React Test Renderer + +--- + +### Task 1: Extract the shared site button + +**Files:** +- Create: `src/web/components/SiteBadgeLink.tsx` +- Test: `src/web/components/site-badge-link.test.tsx` +- Modify: `src/web/pages/Accounts.tsx` +- Modify: `src/web/index.css` + +**Step 1: Write the failing test** + +Add a component test that renders a site badge link and asserts: +- it renders the site name, +- it has the shared interactive class, +- clicking navigates to `/sites?focusSiteId=`. + +**Step 2: Run test to verify it fails** + +Run: `npm test -- src/web/components/site-badge-link.test.tsx` + +Expected: FAIL because the component does not exist yet. + +**Step 3: Write minimal implementation** + +- Create a small component that wraps `Link` from `react-router-dom`. +- Accept `siteId`, `siteName`, and optional badge class props. +- Reproduce the accounts page badge-link styling and fallback behavior. +- Move the accounts page site badge rendering to this component. +- Add any missing shared CSS class if accounts currently relies on page-local styling. + +**Step 4: Run test to verify it passes** + +Run: `npm test -- src/web/components/site-badge-link.test.tsx src/web/pages/accounts.segmented-connections.test.tsx` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/web/components/SiteBadgeLink.tsx src/web/components/site-badge-link.test.tsx src/web/pages/Accounts.tsx src/web/index.css +git commit -m "refactor: extract shared site badge link" +``` + +### Task 2: Apply the shared site button across affected pages + +**Files:** +- Modify: `src/web/pages/Models.tsx` +- Modify: `src/web/pages/ProxyLogs.tsx` +- Modify: `src/web/pages/TokenRoutes.tsx` +- Modify: `src/web/pages/DownstreamKeys.tsx` +- Test: `src/web/pages/Models.marketplace-text.test.tsx` +- Test: `src/web/pages/tokenRoutes.group-collapse.test.tsx` +- Test: `src/web/pages/DownstreamKeys.test.tsx` + +**Step 1: Write failing assertions** + +Add or update tests to assert the affected pages render the shared site button class and, where practical, point to the focused site management route. + +**Step 2: Run test to verify it fails** + +Run: `npm test -- src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx` + +Expected: FAIL because the pages still use inconsistent site rendering. + +**Step 3: Write minimal implementation** + +- Replace page-local site label rendering with `SiteBadgeLink`. +- Only convert labels that represent navigable management-site references. +- Preserve non-site filters, external URLs, and unrelated badges. + +**Step 4: Run test to verify it passes** + +Run: `npm test -- src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx src/web/pages/ProxyLogs.server-driven.test.tsx` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/web/pages/Models.tsx src/web/pages/ProxyLogs.tsx src/web/pages/TokenRoutes.tsx src/web/pages/DownstreamKeys.tsx src/web/pages/Models.marketplace-text.test.tsx src/web/pages/tokenRoutes.group-collapse.test.tsx src/web/pages/DownstreamKeys.test.tsx +git commit -m "feat: unify site badge navigation across pages" +``` + +### Task 3: Restyle downstream keys page + +**Files:** +- Modify: `src/web/pages/DownstreamKeys.tsx` +- Test: `src/web/pages/DownstreamKeys.test.tsx` +- Modify: `src/web/index.css` + +**Step 1: Write failing assertions** + +Add focused assertions for the downstream keys page structure where needed, such as shared management-page card classes, reduced bespoke styling hooks, or the new site badge placement. + +**Step 2: Run test to verify it fails** + +Run: `npm test -- src/web/pages/DownstreamKeys.test.tsx` + +Expected: FAIL after adding assertions that reflect the updated restrained layout. + +**Step 3: Write minimal implementation** + +- Reduce the page’s dashboard-like presentation. +- Align summary, filters, and batch action sections with the same management-console card language used in sites/accounts. +- Reuse shared badge/link patterns instead of one-off visual treatments where practical. +- Keep operations, data fetching, and drawer behavior unchanged. + +**Step 4: Run test to verify it passes** + +Run: `npm test -- src/web/pages/DownstreamKeys.test.tsx` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/web/pages/DownstreamKeys.tsx src/web/pages/DownstreamKeys.test.tsx src/web/index.css +git commit -m "style: align downstream key page with management UI" +``` + +### Task 4: Final verification + +**Files:** +- Modify: none + +**Step 1: Run targeted verification** + +Run: +- `npm test -- src/web/components/site-badge-link.test.tsx` +- `npm test -- src/web/pages/DownstreamKeys.test.tsx` +- `npm test -- src/web/pages/Models.marketplace-text.test.tsx` +- `npm test -- src/web/pages/tokenRoutes.group-collapse.test.tsx` +- `npm test -- src/web/pages/ProxyLogs.server-driven.test.tsx` +- `npm test -- src/web/pages/accounts.segmented-connections.test.tsx` + +Expected: PASS + +**Step 2: Run broader sanity verification if time permits** + +Run: `npm test -- src/web/App.sidebar.test.ts src/web/App.sidebar-mobile.test.tsx` + +Expected: PASS + +**Step 3: Commit final polish** + +```bash +git add -A +git commit -m "test: verify downstream key UI and site link consistency" +``` diff --git a/drizzle/0010_proxy_logs_downstream_api_key.sql b/drizzle/0010_proxy_logs_downstream_api_key.sql new file mode 100644 index 0000000..67ccbf8 --- /dev/null +++ b/drizzle/0010_proxy_logs_downstream_api_key.sql @@ -0,0 +1,3 @@ +ALTER TABLE `proxy_logs` ADD `downstream_api_key_id` integer; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`); diff --git a/drizzle/0011_downstream_api_key_metadata.sql b/drizzle/0011_downstream_api_key_metadata.sql new file mode 100644 index 0000000..5a99895 --- /dev/null +++ b/drizzle/0011_downstream_api_key_metadata.sql @@ -0,0 +1,3 @@ +ALTER TABLE `downstream_api_keys` ADD `group_name` text; +--> statement-breakpoint +ALTER TABLE `downstream_api_keys` ADD `tags` text; diff --git a/drizzle/0012_account_token_value_status.sql b/drizzle/0012_account_token_value_status.sql new file mode 100644 index 0000000..0e999eb --- /dev/null +++ b/drizzle/0012_account_token_value_status.sql @@ -0,0 +1 @@ +ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL; diff --git a/drizzle/0013_oauth_multi_provider.sql b/drizzle/0013_oauth_multi_provider.sql new file mode 100644 index 0000000..92d5345 --- /dev/null +++ b/drizzle/0013_oauth_multi_provider.sql @@ -0,0 +1,24 @@ +ALTER TABLE `accounts` ADD `oauth_provider` text; +--> statement-breakpoint +ALTER TABLE `accounts` ADD `oauth_account_key` text; +--> statement-breakpoint +ALTER TABLE `accounts` ADD `oauth_project_id` text; +--> statement-breakpoint +UPDATE `accounts` +SET + `oauth_provider` = NULLIF(TRIM(json_extract(`extra_config`, '$.oauth.provider')), ''), + `oauth_account_key` = NULLIF( + TRIM(COALESCE( + json_extract(`extra_config`, '$.oauth.accountKey'), + json_extract(`extra_config`, '$.oauth.accountId') + )), + '' + ), + `oauth_project_id` = NULLIF(TRIM(json_extract(`extra_config`, '$.oauth.projectId')), '') +WHERE `extra_config` IS NOT NULL; +--> statement-breakpoint +CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`); +--> statement-breakpoint +CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`,`oauth_account_key`,`oauth_project_id`); +--> statement-breakpoint +CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`); diff --git a/drizzle/0014_explicit_group_routes.sql b/drizzle/0014_explicit_group_routes.sql new file mode 100644 index 0000000..24d5722 --- /dev/null +++ b/drizzle/0014_explicit_group_routes.sql @@ -0,0 +1,13 @@ +ALTER TABLE `token_routes` ADD `route_mode` text DEFAULT 'pattern'; +--> statement-breakpoint +CREATE TABLE `route_group_sources` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `group_route_id` integer NOT NULL, + `source_route_id` integer NOT NULL, + FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`); +--> statement-breakpoint +CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`); diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..bfcee45 --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1892 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b1aee21b-b789-4e47-ab03-7ba93cc26951", + "prevId": "4bdb796c-cedc-4b81-8bfb-78c5e0bde8c0", + "tables": { + "account_tokens": { + "name": "account_tokens", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_group": { + "name": "token_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_status": { + "name": "value_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'manual'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "account_tokens_account_id_idx": { + "name": "account_tokens_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "account_tokens_account_enabled_idx": { + "name": "account_tokens_account_enabled_idx", + "columns": [ + "account_id", + "enabled" + ], + "isUnique": false + }, + "account_tokens_enabled_idx": { + "name": "account_tokens_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_tokens_account_id_accounts_id_fk": { + "name": "account_tokens_account_id_accounts_id_fk", + "tableFrom": "account_tokens", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_token": { + "name": "api_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "balance": { + "name": "balance", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "balance_used": { + "name": "balance_used", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "quota": { + "name": "quota", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "unit_cost": { + "name": "unit_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "value_score": { + "name": "value_score", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "checkin_enabled": { + "name": "checkin_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "last_checkin_at": { + "name": "last_checkin_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_balance_refresh": { + "name": "last_balance_refresh", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "extra_config": { + "name": "extra_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "accounts_site_id_idx": { + "name": "accounts_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + }, + "accounts_status_idx": { + "name": "accounts_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "accounts_site_status_idx": { + "name": "accounts_site_status_idx", + "columns": [ + "site_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "accounts_site_id_sites_id_fk": { + "name": "accounts_site_id_sites_id_fk", + "tableFrom": "accounts", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "checkin_logs": { + "name": "checkin_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reward": { + "name": "reward", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "checkin_logs_account_created_at_idx": { + "name": "checkin_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "checkin_logs_created_at_idx": { + "name": "checkin_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "checkin_logs_status_idx": { + "name": "checkin_logs_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "checkin_logs_account_id_accounts_id_fk": { + "name": "checkin_logs_account_id_accounts_id_fk", + "tableFrom": "checkin_logs", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downstream_api_keys": { + "name": "downstream_api_keys", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_cost": { + "name": "max_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_cost": { + "name": "used_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "max_requests": { + "name": "max_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_requests": { + "name": "used_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "supported_models": { + "name": "supported_models", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_route_ids": { + "name": "allowed_route_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "site_weight_multipliers": { + "name": "site_weight_multipliers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "downstream_api_keys_key_unique": { + "name": "downstream_api_keys_key_unique", + "columns": [ + "key" + ], + "isUnique": true + }, + "downstream_api_keys_name_idx": { + "name": "downstream_api_keys_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "downstream_api_keys_enabled_idx": { + "name": "downstream_api_keys_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + }, + "downstream_api_keys_expires_at_idx": { + "name": "downstream_api_keys_expires_at_idx", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "events": { + "name": "events", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'info'" + }, + "read": { + "name": "read", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "related_id": { + "name": "related_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "related_type": { + "name": "related_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "events_read_created_at_idx": { + "name": "events_read_created_at_idx", + "columns": [ + "read", + "created_at" + ], + "isUnique": false + }, + "events_type_created_at_idx": { + "name": "events_type_created_at_idx", + "columns": [ + "type", + "created_at" + ], + "isUnique": false + }, + "events_created_at_idx": { + "name": "events_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "model_availability": { + "name": "model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_manual": { + "name": "is_manual", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "model_availability_account_model_unique": { + "name": "model_availability_account_model_unique", + "columns": [ + "account_id", + "model_name" + ], + "isUnique": true + }, + "model_availability_account_available_idx": { + "name": "model_availability_account_available_idx", + "columns": [ + "account_id", + "available" + ], + "isUnique": false + }, + "model_availability_model_name_idx": { + "name": "model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + } + }, + "foreignKeys": { + "model_availability_account_id_accounts_id_fk": { + "name": "model_availability_account_id_accounts_id_fk", + "tableFrom": "model_availability", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_files": { + "name": "proxy_files", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_base64": { + "name": "content_base64", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "proxy_files_public_id_unique": { + "name": "proxy_files_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_files_owner_lookup_idx": { + "name": "proxy_files_owner_lookup_idx", + "columns": [ + "owner_type", + "owner_id", + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_logs": { + "name": "proxy_logs", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "downstream_api_key_id": { + "name": "downstream_api_key_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_requested": { + "name": "model_requested", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_actual": { + "name": "model_actual", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_status": { + "name": "http_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_tokens": { + "name": "total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "billing_details": { + "name": "billing_details", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_logs_created_at_idx": { + "name": "proxy_logs_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "proxy_logs_account_created_at_idx": { + "name": "proxy_logs_account_created_at_idx", + "columns": [ + "account_id", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_status_created_at_idx": { + "name": "proxy_logs_status_created_at_idx", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_model_actual_created_at_idx": { + "name": "proxy_logs_model_actual_created_at_idx", + "columns": [ + "model_actual", + "created_at" + ], + "isUnique": false + }, + "proxy_logs_downstream_api_key_created_at_idx": { + "name": "proxy_logs_downstream_api_key_created_at_idx", + "columns": [ + "downstream_api_key_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "proxy_video_tasks": { + "name": "proxy_video_tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_video_id": { + "name": "upstream_video_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_value": { + "name": "token_value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "actual_model": { + "name": "actual_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_snapshot": { + "name": "status_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "upstream_response_meta": { + "name": "upstream_response_meta", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_upstream_status": { + "name": "last_upstream_status", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "proxy_video_tasks_public_id_unique": { + "name": "proxy_video_tasks_public_id_unique", + "columns": [ + "public_id" + ], + "isUnique": true + }, + "proxy_video_tasks_upstream_video_id_idx": { + "name": "proxy_video_tasks_upstream_video_id_idx", + "columns": [ + "upstream_video_id" + ], + "isUnique": false + }, + "proxy_video_tasks_created_at_idx": { + "name": "proxy_video_tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "route_channels": { + "name": "route_channels", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "route_id": { + "name": "route_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_model": { + "name": "source_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "manual_override": { + "name": "manual_override", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "fail_count": { + "name": "fail_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_latency_ms": { + "name": "total_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_at": { + "name": "last_selected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_fail_at": { + "name": "last_fail_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "consecutive_fail_count": { + "name": "consecutive_fail_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_level": { + "name": "cooldown_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cooldown_until": { + "name": "cooldown_until", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "route_channels_route_id_idx": { + "name": "route_channels_route_id_idx", + "columns": [ + "route_id" + ], + "isUnique": false + }, + "route_channels_account_id_idx": { + "name": "route_channels_account_id_idx", + "columns": [ + "account_id" + ], + "isUnique": false + }, + "route_channels_token_id_idx": { + "name": "route_channels_token_id_idx", + "columns": [ + "token_id" + ], + "isUnique": false + }, + "route_channels_route_enabled_idx": { + "name": "route_channels_route_enabled_idx", + "columns": [ + "route_id", + "enabled" + ], + "isUnique": false + }, + "route_channels_route_token_idx": { + "name": "route_channels_route_token_idx", + "columns": [ + "route_id", + "token_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "route_channels_route_id_token_routes_id_fk": { + "name": "route_channels_route_id_token_routes_id_fk", + "tableFrom": "route_channels", + "tableTo": "token_routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_account_id_accounts_id_fk": { + "name": "route_channels_account_id_accounts_id_fk", + "tableFrom": "route_channels", + "tableTo": "accounts", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_channels_token_id_account_tokens_id_fk": { + "name": "route_channels_token_id_account_tokens_id_fk", + "tableFrom": "route_channels", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_disabled_models": { + "name": "site_disabled_models", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "site_disabled_models_site_model_unique": { + "name": "site_disabled_models_site_model_unique", + "columns": [ + "site_id", + "model_name" + ], + "isUnique": true + }, + "site_disabled_models_site_id_idx": { + "name": "site_disabled_models_site_id_idx", + "columns": [ + "site_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "site_disabled_models_site_id_sites_id_fk": { + "name": "site_disabled_models_site_id_sites_id_fk", + "tableFrom": "site_disabled_models", + "tableTo": "sites", + "columnsFrom": [ + "site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sites": { + "name": "sites", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_checkin_url": { + "name": "external_checkin_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_system_proxy": { + "name": "use_system_proxy", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "is_pinned": { + "name": "is_pinned", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "global_weight": { + "name": "global_weight", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "sites_status_idx": { + "name": "sites_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_model_availability": { + "name": "token_model_availability", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "token_id": { + "name": "token_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_name": { + "name": "model_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "available": { + "name": "available", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checked_at": { + "name": "checked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_model_availability_token_model_unique": { + "name": "token_model_availability_token_model_unique", + "columns": [ + "token_id", + "model_name" + ], + "isUnique": true + }, + "token_model_availability_token_available_idx": { + "name": "token_model_availability_token_available_idx", + "columns": [ + "token_id", + "available" + ], + "isUnique": false + }, + "token_model_availability_model_name_idx": { + "name": "token_model_availability_model_name_idx", + "columns": [ + "model_name" + ], + "isUnique": false + }, + "token_model_availability_available_idx": { + "name": "token_model_availability_available_idx", + "columns": [ + "available" + ], + "isUnique": false + } + }, + "foreignKeys": { + "token_model_availability_token_id_account_tokens_id_fk": { + "name": "token_model_availability_token_id_account_tokens_id_fk", + "tableFrom": "token_model_availability", + "tableTo": "account_tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "token_routes": { + "name": "token_routes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "model_pattern": { + "name": "model_pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "display_icon": { + "name": "display_icon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_mapping": { + "name": "model_mapping", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_snapshot": { + "name": "decision_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "decision_refreshed_at": { + "name": "decision_refreshed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "routing_strategy": { + "name": "routing_strategy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'weighted'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "token_routes_model_pattern_idx": { + "name": "token_routes_model_pattern_idx", + "columns": [ + "model_pattern" + ], + "isUnique": false + }, + "token_routes_enabled_idx": { + "name": "token_routes_enabled_idx", + "columns": [ + "enabled" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 701397c..6e870bf 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,41 @@ "when": 1773528832339, "tag": "0009_model_availability_is_manual", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1773553945763, + "tag": "0010_proxy_logs_downstream_api_key", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1773605400000, + "tag": "0011_downstream_api_key_metadata", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1773665311013, + "tag": "0012_account_token_value_status", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1773682200000, + "tag": "0013_oauth_multi_provider", + "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1773855000000, + "tag": "0014_explicit_group_routes", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/electron-builder.yml b/electron-builder.yml index e99c646..ae093b1 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -4,7 +4,7 @@ artifactName: ${name}-${version}-${os}-${arch}.${ext} directories: output: release electronDist: node_modules/electron/dist -icon: build/icon.png +icon: build/desktop-icon.png asar: false files: - dist/**/* diff --git a/package-lock.json b/package-lock.json index 568d8f9..5f16914 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metapi", - "version": "1.2.2", + "version": "1.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metapi", - "version": "1.2.2", + "version": "1.2.3", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -15,7 +15,7 @@ "@fastify/cors": "^11.2.0", "@fastify/static": "^9.0.0", "@visactor/react-vchart": "^2.0.16", - "better-sqlite3": "^11.3.0", + "better-sqlite3": "^12.8.0", "dotenv": "^17.3.1", "drizzle-orm": "^0.36.4", "electron-log": "^5.2.4", @@ -28,7 +28,8 @@ "node-cron": "^4.2.1", "nodemailer": "^8.0.1", "pg": "^8.16.3", - "undici": "^6.24.1" + "undici": "^6.24.1", + "ws": "^8.19.0" }, "devDependencies": { "@electron/notarize": "^3.1.0", @@ -226,6 +227,7 @@ "integrity": "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", @@ -378,6 +380,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -704,6 +707,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -749,6 +753,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1238,7 +1243,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1260,7 +1264,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1277,7 +1280,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1292,7 +1294,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -4443,6 +4444,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -4852,6 +4854,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4879,6 +4882,7 @@ "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -4910,6 +4914,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -5636,6 +5641,7 @@ "integrity": "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.15.1", "@algolia/client-abtesting": "5.49.1", @@ -6070,6 +6076,7 @@ "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", @@ -6119,14 +6126,18 @@ } }, "node_modules/better-sqlite3": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", - "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, "node_modules/bindings": { @@ -6200,6 +6211,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6603,6 +6615,7 @@ "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@chevrotain/cst-dts-gen": "11.1.2", "@chevrotain/gast": "11.1.2", @@ -6958,8 +6971,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "7.0.3", @@ -7008,6 +7020,7 @@ "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10" } @@ -7441,6 +7454,7 @@ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -7875,6 +7889,7 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -8470,7 +8485,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8491,7 +8505,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8681,6 +8694,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -9071,6 +9085,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -11064,7 +11079,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -11109,6 +11123,7 @@ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.18.2.tgz", "integrity": "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==", "license": "MIT", + "peer": true, "dependencies": { "aws-ssl-profiles": "^1.1.2", "denque": "^2.1.0", @@ -11651,6 +11666,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", @@ -11748,6 +11764,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11928,7 +11945,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11946,7 +11962,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -12134,6 +12149,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -12146,6 +12162,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -12434,7 +12451,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -12447,8 +12463,7 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/rimraf/node_modules/brace-expansion": { "version": "1.1.12", @@ -12456,7 +12471,6 @@ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -12469,7 +12483,6 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -12491,7 +12504,6 @@ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -13473,7 +13485,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -14239,6 +14250,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14492,6 +14504,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -16633,6 +16646,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -16748,6 +16762,7 @@ "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", @@ -16937,6 +16952,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", diff --git a/package.json b/package.json index 17e3259..fc59bce 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metapi", - "version": "1.2.2", + "version": "1.2.3", "description": "Meta-layer management and unified proxy for AI API aggregation platforms", "main": "dist/desktop/main.js", "keywords": [ @@ -29,14 +29,15 @@ "dev:server": "tsx scripts/dev/run-server.ts --watch", "dev": "concurrently \"npm run dev:server\" \"vite\"", "dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"", - "build:web": "vite build", + "desktop:icons": "node scripts/desktop/generate-icons.mjs", + "build:web": "npm run desktop:icons && vite build", "build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts", "build:desktop": "tsc -p tsconfig.desktop.json", "build": "npm run build:web && npm run build:server && npm run build:desktop", "dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never", "dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64", - "package:desktop": "electron-builder --config electron-builder.yml --publish never", - "package:desktop:mac:intel": "electron-builder --config electron-builder.yml --publish never --mac --x64", + "package:desktop": "npm run desktop:icons && electron-builder --config electron-builder.yml --publish never", + "package:desktop:mac:intel": "npm run desktop:icons && electron-builder --config electron-builder.yml --publish never --mac --x64", "docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173", @@ -64,7 +65,7 @@ "@fastify/cors": "^11.2.0", "@fastify/static": "^9.0.0", "@visactor/react-vchart": "^2.0.16", - "better-sqlite3": "^11.3.0", + "better-sqlite3": "^12.8.0", "dotenv": "^17.3.1", "drizzle-orm": "^0.36.4", "electron-log": "^5.2.4", @@ -77,7 +78,8 @@ "node-cron": "^4.2.1", "nodemailer": "^8.0.1", "pg": "^8.16.3", - "undici": "^6.24.1" + "undici": "^6.24.1", + "ws": "^8.19.0" }, "devDependencies": { "@electron/notarize": "^3.1.0", diff --git a/query_site.ts b/query_site.ts new file mode 100644 index 0000000..85ef3b9 --- /dev/null +++ b/query_site.ts @@ -0,0 +1,44 @@ +import Database from 'better-sqlite3'; +import fs from 'fs'; + +const dbPath = './data/hub.db'; +const db = new Database(dbPath, { readonly: true }); + +async function run() { + const sites = db.prepare(`SELECT * FROM sites WHERE url LIKE '%openai.api-test.us.ci%'`).all() as any[]; + if (sites.length === 0) { + console.log("No sites found"); + return; + } + + const site = sites[0]; + const accounts = db.prepare(`SELECT * FROM accounts WHERE site_id = ?`).all(site.id) as any[]; + + if (accounts.length === 0) { + console.log("No accounts found"); + return; + } + + const account = accounts[0]; + const accessToken = account.access_token; + + console.log(`Site: ${site.url}, Platform: ${site.platform}`); + console.log(`Access Token: ${accessToken.substring(0, 10)}...`); + + try { + const res = await fetch(`${site.url}/api/token/?p=0`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Accept': 'application/json' + } + }); + const data = await res.json(); + fs.writeFileSync('tokens_result.json', JSON.stringify({ site, accounts, apiData: data }, null, 2)); + console.log('Saved result to tokens_result.json'); + } catch (err: any) { + console.error('Fetch error:', err.message); + } +} + +run(); diff --git a/query_tokens.ts b/query_tokens.ts new file mode 100644 index 0000000..80546ff --- /dev/null +++ b/query_tokens.ts @@ -0,0 +1,9 @@ +import Database from 'better-sqlite3'; +import fs from 'fs'; + +const dbPath = './data/hub.db'; +const db = new Database(dbPath, { readonly: true }); + +const tokens = db.prepare(`SELECT * FROM account_tokens WHERE account_id = 18`).all(); +fs.writeFileSync('account_tokens_18.json', JSON.stringify(tokens, null, 2)); +console.log('Saved tokens to account_tokens_18.json', tokens.length); diff --git a/scripts/desktop/generate-icons.mjs b/scripts/desktop/generate-icons.mjs new file mode 100644 index 0000000..ac587fe --- /dev/null +++ b/scripts/desktop/generate-icons.mjs @@ -0,0 +1,85 @@ +import { mkdir } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import sharp from 'sharp'; + +export const DESKTOP_ICON_SIZE = 512; +export const DESKTOP_ICON_PADDING = 40; +export const DESKTOP_ICON_RADIUS = 96; + +function createRoundedMask(size, cornerRadius) { + return Buffer.from( + ``, + ); +} + +async function renderDesktopIconBuffer({ + sourcePath, + size = DESKTOP_ICON_SIZE, + padding = DESKTOP_ICON_PADDING, + cornerRadius = DESKTOP_ICON_RADIUS, +}) { + const innerSize = size - padding * 2; + const roundedMask = createRoundedMask(innerSize, Math.min(cornerRadius, Math.floor(innerSize / 2))); + + const roundedInner = await sharp(sourcePath) + .resize(innerSize, innerSize, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .composite([{ input: roundedMask, blend: 'dest-in' }]) + .png() + .toBuffer(); + + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite([{ input: roundedInner, left: padding, top: padding }]) + .png() + .toBuffer(); +} + +export async function generateDesktopIconAssets({ + sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'), + buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'), + webOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-icon.png'), + size = DESKTOP_ICON_SIZE, + padding = DESKTOP_ICON_PADDING, + cornerRadius = DESKTOP_ICON_RADIUS, +} = {}) { + const outputBuffer = await renderDesktopIconBuffer({ + sourcePath, + size, + padding, + cornerRadius, + }); + + await Promise.all([ + mkdir(dirname(buildOutputPath), { recursive: true }), + mkdir(dirname(webOutputPath), { recursive: true }), + ]); + + await Promise.all([ + sharp(outputBuffer).toFile(buildOutputPath), + sharp(outputBuffer).toFile(webOutputPath), + ]); + + return { + buildOutputPath, + webOutputPath, + }; +} + +const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isDirectRun) { + const outputs = await generateDesktopIconAssets(); + console.log(`[metapi-desktop] Generated desktop icons: +- ${outputs.buildOutputPath} +- ${outputs.webOutputPath}`); +} diff --git a/scripts/desktop/generate-icons.test.ts b/scripts/desktop/generate-icons.test.ts new file mode 100644 index 0000000..b65247b --- /dev/null +++ b/scripts/desktop/generate-icons.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import sharp from 'sharp'; +import { afterEach, describe, expect, it } from 'vitest'; + +const tempDirs: string[] = []; + +async function makeTempDir() { + const dir = await mkdtemp(join(tmpdir(), 'metapi-desktop-icons-')); + tempDirs.push(dir); + return dir; +} + +function alphaAt(buffer: Buffer, width: number, x: number, y: number) { + return buffer[(y * width + x) * 4 + 3]; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('generateDesktopIconAssets', () => { + it('writes rounded desktop icon outputs for build and runtime usage', async () => { + const { generateDesktopIconAssets } = await import('./generate-icons.mjs'); + + const dir = await makeTempDir(); + const sourcePath = join(dir, 'logo.png'); + const buildOutputPath = join(dir, 'build.png'); + const webOutputPath = join(dir, 'desktop-icon.png'); + + await sharp({ + create: { + width: 512, + height: 512, + channels: 4, + background: { r: 255, g: 112, b: 48, alpha: 1 }, + }, + }).png().toFile(sourcePath); + + await generateDesktopIconAssets({ + sourcePath, + buildOutputPath, + webOutputPath, + }); + + const buildMeta = await sharp(buildOutputPath).metadata(); + const webMeta = await sharp(webOutputPath).metadata(); + + expect(buildMeta.width).toBe(512); + expect(buildMeta.height).toBe(512); + expect(webMeta.width).toBe(512); + expect(webMeta.height).toBe(512); + + const { data, info } = await sharp(buildOutputPath) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + expect(alphaAt(data, info.width, 0, 0)).toBe(0); + expect(alphaAt(data, info.width, 12, 12)).toBe(0); + expect(alphaAt(data, info.width, Math.floor(info.width / 2), 6)).toBe(0); + expect(alphaAt(data, info.width, Math.floor(info.width / 2), Math.floor(info.height / 2))).toBe(255); + + expect(await sharp(buildOutputPath).png().toBuffer()) + .toEqual(await sharp(webOutputPath).png().toBuffer()); + }); +}); diff --git a/scripts/desktop/release.workflow.test.ts b/scripts/desktop/release.workflow.test.ts new file mode 100644 index 0000000..23b37c3 --- /dev/null +++ b/scripts/desktop/release.workflow.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +describe('release workflow', () => { + it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => { + const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8'); + + expect(workflow).toContain('runner: macos-15-intel'); + expect(workflow).toContain('runner: macos-15'); + expect(workflow).toContain('expectedMacArch: x64'); + expect(workflow).toContain('expectedMacArch: arm64'); + expect(workflow).toContain('Verify packaged macOS architecture'); + expect(workflow).toContain('lipo -archs'); + }); +}); diff --git a/src/desktop/iconAssets.test.ts b/src/desktop/iconAssets.test.ts new file mode 100644 index 0000000..34b3227 --- /dev/null +++ b/src/desktop/iconAssets.test.ts @@ -0,0 +1,24 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +describe('desktop icon assets', () => { + it('keeps runtime icon paths aligned to the generated desktop icon', async () => { + const { + DESKTOP_BUILD_ICON_RELATIVE_PATH, + DESKTOP_RUNTIME_ICON_RELATIVE_PATH, + getDesktopRuntimeIconPath, + } = await import('./iconAssets.js'); + + expect(DESKTOP_RUNTIME_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-icon.png')); + expect(DESKTOP_BUILD_ICON_RELATIVE_PATH).toBe(join('build', 'desktop-icon.png')); + expect(getDesktopRuntimeIconPath('/app')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png')); + }); + + it('points electron-builder at the generated desktop package icon', async () => { + const configPath = join(process.cwd(), 'electron-builder.yml'); + const config = await readFile(configPath, 'utf8'); + + expect(config).toContain('icon: build/desktop-icon.png'); + }); +}); diff --git a/src/desktop/iconAssets.ts b/src/desktop/iconAssets.ts new file mode 100644 index 0000000..ee73dd0 --- /dev/null +++ b/src/desktop/iconAssets.ts @@ -0,0 +1,9 @@ +import { join } from 'node:path'; + +export const DESKTOP_RUNTIME_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-icon.png'); +export const DESKTOP_BUILD_ICON_RELATIVE_PATH = join('build', 'desktop-icon.png'); +export const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png'; + +export function getDesktopRuntimeIconPath(appPath: string) { + return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH); +} diff --git a/src/desktop/main.ts b/src/desktop/main.ts index f6d7ffe..dd81eff 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -9,7 +9,6 @@ import { } from 'electron'; import log from 'electron-log'; import electronUpdater from 'electron-updater'; -import getPort, { portNumbers } from 'get-port'; import { spawn, type ChildProcess } from 'node:child_process'; import { existsSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; @@ -18,9 +17,11 @@ import { createDesktopHealthUrl, createDesktopServerUrl, isFatalServerExit, + resolveDesktopServerPort, resolveDesktopServerWorkingDir, waitForServerReady, } from './runtime.js'; +import { getDesktopRuntimeIconPath } from './iconAssets.js'; const { autoUpdater } = electronUpdater; @@ -51,7 +52,7 @@ function getServerEntryPath() { } function getTrayIconPath() { - return join(app.getAppPath(), 'dist', 'web', 'logo.png'); + return getDesktopRuntimeIconPath(app.getAppPath()); } function getWindowIconPath() { @@ -68,12 +69,6 @@ function resolveExternalServerUrl() { return (process.env.METAPI_DESKTOP_EXTERNAL_SERVER_URL || '').trim(); } -async function resolveServerPort() { - const forcedPort = Number.parseInt(process.env.METAPI_DESKTOP_SERVER_PORT || '', 10); - if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort; - return getPort({ port: portNumbers(4310, 4399) }); -} - function showMainWindow() { if (!mainWindow) return; if (mainWindow.isMinimized()) mainWindow.restore(); @@ -185,7 +180,7 @@ async function waitForManagedServerReady(url: string) { async function startManagedBackend() { ensureDesktopDirs(); const serverEntryPath = getServerEntryPath(); - const port = await resolveServerPort(); + const port = resolveDesktopServerPort(process.env); serverUrl = createDesktopServerUrl(port); const env = buildDesktopServerEnv({ diff --git a/src/desktop/runtime.test.ts b/src/desktop/runtime.test.ts index f2bf957..1dbea80 100644 --- a/src/desktop/runtime.test.ts +++ b/src/desktop/runtime.test.ts @@ -3,12 +3,13 @@ import { buildDesktopServerEnv, createDesktopServerUrl, isFatalServerExit, + resolveDesktopServerPort, resolveDesktopServerWorkingDir, waitForServerReady, } from './runtime.js'; describe('desktop runtime helpers', () => { - it('builds desktop server env with loopback host and app directories', () => { + it('builds desktop server env with external listen host and app directories', () => { const env = buildDesktopServerEnv({ inheritedEnv: { AUTH_TOKEN: 'admin-token', @@ -19,7 +20,7 @@ describe('desktop runtime helpers', () => { port: 4312, }); - expect(env.HOST).toBe('127.0.0.1'); + expect(env.HOST).toBe('0.0.0.0'); expect(env.PORT).toBe('4312'); expect(env.DATA_DIR).toBe('/tmp/metapi-data'); expect(env.METAPI_LOG_DIR).toBe('/tmp/metapi-logs'); @@ -31,6 +32,16 @@ describe('desktop runtime helpers', () => { expect(createDesktopServerUrl(4312)).toBe('http://127.0.0.1:4312'); }); + it('defaults desktop backend port to 4000', () => { + expect(resolveDesktopServerPort({})).toBe(4000); + }); + + it('honors explicit desktop backend port override', () => { + expect(resolveDesktopServerPort({ + METAPI_DESKTOP_SERVER_PORT: '4312', + })).toBe(4312); + }); + it('uses resources path as backend cwd for packaged desktop builds', () => { expect(resolveDesktopServerWorkingDir({ appPath: 'C:/Users/test/AppData/Local/Programs/Metapi/resources/app.asar', diff --git a/src/desktop/runtime.ts b/src/desktop/runtime.ts index 71d0d2a..1ae4022 100644 --- a/src/desktop/runtime.ts +++ b/src/desktop/runtime.ts @@ -23,6 +23,7 @@ type DesktopServerWorkingDirInput = { isPackaged: boolean; }; +const DEFAULT_DESKTOP_SERVER_PORT = 4000; const DEFAULT_READY_TIMEOUT_MS = 30_000; const DEFAULT_READY_INTERVAL_MS = 250; @@ -31,9 +32,11 @@ function delay(ms: number) { } export function buildDesktopServerEnv(input: DesktopServerEnvInput): NodeJS.ProcessEnv { + const host = (input.inheritedEnv?.HOST || '0.0.0.0').trim() || '0.0.0.0'; + return { ...(input.inheritedEnv || {}), - HOST: '127.0.0.1', + HOST: host, PORT: String(input.port), DATA_DIR: input.userDataDir, METAPI_DESKTOP: '1', @@ -49,6 +52,12 @@ export function createDesktopHealthUrl(port: number): string { return `${createDesktopServerUrl(port)}/api/desktop/health`; } +export function resolveDesktopServerPort(env?: NodeJS.ProcessEnv): number { + const forcedPort = Number.parseInt(env?.METAPI_DESKTOP_SERVER_PORT || '', 10); + if (Number.isFinite(forcedPort) && forcedPort > 0) return forcedPort; + return DEFAULT_DESKTOP_SERVER_PORT; +} + export function resolveDesktopServerWorkingDir(input: DesktopServerWorkingDirInput): string { return input.isPackaged ? input.resourcesPath : input.appPath; } diff --git a/src/server/config.test.ts b/src/server/config.test.ts index 3bc80f6..60ef01b 100644 --- a/src/server/config.test.ts +++ b/src/server/config.test.ts @@ -11,7 +11,7 @@ describe('buildConfig', () => { expect(config.dataDir).toBe('./data'); }); - it('keeps desktop deployments bound to loopback', () => { + it('aligns desktop deployments with server deployments for listen host', () => { const config = buildConfig({ HOST: '0.0.0.0', METAPI_DESKTOP: '1', @@ -19,7 +19,7 @@ describe('buildConfig', () => { DATA_DIR: '/tmp/metapi-data', }); - expect(config.listenHost).toBe('127.0.0.1'); + expect(config.listenHost).toBe('0.0.0.0'); expect(config.port).toBe(4312); expect(config.dataDir).toBe('/tmp/metapi-data'); }); @@ -38,6 +38,16 @@ describe('buildConfig', () => { expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org'); }); + it('ships CLI-aligned OAuth defaults', () => { + const config = buildConfig({}); + + expect(config.codexClientId).toBe('app_EMoamEEZ73f0CkXaXp7hrann'); + expect(config.claudeClientId).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e'); + expect(config.claudeClientSecret).toBe(''); + expect(config.geminiCliClientId).toBe('681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'); + expect(config.geminiCliClientSecret).toBe('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'); + }); + it('accepts JSON request bodies larger than Fastify default 1 MiB', async () => { const app = Fastify(buildFastifyOptions(buildConfig({}))); const largeText = 'a'.repeat(2 * 1024 * 1024); diff --git a/src/server/config.ts b/src/server/config.ts index 3a45843..1d4caf9 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -2,6 +2,10 @@ import 'dotenv/config'; import type { FastifyServerOptions } from 'fastify'; const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024; +const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; +const DEFAULT_CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; +const DEFAULT_GEMINI_CLI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; +const DEFAULT_GEMINI_CLI_CLIENT_SECRET = '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'; function parseBoolean(value: string | undefined, fallback = false): boolean { if (value === undefined) return fallback; @@ -24,6 +28,10 @@ function parseCsvList(value: string | undefined): string[] { .filter((item) => item.length > 0); } +function parseOptionalSecret(value: string | undefined): string { + return (value || '').trim(); +} + function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' { const normalized = (value || 'sqlite').trim().toLowerCase(); if (normalized === 'mysql') return 'mysql'; @@ -32,11 +40,7 @@ function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' } function parseListenHost(env: NodeJS.ProcessEnv): string { - const requestedHost = (env.HOST || '0.0.0.0').trim() || '0.0.0.0'; - if (parseBoolean(env.METAPI_DESKTOP, false)) { - return '127.0.0.1'; - } - return requestedHost; + return (env.HOST || '0.0.0.0').trim() || '0.0.0.0'; } export function buildConfig(env: NodeJS.ProcessEnv) { @@ -45,6 +49,11 @@ export function buildConfig(env: NodeJS.ProcessEnv) { return { authToken: env.AUTH_TOKEN || 'change-me-admin-token', proxyToken: env.PROXY_TOKEN || 'change-me-proxy-sk-token', + codexClientId: parseOptionalSecret(env.CODEX_CLIENT_ID) || DEFAULT_CODEX_CLIENT_ID, + claudeClientId: parseOptionalSecret(env.CLAUDE_CLIENT_ID) || DEFAULT_CLAUDE_CLIENT_ID, + claudeClientSecret: parseOptionalSecret(env.CLAUDE_CLIENT_SECRET), + geminiCliClientId: parseOptionalSecret(env.GEMINI_CLI_CLIENT_ID) || DEFAULT_GEMINI_CLI_CLIENT_ID, + geminiCliClientSecret: parseOptionalSecret(env.GEMINI_CLI_CLIENT_SECRET) || DEFAULT_GEMINI_CLI_CLIENT_SECRET, systemProxyUrl: env.SYSTEM_PROXY_URL || '', accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token', checkinCron: env.CHECKIN_CRON || '0 8 * * *', diff --git a/src/server/db/accountTokenSchemaCompatibility.ts b/src/server/db/accountTokenSchemaCompatibility.ts index bd2ec40..c97b4a8 100644 --- a/src/server/db/accountTokenSchemaCompatibility.ts +++ b/src/server/db/accountTokenSchemaCompatibility.ts @@ -23,6 +23,15 @@ const ACCOUNT_TOKEN_COLUMN_COMPATIBILITY_SPECS: AccountTokenColumnCompatibilityS postgres: 'ALTER TABLE "account_tokens" ADD COLUMN "token_group" TEXT', }, }, + { + table: 'account_tokens', + column: 'value_status', + addSql: { + sqlite: "ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';", + mysql: "ALTER TABLE `account_tokens` ADD COLUMN `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready'", + postgres: "ALTER TABLE \"account_tokens\" ADD COLUMN \"value_status\" TEXT NOT NULL DEFAULT 'ready'", + }, + }, ]; function normalizeSchemaErrorMessage(error: unknown): string { diff --git a/src/server/db/generated/mysql.bootstrap.sql b/src/server/db/generated/mysql.bootstrap.sql index 233bca1..b29fe93 100644 --- a/src/server/db/generated/mysql.bootstrap.sql +++ b/src/server/db/generated/mysql.bootstrap.sql @@ -1,15 +1,16 @@ CREATE TABLE IF NOT EXISTS `sites` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `platform` TEXT NOT NULL, `status` VARCHAR(191) NOT NULL DEFAULT 'active', `api_key` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `proxy_url` TEXT, `use_system_proxy` BOOLEAN DEFAULT false, `custom_headers` JSON, `external_checkin_url` TEXT, `global_weight` DOUBLE DEFAULT 1); -CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` DATETIME, `last_balance_refresh` DATETIME, `extra_config` JSON, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `token_group` TEXT, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `accounts` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `username` TEXT, `access_token` TEXT NOT NULL, `api_token` TEXT, `balance` DOUBLE DEFAULT 0, `balance_used` DOUBLE DEFAULT 0, `quota` DOUBLE DEFAULT 0, `unit_cost` DOUBLE, `value_score` DOUBLE DEFAULT 0, `status` VARCHAR(191) DEFAULT 'active', `checkin_enabled` BOOLEAN DEFAULT true, `last_checkin_at` DATETIME, `last_balance_refresh` DATETIME, `extra_config` JSON, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_pinned` BOOLEAN DEFAULT false, `sort_order` INT DEFAULT 0, `oauth_provider` TEXT, `oauth_account_key` TEXT, `oauth_project_id` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS `account_tokens` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `name` TEXT NOT NULL, `token` TEXT NOT NULL, `source` VARCHAR(191) DEFAULT 'manual', `enabled` BOOLEAN DEFAULT true, `is_default` BOOLEAN DEFAULT false, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `token_group` TEXT, `value_status` VARCHAR(191) NOT NULL DEFAULT 'ready', FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `checkin_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `status` TEXT NOT NULL, `message` TEXT, `reward` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` DATETIME, `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP); +CREATE TABLE IF NOT EXISTS `downstream_api_keys` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `name` TEXT NOT NULL, `key` TEXT NOT NULL, `description` TEXT, `enabled` BOOLEAN DEFAULT true, `expires_at` DATETIME, `max_cost` DOUBLE, `used_cost` DOUBLE DEFAULT 0, `max_requests` INT, `used_requests` INT DEFAULT 0, `supported_models` JSON, `allowed_route_ids` JSON, `site_weight_multipliers` JSON, `last_used_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `group_name` TEXT, `tags` TEXT); CREATE TABLE IF NOT EXISTS `events` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `message` TEXT, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `read` BOOLEAN DEFAULT false, `related_id` INT, `related_type` TEXT, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS `model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `account_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `proxy_files` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `owner_type` TEXT NOT NULL, `owner_id` TEXT NOT NULL, `filename` TEXT NOT NULL, `mime_type` TEXT NOT NULL, `purpose` TEXT, `byte_size` INT NOT NULL, `sha256` TEXT NOT NULL, `content_base64` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `deleted_at` DATETIME); -CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON); +CREATE TABLE IF NOT EXISTS `proxy_logs` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT, `channel_id` INT, `account_id` INT, `model_requested` TEXT, `model_actual` TEXT, `status` TEXT, `http_status` INT, `latency_ms` INT, `prompt_tokens` INT, `completion_tokens` INT, `total_tokens` INT, `estimated_cost` DOUBLE, `error_message` TEXT, `retry_count` INT DEFAULT 0, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON, `downstream_api_key_id` INT); CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `public_id` TEXT NOT NULL, `upstream_video_id` TEXT NOT NULL, `site_url` TEXT NOT NULL, `token_value` TEXT NOT NULL, `requested_model` TEXT, `actual_model` TEXT, `channel_id` INT, `account_id` INT, `status_snapshot` JSON, `upstream_response_meta` JSON, `last_upstream_status` INT, `last_polled_at` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` DATETIME, `routing_strategy` VARCHAR(191) DEFAULT 'weighted'); +CREATE TABLE IF NOT EXISTS `token_routes` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `model_pattern` TEXT NOT NULL, `model_mapping` JSON, `enabled` BOOLEAN DEFAULT true, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` DATETIME, `routing_strategy` VARCHAR(191) DEFAULT 'weighted', `route_mode` VARCHAR(191) DEFAULT 'pattern'); CREATE TABLE IF NOT EXISTS `route_channels` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `route_id` INT NOT NULL, `account_id` INT NOT NULL, `token_id` INT, `source_model` TEXT, `priority` INT DEFAULT 0, `weight` INT DEFAULT 10, `enabled` BOOLEAN DEFAULT true, `manual_override` BOOLEAN DEFAULT false, `success_count` INT DEFAULT 0, `fail_count` INT DEFAULT 0, `total_latency_ms` INT DEFAULT 0, `total_cost` DOUBLE DEFAULT 0, `last_used_at` DATETIME, `last_selected_at` DATETIME, `last_fail_at` DATETIME, `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` DATETIME, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL); +CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `settings` (`key` VARCHAR(191) NOT NULL PRIMARY KEY, `value` TEXT); CREATE TABLE IF NOT EXISTS `site_disabled_models` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `model_name` TEXT NOT NULL, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS `token_model_availability` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `token_id` INT NOT NULL, `model_name` TEXT NOT NULL, `available` BOOLEAN, `latency_ms` INT, `checked_at` DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE); @@ -17,11 +18,15 @@ CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (` CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191)); CREATE UNIQUE INDEX `proxy_files_public_id_unique` ON `proxy_files` (`public_id`(191)); CREATE UNIQUE INDEX `proxy_video_tasks_public_id_unique` ON `proxy_video_tasks` (`public_id`(191)); +CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`); CREATE UNIQUE INDEX `site_disabled_models_site_model_unique` ON `site_disabled_models` (`site_id`, `model_name`(191)); +CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191)); CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191)); CREATE INDEX `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`, `enabled`); CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`); CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`); +CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`(191), `oauth_account_key`(191), `oauth_project_id`(191)); +CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`(191)); CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`); CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`(191)); CREATE INDEX `accounts_status_idx` ON `accounts` (`status`(191)); @@ -39,6 +44,7 @@ CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model CREATE INDEX `proxy_files_owner_lookup_idx` ON `proxy_files` (`owner_type`(191), `owner_id`(191), `deleted_at`); CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`); CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`); +CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`); CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`); CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`(191), `created_at`); CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`); @@ -48,6 +54,7 @@ CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`); CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`); CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`); +CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`); CREATE INDEX `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`); CREATE INDEX `sites_status_idx` ON `sites` (`status`(191)); CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`); diff --git a/src/server/db/generated/mysql.upgrade.sql b/src/server/db/generated/mysql.upgrade.sql index 9c88768..2e455b5 100644 --- a/src/server/db/generated/mysql.upgrade.sql +++ b/src/server/db/generated/mysql.upgrade.sql @@ -1 +1,4 @@ -ALTER TABLE `model_availability` ADD COLUMN `is_manual` BOOLEAN DEFAULT false; +CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` INT NOT NULL, `source_route_id` INT NOT NULL, FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE); +ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(191) DEFAULT 'pattern'; +CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`, `source_route_id`); +CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`); diff --git a/src/server/db/generated/postgres.bootstrap.sql b/src/server/db/generated/postgres.bootstrap.sql index 8402c44..29b8f81 100644 --- a/src/server/db/generated/postgres.bootstrap.sql +++ b/src/server/db/generated/postgres.bootstrap.sql @@ -1,15 +1,16 @@ CREATE TABLE IF NOT EXISTS "sites" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "url" TEXT NOT NULL, "platform" TEXT NOT NULL, "status" TEXT NOT NULL DEFAULT 'active', "api_key" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "proxy_url" TEXT, "use_system_proxy" BOOLEAN DEFAULT false, "custom_headers" JSONB, "external_checkin_url" TEXT, "global_weight" DOUBLE PRECISION DEFAULT 1); -CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TIMESTAMP, "last_balance_refresh" TIMESTAMP, "extra_config" JSONB, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "token_group" TEXT, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "accounts" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "username" TEXT, "access_token" TEXT NOT NULL, "api_token" TEXT, "balance" DOUBLE PRECISION DEFAULT 0, "balance_used" DOUBLE PRECISION DEFAULT 0, "quota" DOUBLE PRECISION DEFAULT 0, "unit_cost" DOUBLE PRECISION, "value_score" DOUBLE PRECISION DEFAULT 0, "status" TEXT DEFAULT 'active', "checkin_enabled" BOOLEAN DEFAULT true, "last_checkin_at" TIMESTAMP, "last_balance_refresh" TIMESTAMP, "extra_config" JSONB, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_pinned" BOOLEAN DEFAULT false, "sort_order" INTEGER DEFAULT 0, "oauth_provider" TEXT, "oauth_account_key" TEXT, "oauth_project_id" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); +CREATE TABLE IF NOT EXISTS "account_tokens" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "name" TEXT NOT NULL, "token" TEXT NOT NULL, "source" TEXT DEFAULT 'manual', "enabled" BOOLEAN DEFAULT true, "is_default" BOOLEAN DEFAULT false, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "token_group" TEXT, "value_status" TEXT NOT NULL DEFAULT 'ready', FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "checkin_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "status" TEXT NOT NULL, "message" TEXT, "reward" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); -CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TIMESTAMP, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); +CREATE TABLE IF NOT EXISTS "downstream_api_keys" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "name" TEXT NOT NULL, "key" TEXT NOT NULL, "description" TEXT, "enabled" BOOLEAN DEFAULT true, "expires_at" TIMESTAMP, "max_cost" DOUBLE PRECISION, "used_cost" DOUBLE PRECISION DEFAULT 0, "max_requests" INTEGER, "used_requests" INTEGER DEFAULT 0, "supported_models" JSONB, "allowed_route_ids" JSONB, "site_weight_multipliers" JSONB, "last_used_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "group_name" TEXT, "tags" TEXT); CREATE TABLE IF NOT EXISTS "events" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "type" TEXT NOT NULL, "title" TEXT NOT NULL, "message" TEXT, "level" TEXT NOT NULL DEFAULT 'info', "read" BOOLEAN DEFAULT false, "related_id" INTEGER, "related_type" TEXT, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS "model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "account_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "proxy_files" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "owner_type" TEXT NOT NULL, "owner_id" TEXT NOT NULL, "filename" TEXT NOT NULL, "mime_type" TEXT NOT NULL, "purpose" TEXT, "byte_size" INTEGER NOT NULL, "sha256" TEXT NOT NULL, "content_base64" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMP); -CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB); +CREATE TABLE IF NOT EXISTS "proxy_logs" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER, "channel_id" INTEGER, "account_id" INTEGER, "model_requested" TEXT, "model_actual" TEXT, "status" TEXT, "http_status" INTEGER, "latency_ms" INTEGER, "prompt_tokens" INTEGER, "completion_tokens" INTEGER, "total_tokens" INTEGER, "estimated_cost" DOUBLE PRECISION, "error_message" TEXT, "retry_count" INTEGER DEFAULT 0, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB, "downstream_api_key_id" INTEGER); CREATE TABLE IF NOT EXISTS "proxy_video_tasks" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "public_id" TEXT NOT NULL, "upstream_video_id" TEXT NOT NULL, "site_url" TEXT NOT NULL, "token_value" TEXT NOT NULL, "requested_model" TEXT, "actual_model" TEXT, "channel_id" INTEGER, "account_id" INTEGER, "status_snapshot" JSONB, "upstream_response_meta" JSONB, "last_upstream_status" INTEGER, "last_polled_at" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP); -CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TIMESTAMP, "routing_strategy" TEXT DEFAULT 'weighted'); +CREATE TABLE IF NOT EXISTS "token_routes" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "model_pattern" TEXT NOT NULL, "model_mapping" JSONB, "enabled" BOOLEAN DEFAULT true, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TIMESTAMP, "routing_strategy" TEXT DEFAULT 'weighted', "route_mode" TEXT DEFAULT 'pattern'); CREATE TABLE IF NOT EXISTS "route_channels" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "route_id" INTEGER NOT NULL, "account_id" INTEGER NOT NULL, "token_id" INTEGER, "source_model" TEXT, "priority" INTEGER DEFAULT 0, "weight" INTEGER DEFAULT 10, "enabled" BOOLEAN DEFAULT true, "manual_override" BOOLEAN DEFAULT false, "success_count" INTEGER DEFAULT 0, "fail_count" INTEGER DEFAULT 0, "total_latency_ms" INTEGER DEFAULT 0, "total_cost" DOUBLE PRECISION DEFAULT 0, "last_used_at" TIMESTAMP, "last_selected_at" TIMESTAMP, "last_fail_at" TIMESTAMP, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL); +CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "settings" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT); CREATE TABLE IF NOT EXISTS "site_disabled_models" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS "token_model_availability" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "token_id" INTEGER NOT NULL, "model_name" TEXT NOT NULL, "available" BOOLEAN, "latency_ms" INTEGER, "checked_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE); @@ -17,11 +18,15 @@ CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" (" CREATE UNIQUE INDEX "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name"); CREATE UNIQUE INDEX "proxy_files_public_id_unique" ON "proxy_files" ("public_id"); CREATE UNIQUE INDEX "proxy_video_tasks_public_id_unique" ON "proxy_video_tasks" ("public_id"); +CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id"); CREATE UNIQUE INDEX "site_disabled_models_site_model_unique" ON "site_disabled_models" ("site_id", "model_name"); +CREATE UNIQUE INDEX "sites_platform_url_unique" ON "sites" ("platform", "url"); CREATE UNIQUE INDEX "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name"); CREATE INDEX "account_tokens_account_enabled_idx" ON "account_tokens" ("account_id", "enabled"); CREATE INDEX "account_tokens_account_id_idx" ON "account_tokens" ("account_id"); CREATE INDEX "account_tokens_enabled_idx" ON "account_tokens" ("enabled"); +CREATE INDEX "accounts_oauth_identity_idx" ON "accounts" ("oauth_provider", "oauth_account_key", "oauth_project_id"); +CREATE INDEX "accounts_oauth_provider_idx" ON "accounts" ("oauth_provider"); CREATE INDEX "accounts_site_id_idx" ON "accounts" ("site_id"); CREATE INDEX "accounts_site_status_idx" ON "accounts" ("site_id", "status"); CREATE INDEX "accounts_status_idx" ON "accounts" ("status"); @@ -39,6 +44,7 @@ CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model CREATE INDEX "proxy_files_owner_lookup_idx" ON "proxy_files" ("owner_type", "owner_id", "deleted_at"); CREATE INDEX "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at"); CREATE INDEX "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at"); +CREATE INDEX "proxy_logs_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at"); CREATE INDEX "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at"); CREATE INDEX "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at"); CREATE INDEX "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at"); @@ -48,6 +54,7 @@ CREATE INDEX "route_channels_route_enabled_idx" ON "route_channels" ("route_id", CREATE INDEX "route_channels_route_id_idx" ON "route_channels" ("route_id"); CREATE INDEX "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id"); CREATE INDEX "route_channels_token_id_idx" ON "route_channels" ("token_id"); +CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id"); CREATE INDEX "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id"); CREATE INDEX "sites_status_idx" ON "sites" ("status"); CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available"); diff --git a/src/server/db/generated/postgres.upgrade.sql b/src/server/db/generated/postgres.upgrade.sql index 266130b..f8ca612 100644 --- a/src/server/db/generated/postgres.upgrade.sql +++ b/src/server/db/generated/postgres.upgrade.sql @@ -1 +1,4 @@ -ALTER TABLE "model_availability" ADD COLUMN "is_manual" BOOLEAN DEFAULT false; +CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE); +ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT 'pattern'; +CREATE UNIQUE INDEX "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id"); +CREATE INDEX "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id"); diff --git a/src/server/db/generated/schemaContract.json b/src/server/db/generated/schemaContract.json index f57e87e..75e577d 100644 --- a/src/server/db/generated/schemaContract.json +++ b/src/server/db/generated/schemaContract.json @@ -61,6 +61,12 @@ "notNull": false, "defaultValue": null, "primaryKey": false + }, + "value_status": { + "logicalType": "text", + "notNull": true, + "defaultValue": "'ready'", + "primaryKey": false } } }, @@ -179,6 +185,24 @@ "notNull": false, "defaultValue": "0", "primaryKey": false + }, + "oauth_provider": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "oauth_account_key": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "oauth_project_id": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false } } }, @@ -319,6 +343,18 @@ "notNull": false, "defaultValue": "datetime('now')", "primaryKey": false + }, + "group_name": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false + }, + "tags": { + "logicalType": "text", + "notNull": false, + "defaultValue": null, + "primaryKey": false } } }, @@ -611,6 +647,12 @@ "notNull": false, "defaultValue": null, "primaryKey": false + }, + "downstream_api_key_id": { + "logicalType": "integer", + "notNull": false, + "defaultValue": null, + "primaryKey": false } } }, @@ -826,6 +868,28 @@ } } }, + "route_group_sources": { + "columns": { + "id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": true + }, + "group_route_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + }, + "source_route_id": { + "logicalType": "integer", + "notNull": true, + "defaultValue": null, + "primaryKey": false + } + } + }, "settings": { "columns": { "key": { @@ -1071,6 +1135,12 @@ "notNull": false, "defaultValue": "'weighted'", "primaryKey": false + }, + "route_mode": { + "logicalType": "text", + "notNull": false, + "defaultValue": "'pattern'", + "primaryKey": false } } } @@ -1101,6 +1171,24 @@ ], "unique": false }, + { + "name": "accounts_oauth_identity_idx", + "table": "accounts", + "columns": [ + "oauth_provider", + "oauth_account_key", + "oauth_project_id" + ], + "unique": false + }, + { + "name": "accounts_oauth_provider_idx", + "table": "accounts", + "columns": [ + "oauth_provider" + ], + "unique": false + }, { "name": "accounts_site_id_idx", "table": "accounts", @@ -1270,6 +1358,15 @@ ], "unique": false }, + { + "name": "proxy_logs_downstream_api_key_created_at_idx", + "table": "proxy_logs", + "columns": [ + "downstream_api_key_id", + "created_at" + ], + "unique": false + }, { "name": "proxy_logs_model_actual_created_at_idx", "table": "proxy_logs", @@ -1354,6 +1451,23 @@ ], "unique": false }, + { + "name": "route_group_sources_group_source_unique", + "table": "route_group_sources", + "columns": [ + "group_route_id", + "source_route_id" + ], + "unique": true + }, + { + "name": "route_group_sources_source_route_id_idx", + "table": "route_group_sources", + "columns": [ + "source_route_id" + ], + "unique": false + }, { "name": "site_disabled_models_site_id_idx", "table": "site_disabled_models", @@ -1371,6 +1485,15 @@ ], "unique": true }, + { + "name": "sites_platform_url_unique", + "table": "sites", + "columns": [ + "platform", + "url" + ], + "unique": true + }, { "name": "sites_status_idx", "table": "sites", @@ -1460,6 +1583,14 @@ "public_id" ] }, + { + "name": "route_group_sources_group_source_unique", + "table": "route_group_sources", + "columns": [ + "group_route_id", + "source_route_id" + ] + }, { "name": "site_disabled_models_site_model_unique", "table": "site_disabled_models", @@ -1468,6 +1599,14 @@ "model_name" ] }, + { + "name": "sites_platform_url_unique", + "table": "sites", + "columns": [ + "platform", + "url" + ] + }, { "name": "token_model_availability_token_model_unique", "table": "token_model_availability", @@ -1555,6 +1694,28 @@ ], "onDelete": "SET NULL" }, + { + "table": "route_group_sources", + "columns": [ + "group_route_id" + ], + "referencedTable": "token_routes", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, + { + "table": "route_group_sources", + "columns": [ + "source_route_id" + ], + "referencedTable": "token_routes", + "referencedColumns": [ + "id" + ], + "onDelete": "CASCADE" + }, { "table": "site_disabled_models", "columns": [ diff --git a/src/server/db/index.ts b/src/server/db/index.ts index bb27a1b..72627be 100644 --- a/src/server/db/index.ts +++ b/src/server/db/index.ts @@ -8,7 +8,6 @@ import * as schema from './schema.js'; import { ensureSiteSchemaCompatibility, type SiteSchemaInspector } from './siteSchemaCompatibility.js'; import { ensureRouteGroupingSchemaCompatibility } from './routeGroupingSchemaCompatibility.js'; import { ensureProxyFileSchemaCompatibility } from './proxyFileSchemaCompatibility.js'; -import { ensureSharedIndexSchemaCompatibility } from './sharedIndexSchemaCompatibility.js'; import { executeLegacyCompat, executeLegacyCompatSync } from './legacySchemaCompat.js'; import { config } from '../config.js'; import { ensureRuntimeDatabaseReady } from '../runtimeDatabaseBootstrap.js'; @@ -28,6 +27,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([ 'model_availability', 'token_model_availability', 'token_routes', + 'route_group_sources', 'route_channels', 'proxy_logs', 'proxy_video_tasks', @@ -42,6 +42,7 @@ let sqliteConnection: Database.Database | null = null; let mysqlPool: mysql.Pool | null = null; let pgPool: pg.Pool | null = null; let proxyLogBillingDetailsColumnAvailable: boolean | null = null; +let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null; function resolveSqlitePath(): string { const raw = (config.dbUrl || '').trim(); @@ -137,6 +138,7 @@ function ensureTokenManagementSchema() { name text NOT NULL, token text NOT NULL, token_group text, + value_status text NOT NULL DEFAULT 'ready', source text DEFAULT 'manual', enabled integer DEFAULT true, is_default integer DEFAULT false, @@ -152,6 +154,9 @@ function ensureTokenManagementSchema() { if (!tableColumnExists('account_tokens', 'token_group')) { execSqliteLegacyCompat('ALTER TABLE account_tokens ADD COLUMN token_group text;'); } + if (!tableColumnExists('account_tokens', 'value_status')) { + execSqliteLegacyCompat("ALTER TABLE account_tokens ADD COLUMN value_status text NOT NULL DEFAULT 'ready';"); + } execSqliteStatement(` INSERT INTO account_tokens (account_id, name, token, source, enabled, is_default, created_at, updated_at) @@ -446,12 +451,6 @@ export async function ensureProxyFileCompatibilityColumns(): Promise { await ensureProxyFileSchemaCompatibility(inspector); } -export async function ensureSharedIndexCompatibility(): Promise { - const inspector = createRuntimeSchemaInspector(); - if (!inspector) return; - await ensureSharedIndexSchemaCompatibility(inspector); -} - function ensureRouteGroupingSchema() { if (!tableExists('token_routes') || !tableExists('route_channels')) { return; @@ -465,6 +464,10 @@ function ensureRouteGroupingSchema() { execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN display_icon text;`); } + if (!tableColumnExists('token_routes', 'route_mode')) { + execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT 'pattern';`); + } + if (!tableColumnExists('token_routes', 'decision_snapshot')) { execSqliteLegacyCompat(`ALTER TABLE token_routes ADD COLUMN decision_snapshot text;`); } @@ -492,6 +495,22 @@ function ensureRouteGroupingSchema() { if (!tableColumnExists('route_channels', 'cooldown_level')) { execSqliteLegacyCompat(`ALTER TABLE route_channels ADD COLUMN cooldown_level integer NOT NULL DEFAULT 0;`); } + + execSqliteLegacyCompat(` + CREATE TABLE IF NOT EXISTS route_group_sources ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, + source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade + ); + `); + execSqliteLegacyCompat(` + CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique + ON route_group_sources(group_route_id, source_route_id); + `); + execSqliteLegacyCompat(` + CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx + ON route_group_sources(source_route_id); + `); } function ensureDownstreamApiKeySchema() { @@ -501,6 +520,8 @@ function ensureDownstreamApiKeySchema() { name text NOT NULL, key text NOT NULL, description text, + group_name text, + tags text, enabled integer DEFAULT true, expires_at text, max_cost real, @@ -532,6 +553,14 @@ function ensureDownstreamApiKeySchema() { CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx ON downstream_api_keys(expires_at); `); + + if (!tableColumnExists('downstream_api_keys', 'group_name')) { + execSqliteLegacyCompat('ALTER TABLE downstream_api_keys ADD COLUMN group_name text;'); + } + + if (!tableColumnExists('downstream_api_keys', 'tags')) { + execSqliteLegacyCompat('ALTER TABLE downstream_api_keys ADD COLUMN tags text;'); + } } function ensureProxyLogBillingDetailsSchema() { @@ -546,6 +575,18 @@ function ensureProxyLogBillingDetailsSchema() { proxyLogBillingDetailsColumnAvailable = true; } +function ensureProxyLogDownstreamApiKeyIdSchema() { + if (!tableExists('proxy_logs')) { + return; + } + + if (!tableColumnExists('proxy_logs', 'downstream_api_key_id')) { + execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN downstream_api_key_id integer;'); + } + + proxyLogDownstreamApiKeyIdColumnAvailable = true; +} + function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { return String((error as { message?: unknown }).message || ''); @@ -624,8 +665,75 @@ export async function ensureProxyLogBillingDetailsColumn(): Promise { } } +export async function hasProxyLogDownstreamApiKeyIdColumn(): Promise { + if (proxyLogDownstreamApiKeyIdColumnAvailable !== null) { + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (runtimeDbDialect === 'sqlite') { + proxyLogDownstreamApiKeyIdColumnAvailable = tableExists('proxy_logs') + && tableColumnExists('proxy_logs', 'downstream_api_key_id'); + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + const [rows] = await mysqlPool.query('SHOW COLUMNS FROM `proxy_logs` LIKE ?', ['downstream_api_key_id']); + proxyLogDownstreamApiKeyIdColumnAvailable = Array.isArray(rows) && rows.length > 0; + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (!pgPool) return false; + const result = await pgPool.query( + 'SELECT 1 FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = $2 LIMIT 1', + ['proxy_logs', 'downstream_api_key_id'], + ); + proxyLogDownstreamApiKeyIdColumnAvailable = Number(result.rowCount || 0) > 0; + return proxyLogDownstreamApiKeyIdColumnAvailable; +} + +export async function ensureProxyLogDownstreamApiKeyIdColumn(): Promise { + if (runtimeDbDialect === 'sqlite') { + ensureProxyLogDownstreamApiKeyIdSchema(); + proxyLogDownstreamApiKeyIdColumnAvailable = tableExists('proxy_logs') + && tableColumnExists('proxy_logs', 'downstream_api_key_id'); + return proxyLogDownstreamApiKeyIdColumnAvailable; + } + + if (await hasProxyLogDownstreamApiKeyIdColumn()) { + return true; + } + + try { + if (runtimeDbDialect === 'mysql') { + if (!mysqlPool) return false; + await executeLegacyCompat( + (statement) => mysqlPool!.query(statement).then(() => undefined), + 'ALTER TABLE `proxy_logs` ADD COLUMN `downstream_api_key_id` INT NULL', + ); + } else { + if (!pgPool) return false; + await executeLegacyCompat( + (statement) => pgPool!.query(statement).then(() => undefined), + 'ALTER TABLE "proxy_logs" ADD COLUMN "downstream_api_key_id" INTEGER', + ); + } + proxyLogDownstreamApiKeyIdColumnAvailable = true; + return true; + } catch (error) { + if (isDuplicateColumnError(error)) { + proxyLogDownstreamApiKeyIdColumnAvailable = true; + return true; + } + proxyLogDownstreamApiKeyIdColumnAvailable = false; + console.warn('[db] failed to ensure proxy_logs.downstream_api_key_id column', error); + return false; + } +} + function resetSchemaCapabilityCache() { proxyLogBillingDetailsColumnAvailable = null; + proxyLogDownstreamApiKeyIdColumnAvailable = null; } async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) { diff --git a/src/server/db/legacySchemaCompat.ts b/src/server/db/legacySchemaCompat.ts index 0e20a3e..38bcdb9 100644 --- a/src/server/db/legacySchemaCompat.ts +++ b/src/server/db/legacySchemaCompat.ts @@ -32,6 +32,7 @@ export interface LegacySchemaCompatInspector extends const LEGACY_COMPAT_TABLES = new Set([ 'account_tokens', 'token_model_availability', + 'route_group_sources', 'proxy_video_tasks', 'proxy_files', 'downstream_api_keys', @@ -46,8 +47,10 @@ const LEGACY_COMPAT_COLUMNS = new Set([ 'sites.external_checkin_url', 'sites.global_weight', 'account_tokens.token_group', + 'account_tokens.value_status', 'token_routes.display_name', 'token_routes.display_icon', + 'token_routes.route_mode', 'token_routes.decision_snapshot', 'token_routes.decision_refreshed_at', 'token_routes.routing_strategy', @@ -60,7 +63,10 @@ const LEGACY_COMPAT_COLUMNS = new Set([ 'proxy_video_tasks.upstream_response_meta', 'proxy_video_tasks.last_upstream_status', 'proxy_video_tasks.last_polled_at', + 'downstream_api_keys.group_name', + 'downstream_api_keys.tags', 'proxy_logs.billing_details', + 'proxy_logs.downstream_api_key_id', ]); const LEGACY_COMPAT_INDEXES = new Set([ @@ -75,6 +81,9 @@ const LEGACY_COMPAT_INDEXES = new Set([ 'downstream_api_keys_name_idx', 'downstream_api_keys_enabled_idx', 'downstream_api_keys_expires_at_idx', + 'proxy_logs_downstream_api_key_created_at_idx', + 'route_group_sources_group_source_unique', + 'route_group_sources_source_route_id_idx', ...SHARED_INDEX_COMPATIBILITY_SPECS.map((spec) => spec.indexName), ]); diff --git a/src/server/db/migrate.test.ts b/src/server/db/migrate.test.ts index d9cc867..08695c5 100644 --- a/src/server/db/migrate.test.ts +++ b/src/server/db/migrate.test.ts @@ -208,6 +208,64 @@ describe('sqlite migrate bootstrap', () => { sqlite.close(); }); + it('recovers duplicate-column errors when the failed SQL contains quoted literals', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-quoted-')); + process.env.DATA_DIR = dataDir; + vi.resetModules(); + + const migrateModule = await import('./migrate.js'); + const { __migrateTestUtils } = migrateModule; + + const sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE account_tokens ( + id integer PRIMARY KEY AUTOINCREMENT NOT NULL, + value_status text DEFAULT 'ready' NOT NULL + ); + `); + + const tempMigrationsDir = mkdtempSync(join(tmpdir(), 'metapi-migration-files-quoted-')); + mkdirSync(join(tempMigrationsDir, 'meta'), { recursive: true }); + + writeFileSync( + join(tempMigrationsDir, 'meta', '_journal.json'), + JSON.stringify({ + entries: [ + { + tag: '0012_account_token_value_status', + when: 1773665311013, + }, + ], + }), + ); + + writeFileSync( + join(tempMigrationsDir, '0012_account_token_value_status.sql'), + "ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n", + ); + + const duplicateColumnError = new Error( + "DrizzleError: Failed to run the query 'ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;\n' duplicate column name: value_status", + ); + + const recovered = __migrateTestUtils.tryRecoverDuplicateColumnMigrationError( + sqlite, + tempMigrationsDir, + duplicateColumnError, + ); + + expect(recovered).toBe(true); + + const applied = sqlite + .prepare('SELECT hash, created_at FROM __drizzle_migrations') + .all() as Array<{ hash: string; created_at: number }>; + + expect(applied).toHaveLength(1); + expect(Number(applied[0].created_at)).toBe(1773665311013); + + sqlite.close(); + }); + it('recovers duplicate-column errors inside multi-statement migrations by replaying the full migration', async () => { const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-recover-multi-')); process.env.DATA_DIR = dataDir; @@ -283,6 +341,10 @@ describe('sqlite migrate bootstrap', () => { '0007_account_token_group', '0008_sqlite_schema_backfill', '0009_model_availability_is_manual', + '0010_proxy_logs_downstream_api_key', + '0011_downstream_api_key_metadata', + '0012_account_token_value_status', + '0013_oauth_multi_provider', ]); const appliedEntries = journalEntries.filter((entry) => !missingTags.has(entry.tag)); @@ -322,4 +384,84 @@ describe('sqlite migrate bootstrap', () => { verified.close(); }); + + it('deduplicates legacy duplicate sites before applying the oauth site unique index', async () => { + const dataDir = mkdtempSync(join(tmpdir(), 'metapi-migrate-duplicate-sites-')); + const dbPath = join(dataDir, 'hub.db'); + const sqlite = new Database(dbPath); + const journalEntries = readMigrationJournalEntries(); + const appliedEntries = journalEntries.filter((entry) => entry.tag !== '0013_oauth_multi_provider'); + + for (const entry of appliedEntries) { + const sqlText = readFileSync(join(migrationsDir, `${entry.tag}.sql`), 'utf8'); + applyMigrationSql(sqlite, sqlText); + } + recordAppliedMigrations(sqlite, appliedEntries); + + sqlite.exec(` + INSERT INTO sites (id, name, url, platform, status, is_pinned, sort_order, global_weight) + VALUES + (101, 'Primary Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'active', 0, 0, 1), + (202, 'Duplicate Codex', 'https://chatgpt.com/backend-api/codex', 'codex', 'disabled', 1, 9, 3); + + INSERT INTO accounts (site_id, username, access_token, status, checkin_enabled) + VALUES + (101, 'first@example.com', 'token-a', 'active', 0), + (202, 'second@example.com', 'token-b', 'disabled', 0); + + INSERT INTO site_disabled_models (site_id, model_name) + VALUES + (101, 'gpt-5'), + (202, 'gpt-5'), + (202, 'gpt-5-mini'); + `); + + sqlite.close(); + + process.env.DATA_DIR = dataDir; + vi.resetModules(); + + await expect(import('./migrate.js')).resolves.toMatchObject({ + runSqliteMigrations: expect.any(Function), + }); + + const verified = new Database(dbPath, { readonly: true }); + const sites = verified + .prepare('SELECT id, name, url, platform, status, is_pinned, sort_order, global_weight FROM sites ORDER BY id ASC') + .all() as Array<{ + id: number; + name: string; + url: string; + platform: string; + status: string; + is_pinned: number; + sort_order: number; + global_weight: number; + }>; + const accounts = verified + .prepare('SELECT username, site_id FROM accounts ORDER BY username ASC') + .all() as Array<{ username: string; site_id: number }>; + const disabledModels = verified + .prepare('SELECT site_id, model_name FROM site_disabled_models ORDER BY site_id ASC, model_name ASC') + .all() as Array<{ site_id: number; model_name: string }>; + + expect(sites).toEqual([ + expect.objectContaining({ + id: 101, + name: 'Primary Codex', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }), + ]); + expect(accounts).toEqual([ + { username: 'first@example.com', site_id: 101 }, + { username: 'second@example.com', site_id: 101 }, + ]); + expect(disabledModels).toEqual([ + { site_id: 101, model_name: 'gpt-5' }, + { site_id: 101, model_name: 'gpt-5-mini' }, + ]); + + verified.close(); + }); }); diff --git a/src/server/db/migrate.ts b/src/server/db/migrate.ts index 5321d06..a2758d4 100644 --- a/src/server/db/migrate.ts +++ b/src/server/db/migrate.ts @@ -34,7 +34,13 @@ type RecoveryMigration = RecoveryMigrationRecord & { statements: string[]; }; -const VERIFIED_BOOTSTRAP_TAG = '0009_model_availability_is_manual'; +type LegacySiteRow = { + id: number; + platform: string; + url: string; +}; + +const VERIFIED_BOOTSTRAP_TAG = '0012_account_token_value_status'; const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ { table: 'sites' }, { table: 'settings' }, @@ -57,6 +63,13 @@ const VERIFIED_SCHEMA_MARKERS: SchemaMarker[] = [ { table: 'account_tokens', column: 'token_group' }, // 0009: is_manual column on model_availability { table: 'model_availability', column: 'is_manual' }, + // 0010: downstream_api_key_id column on proxy_logs + { table: 'proxy_logs', column: 'downstream_api_key_id' }, + // 0011: downstream key metadata columns + { table: 'downstream_api_keys', column: 'group_name' }, + { table: 'downstream_api_keys', column: 'tags' }, + // 0012: value_status column on account_tokens + { table: 'account_tokens', column: 'value_status' }, ]; @@ -198,6 +211,28 @@ function findMatchingMigrationByStatement( return null; } +function findMatchingMigrationByErrorMessage( + migrationsFolder: string, + error: unknown, +): RecoveryMigrationRecord | null { + const normalizedErrorMessage = normalizeSqlForMatch(normalizeSchemaErrorMessage(error)); + const migrations = readRecoveryMigrations(migrationsFolder); + + for (const migration of migrations) { + if (!migration.statements.some((statement) => normalizedErrorMessage.includes(normalizeSqlForMatch(statement)))) { + continue; + } + + return { + tag: migration.tag, + createdAt: migration.createdAt, + hash: migration.hash, + }; + } + + return null; +} + function readRecoveryMigrations(migrationsFolder: string): RecoveryMigration[] { const journalPath = resolve(migrationsFolder, 'meta', '_journal.json'); const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as MigrationJournalFile; @@ -239,6 +274,14 @@ function markMigrationRecordIfMissing(sqlite: Database.Database, record: Migrati return true; } +function hasMigrationRecord(sqlite: Database.Database, record: MigrationRecord): boolean { + if (!tableExists(sqlite, '__drizzle_migrations')) return false; + const row = sqlite + .prepare('SELECT 1 FROM "__drizzle_migrations" WHERE "hash" = ? LIMIT 1') + .get(record.hash); + return !!row; +} + function normalizeSchemaErrorMessage(error: unknown): string { if (!error || typeof error !== 'object') { return String(error || ''); @@ -282,15 +325,19 @@ function isRecoverableSchemaConflictError(error: unknown): boolean { || lowered.includes('already exists'); } -function getLatestRecordedMigrationCreatedAt(sqlite: Database.Database): number | null { - if (!tableExists(sqlite, '__drizzle_migrations')) return null; - const row = sqlite - .prepare('SELECT created_at FROM "__drizzle_migrations" ORDER BY created_at DESC LIMIT 1') - .get() as { created_at?: number } | undefined; - if (!row || row.created_at === undefined || row.created_at === null) { - return null; +function isSitesPlatformUrlUniqueConflictError(error: unknown): boolean { + const lowered = normalizeSchemaErrorMessage(error).toLowerCase(); + if (!lowered.includes('unique constraint failed: sites.platform, sites.url')) { + return false; } - return Number(row.created_at); + + const failedSqlText = extractFailedSqlFromError(error); + if (!failedSqlText) { + return true; + } + + return normalizeSqlForMatch(failedSqlText) + === normalizeSqlForMatch('CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`);'); } function replayMigrationStatements(sqlite: Database.Database, statements: string[]): void { @@ -298,9 +345,23 @@ function replayMigrationStatements(sqlite: Database.Database, statements: string try { sqlite.exec(statement); } catch (error) { - if (!isRecoverableSchemaConflictError(error)) { - throw error; + if (isRecoverableSchemaConflictError(error)) { + continue; } + + if (isSitesPlatformUrlUniqueConflictError(error) && deduplicateLegacySitesForUniqueIndex(sqlite)) { + try { + sqlite.exec(statement); + continue; + } catch (retryError) { + if (isRecoverableSchemaConflictError(retryError)) { + continue; + } + throw retryError; + } + } + + throw error; } } } @@ -316,20 +377,40 @@ function recoverMigrationSequence( return false; } - let latestRecordedCreatedAt = getLatestRecordedMigrationCreatedAt(sqlite); for (const migration of migrations.slice(0, failedMigrationIndex + 1)) { - if (latestRecordedCreatedAt !== null && latestRecordedCreatedAt >= migration.createdAt) { + if (hasMigrationRecord(sqlite, migration)) { continue; } replayMigrationStatements(sqlite, migration.statements); markMigrationRecordIfMissing(sqlite, migration); - latestRecordedCreatedAt = migration.createdAt; } return true; } +function backfillMissingRecordedMigrations(sqlite: Database.Database, migrationsFolder: string): number { + if (!tableExists(sqlite, '__drizzle_migrations')) return 0; + + let recoveredCount = 0; + for (const migration of readRecoveryMigrations(migrationsFolder)) { + if (hasMigrationRecord(sqlite, migration)) { + continue; + } + + replayMigrationStatements(sqlite, migration.statements); + if (markMigrationRecordIfMissing(sqlite, migration)) { + recoveredCount += 1; + } + } + + if (recoveredCount > 0) { + console.warn(`[db] Backfilled ${recoveredCount} missing drizzle migration record(s).`); + } + + return recoveredCount; +} + function tryRecoverDuplicateColumnMigrationError( sqlite: Database.Database, migrationsFolder: string, @@ -340,11 +421,10 @@ function tryRecoverDuplicateColumnMigrationError( } const failedSqlText = extractFailedSqlFromError(error); - if (!failedSqlText) { - return false; - } - - const matchedMigration = findMatchingMigrationByStatement(migrationsFolder, failedSqlText); + const matchedMigration = failedSqlText + ? findMatchingMigrationByStatement(migrationsFolder, failedSqlText) + ?? findMatchingMigrationByErrorMessage(migrationsFolder, error) + : findMatchingMigrationByErrorMessage(migrationsFolder, error); if (!matchedMigration) { return false; } @@ -356,16 +436,120 @@ function tryRecoverDuplicateColumnMigrationError( return recovered; } +function rewriteDownstreamSiteWeightMultipliers( + sqlite: Database.Database, + siteIdMapping: Map, +): void { + if (siteIdMapping.size <= 0) return; + if (!tableExists(sqlite, 'downstream_api_keys')) return; + if (!columnExists(sqlite, 'downstream_api_keys', 'site_weight_multipliers')) return; + + const rows = sqlite.prepare(` + SELECT id, site_weight_multipliers + FROM downstream_api_keys + WHERE site_weight_multipliers IS NOT NULL + AND TRIM(site_weight_multipliers) <> '' + `).all() as Array<{ id: number; site_weight_multipliers: string | null }>; + + const update = sqlite.prepare('UPDATE downstream_api_keys SET site_weight_multipliers = ? WHERE id = ?'); + for (const row of rows) { + if (!row.site_weight_multipliers) continue; + + let parsed: unknown; + try { + parsed = JSON.parse(row.site_weight_multipliers); + } catch { + continue; + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) continue; + const nextValue = { ...(parsed as Record) }; + let changed = false; + + for (const [fromSiteId, toSiteId] of siteIdMapping.entries()) { + const fromKey = String(fromSiteId); + const toKey = String(toSiteId); + if (!(fromKey in nextValue)) continue; + if (!(toKey in nextValue)) { + nextValue[toKey] = nextValue[fromKey]; + } + delete nextValue[fromKey]; + changed = true; + } + + if (!changed) continue; + update.run(JSON.stringify(nextValue), row.id); + } +} + +function deduplicateLegacySitesForUniqueIndex(sqlite: Database.Database): boolean { + const duplicateGroups = sqlite.prepare(` + SELECT platform, url + FROM sites + GROUP BY platform, url + HAVING COUNT(*) > 1 + `).all() as Array<{ platform: string; url: string }>; + + if (duplicateGroups.length <= 0) { + return false; + } + + const selectSitesByIdentity = sqlite.prepare(` + SELECT id, platform, url + FROM sites + WHERE platform = ? AND url = ? + ORDER BY id ASC + `); + const rebindAccounts = sqlite.prepare('UPDATE accounts SET site_id = ? WHERE site_id = ?'); + const mergeDisabledModels = sqlite.prepare(` + INSERT OR IGNORE INTO site_disabled_models (site_id, model_name, created_at) + SELECT ?, model_name, created_at + FROM site_disabled_models + WHERE site_id = ? + `); + const deleteDisabledModels = sqlite.prepare('DELETE FROM site_disabled_models WHERE site_id = ?'); + const deleteSite = sqlite.prepare('DELETE FROM sites WHERE id = ?'); + + const siteIdMapping = new Map(); + + const transaction = sqlite.transaction(() => { + for (const group of duplicateGroups) { + const sites = selectSitesByIdentity.all(group.platform, group.url) as LegacySiteRow[]; + if (sites.length <= 1) continue; + + const canonicalSiteId = sites[0]!.id; + for (const site of sites.slice(1)) { + mergeDisabledModels.run(canonicalSiteId, site.id); + deleteDisabledModels.run(site.id); + rebindAccounts.run(canonicalSiteId, site.id); + siteIdMapping.set(site.id, canonicalSiteId); + deleteSite.run(site.id); + } + } + + rewriteDownstreamSiteWeightMultipliers(sqlite, siteIdMapping); + }); + + transaction(); + if (siteIdMapping.size > 0) { + console.warn(`[db] Deduplicated ${siteIdMapping.size} legacy site entries before applying sites_platform_url_unique.`); + } + return siteIdMapping.size > 0; +} + export const __migrateTestUtils = { splitMigrationStatements, normalizeSqlForMatch, extractFailedSqlFromError, findMatchingSingleStatementMigration, findMatchingMigrationByStatement, + findMatchingMigrationByErrorMessage, readRecoveryMigrations, markMigrationRecordIfMissing, recoverMigrationSequence, tryRecoverDuplicateColumnMigrationError, + isSitesPlatformUrlUniqueConflictError, + deduplicateLegacySitesForUniqueIndex, }; function bootstrapLegacyDrizzleMigrations(sqlite: Database.Database, migrationsFolder: string): boolean { @@ -404,11 +588,18 @@ export function runSqliteMigrations(): void { const sqlite = new Database(dbPath); bootstrapLegacyDrizzleMigrations(sqlite, migrationsFolder); + backfillMissingRecordedMigrations(sqlite, migrationsFolder); try { migrate(drizzle(sqlite), { migrationsFolder }); } catch (error) { - if (!tryRecoverDuplicateColumnMigrationError(sqlite, migrationsFolder, error)) { + const recoveredDuplicateColumns = tryRecoverDuplicateColumnMigrationError(sqlite, migrationsFolder, error); + const recoveredDuplicateSites = ( + !recoveredDuplicateColumns + && isSitesPlatformUrlUniqueConflictError(error) + && deduplicateLegacySitesForUniqueIndex(sqlite) + ); + if (!recoveredDuplicateColumns && !recoveredDuplicateSites) { sqlite.close(); throw error; } diff --git a/src/server/db/routeGroupingSchemaCompatibility.test.ts b/src/server/db/routeGroupingSchemaCompatibility.test.ts index 3c1db60..bf500b9 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.test.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.test.ts @@ -8,9 +8,11 @@ function createInspector( dialect: RouteGroupingSchemaInspector['dialect'], options?: { existingColumnsByTable?: Partial>; + existingTables?: Array<'token_routes' | 'route_channels' | 'route_group_sources'>; }, ) { const executedSql: string[] = []; + const existingTables = new Set(options?.existingTables ?? ['token_routes', 'route_channels']); const existingColumnsByTable = { token_routes: new Set(options?.existingColumnsByTable?.token_routes ?? []), route_channels: new Set(options?.existingColumnsByTable?.route_channels ?? []), @@ -19,7 +21,7 @@ function createInspector( const inspector: RouteGroupingSchemaInspector = { dialect, async tableExists(table) { - return table === 'token_routes' || table === 'route_channels'; + return existingTables.has(table as 'token_routes' | 'route_channels' | 'route_group_sources'); }, async columnExists(table, column) { if (table === 'token_routes' || table === 'route_channels') { @@ -40,8 +42,12 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { { dialect: 'sqlite' as const, expectedSql: [ + 'CREATE TABLE IF NOT EXISTS route_group_sources (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade);', + 'CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique ON route_group_sources(group_route_id, source_route_id);', + 'CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx ON route_group_sources(source_route_id);', 'ALTER TABLE token_routes ADD COLUMN display_name text;', 'ALTER TABLE token_routes ADD COLUMN display_icon text;', + 'ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT \'pattern\';', 'ALTER TABLE token_routes ADD COLUMN decision_snapshot text;', 'ALTER TABLE token_routes ADD COLUMN decision_refreshed_at text;', 'ALTER TABLE token_routes ADD COLUMN routing_strategy text DEFAULT \'weighted\';', @@ -54,8 +60,12 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { { dialect: 'postgres' as const, expectedSql: [ + 'CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, CONSTRAINT "route_group_sources_group_route_id_token_routes_id_fk" FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, CONSTRAINT "route_group_sources_source_route_id_token_routes_id_fk" FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE)', + 'CREATE UNIQUE INDEX IF NOT EXISTS "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id")', + 'CREATE INDEX IF NOT EXISTS "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id")', 'ALTER TABLE "token_routes" ADD COLUMN "display_name" TEXT', 'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT', + 'ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT \'pattern\'', 'ALTER TABLE "token_routes" ADD COLUMN "decision_snapshot" TEXT', 'ALTER TABLE "token_routes" ADD COLUMN "decision_refreshed_at" TEXT', 'ALTER TABLE "token_routes" ADD COLUMN "routing_strategy" TEXT DEFAULT \'weighted\'', @@ -68,8 +78,12 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { { dialect: 'mysql' as const, expectedSql: [ + 'CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` int AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` int NOT NULL, `source_route_id` int NOT NULL, CONSTRAINT `route_group_sources_group_route_id_token_routes_id_fk` FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade, CONSTRAINT `route_group_sources_source_route_id_token_routes_id_fk` FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade)', + 'CREATE UNIQUE INDEX IF NOT EXISTS `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`)', + 'CREATE INDEX IF NOT EXISTS `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`)', 'ALTER TABLE `token_routes` ADD COLUMN `display_name` TEXT NULL', 'ALTER TABLE `token_routes` ADD COLUMN `display_icon` TEXT NULL', + 'ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(32) NULL DEFAULT \'pattern\'', 'ALTER TABLE `token_routes` ADD COLUMN `decision_snapshot` TEXT NULL', 'ALTER TABLE `token_routes` ADD COLUMN `decision_refreshed_at` TEXT NULL', 'ALTER TABLE `token_routes` ADD COLUMN `routing_strategy` VARCHAR(32) NULL DEFAULT \'weighted\'', @@ -90,9 +104,10 @@ describe('ensureRouteGroupingSchemaCompatibility', () => { it('skips existing columns', async () => { const { inspector, executedSql } = createInspector('postgres', { existingColumnsByTable: { - token_routes: ['display_name', 'display_icon', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy'], + token_routes: ['display_name', 'display_icon', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy'], route_channels: ['source_model', 'last_selected_at', 'consecutive_fail_count', 'cooldown_level'], }, + existingTables: ['token_routes', 'route_channels', 'route_group_sources'], }); await ensureRouteGroupingSchemaCompatibility(inspector); diff --git a/src/server/db/routeGroupingSchemaCompatibility.ts b/src/server/db/routeGroupingSchemaCompatibility.ts index c36183c..18b6c59 100644 --- a/src/server/db/routeGroupingSchemaCompatibility.ts +++ b/src/server/db/routeGroupingSchemaCompatibility.ts @@ -13,6 +13,11 @@ type RouteGroupingColumnCompatibilitySpec = { addSql: Record; }; +type RouteGroupingTableCompatibilitySpec = { + table: 'route_group_sources'; + createSql: Record; +}; + const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilitySpec[] = [ { table: 'token_routes', @@ -32,6 +37,15 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit postgres: 'ALTER TABLE "token_routes" ADD COLUMN "display_icon" TEXT', }, }, + { + table: 'token_routes', + column: 'route_mode', + addSql: { + sqlite: 'ALTER TABLE token_routes ADD COLUMN route_mode text DEFAULT \'pattern\';', + mysql: 'ALTER TABLE `token_routes` ADD COLUMN `route_mode` VARCHAR(32) NULL DEFAULT \'pattern\'', + postgres: 'ALTER TABLE "token_routes" ADD COLUMN "route_mode" TEXT DEFAULT \'pattern\'', + }, + }, { table: 'token_routes', column: 'decision_snapshot', @@ -97,6 +111,29 @@ const ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS: RouteGroupingColumnCompatibilit }, ]; +const ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS: RouteGroupingTableCompatibilitySpec[] = [ + { + table: 'route_group_sources', + createSql: { + sqlite: [ + 'CREATE TABLE IF NOT EXISTS route_group_sources (id integer PRIMARY KEY AUTOINCREMENT NOT NULL, group_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade, source_route_id integer NOT NULL REFERENCES token_routes(id) ON DELETE cascade);', + 'CREATE UNIQUE INDEX IF NOT EXISTS route_group_sources_group_source_unique ON route_group_sources(group_route_id, source_route_id);', + 'CREATE INDEX IF NOT EXISTS route_group_sources_source_route_id_idx ON route_group_sources(source_route_id);', + ], + mysql: [ + 'CREATE TABLE IF NOT EXISTS `route_group_sources` (`id` int AUTO_INCREMENT NOT NULL PRIMARY KEY, `group_route_id` int NOT NULL, `source_route_id` int NOT NULL, CONSTRAINT `route_group_sources_group_route_id_token_routes_id_fk` FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade, CONSTRAINT `route_group_sources_source_route_id_token_routes_id_fk` FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON DELETE cascade)', + 'CREATE UNIQUE INDEX IF NOT EXISTS `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`)', + 'CREATE INDEX IF NOT EXISTS `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`)', + ], + postgres: [ + 'CREATE TABLE IF NOT EXISTS "route_group_sources" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "group_route_id" INTEGER NOT NULL, "source_route_id" INTEGER NOT NULL, CONSTRAINT "route_group_sources_group_route_id_token_routes_id_fk" FOREIGN KEY ("group_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, CONSTRAINT "route_group_sources_source_route_id_token_routes_id_fk" FOREIGN KEY ("source_route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE)', + 'CREATE UNIQUE INDEX IF NOT EXISTS "route_group_sources_group_source_unique" ON "route_group_sources" ("group_route_id", "source_route_id")', + 'CREATE INDEX IF NOT EXISTS "route_group_sources_source_route_id_idx" ON "route_group_sources" ("source_route_id")', + ], + }, + }, +]; + function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { return String((error as { message?: unknown }).message || ''); @@ -124,6 +161,18 @@ async function executeAddColumn(inspector: RouteGroupingSchemaInspector, sqlText export async function ensureRouteGroupingSchemaCompatibility(inspector: RouteGroupingSchemaInspector): Promise { const tableExistsCache = new Map(); + for (const spec of ROUTE_GROUPING_TABLE_COMPATIBILITY_SPECS) { + const hasTable = await inspector.tableExists(spec.table); + tableExistsCache.set(spec.table, hasTable); + if (hasTable) { + continue; + } + for (const sqlText of spec.createSql[inspector.dialect]) { + await executeAddColumn(inspector, sqlText); + } + tableExistsCache.set(spec.table, true); + } + for (const spec of ROUTE_GROUPING_COLUMN_COMPATIBILITY_SPECS) { let hasTable = tableExistsCache.get(spec.table); if (hasTable === undefined) { diff --git a/src/server/db/runtimeSchemaBootstrap.test.ts b/src/server/db/runtimeSchemaBootstrap.test.ts index ec31d52..adcf117 100644 --- a/src/server/db/runtimeSchemaBootstrap.test.ts +++ b/src/server/db/runtimeSchemaBootstrap.test.ts @@ -2,6 +2,7 @@ import baselineContract from './generated/fixtures/2026-03-14-baseline.schemaCon import currentContract from './generated/schemaContract.json' with { type: 'json' }; import { classifyLegacyCompatMutation } from './legacySchemaCompat.js'; import { generateUpgradeSql } from './schemaArtifactGenerator.js'; +import type { SchemaContract, SchemaContractColumn } from './schemaContract.js'; import { describe, expect, it } from 'vitest'; import { __runtimeSchemaBootstrapTestUtils, @@ -17,6 +18,9 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]): commit: async () => {}, rollback: async () => {}, execute: async (sqlText: string) => { + if (sqlText.trim().toLowerCase().startsWith('select')) { + return []; + } executedSql.push(sqlText); return []; }, @@ -33,6 +37,16 @@ function createStubClient(dialect: RuntimeSchemaDialect, executedSql: string[]): }; } +function makeColumn(overrides: Partial = {}): SchemaContractColumn { + return { + logicalType: 'text', + notNull: false, + defaultValue: null, + primaryKey: false, + ...overrides, + }; +} + describe('runtime schema bootstrap', () => { it.each(['mysql', 'postgres'] as const)('executes live-schema upgrade statements for %s', async (dialect) => { const executedSql: string[] = []; @@ -149,4 +163,142 @@ describe('runtime schema bootstrap', () => { expect(executedSql).toContain(targetSql); }); + + it('adds mysql text prefixes for new indexes when live datetime-like columns are still stored as text', async () => { + const executedSql: string[] = []; + const minimalContract: SchemaContract = { + tables: { + proxy_logs: { + columns: { + downstream_api_key_id: makeColumn({ logicalType: 'integer' }), + created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }), + }, + }, + }, + indexes: [ + { + name: 'proxy_logs_downstream_api_key_created_at_idx', + table: 'proxy_logs', + columns: ['downstream_api_key_id', 'created_at'], + unique: false, + }, + ], + uniques: [], + foreignKeys: [], + }; + + await ensureRuntimeDatabaseSchema({ + ...createStubClient('mysql', executedSql), + execute: async (sqlText: string) => { + if (sqlText.includes('FROM information_schema.columns')) { + return [[ + { + table_name: 'proxy_logs', + column_name: 'downstream_api_key_id', + data_type: 'int', + column_type: 'int', + }, + { + table_name: 'proxy_logs', + column_name: 'created_at', + data_type: 'text', + column_type: 'text', + }, + ]]; + } + + executedSql.push(sqlText); + return []; + }, + queryScalar: async (sqlText: string, params: unknown[] = []) => { + if (sqlText.includes('information_schema.tables')) { + return params[0] === 'proxy_logs' ? 1 : 0; + } + if (sqlText.includes('information_schema.columns')) { + return 1; + } + return 0; + }, + }, { + currentContract: minimalContract, + liveContract: { + ...minimalContract, + indexes: [], + }, + }); + + expect(executedSql).toContain( + 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', + ); + }); + + it('does not add mysql text prefixes when live indexed text columns are varchar-backed', async () => { + const executedSql: string[] = []; + const minimalContract: SchemaContract = { + tables: { + sites: { + columns: { + platform: makeColumn({ logicalType: 'text', notNull: true }), + url: makeColumn({ logicalType: 'text', notNull: true }), + }, + }, + }, + indexes: [], + uniques: [ + { + name: 'sites_platform_url_unique', + table: 'sites', + columns: ['platform', 'url'], + }, + ], + foreignKeys: [], + }; + + await ensureRuntimeDatabaseSchema({ + ...createStubClient('mysql', executedSql), + execute: async (sqlText: string) => { + if (sqlText.includes('FROM information_schema.columns')) { + return [[ + { + table_name: 'sites', + column_name: 'platform', + data_type: 'varchar', + column_type: 'varchar(32)', + }, + { + table_name: 'sites', + column_name: 'url', + data_type: 'varchar', + column_type: 'varchar(255)', + }, + ]]; + } + + executedSql.push(sqlText); + return []; + }, + queryScalar: async (sqlText: string, params: unknown[] = []) => { + if (sqlText.includes('information_schema.tables')) { + return params[0] === 'sites' ? 1 : 0; + } + if (sqlText.includes('information_schema.columns')) { + return 1; + } + return 0; + }, + }, { + currentContract: minimalContract, + liveContract: { + ...minimalContract, + uniques: [], + }, + }); + + expect(executedSql).toContain( + 'CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`, `url`)', + ); + expect(executedSql).not.toContain( + 'CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191))', + ); + }); }); diff --git a/src/server/db/runtimeSchemaBootstrap.ts b/src/server/db/runtimeSchemaBootstrap.ts index 1838527..673c9c4 100644 --- a/src/server/db/runtimeSchemaBootstrap.ts +++ b/src/server/db/runtimeSchemaBootstrap.ts @@ -7,7 +7,11 @@ import { ensureLegacySchemaCompatibility, type LegacySchemaCompatInspector, } from './legacySchemaCompat.js'; -import { generateBootstrapSql, generateUpgradeSql } from './schemaArtifactGenerator.js'; +import { + generateBootstrapSql, + generateUpgradeSql, + type MysqlIndexPrefixRequirementMap, +} from './schemaArtifactGenerator.js'; import { introspectLiveSchema } from './schemaIntrospection.js'; import { resolveGeneratedSchemaContractPath, type SchemaContract } from './schemaContract.js'; @@ -233,6 +237,88 @@ function buildCompatibleRuntimeBaseline( return baseline; } +function collectIndexedColumns(contract: SchemaContract): Map> { + const indexedColumns = new Map>(); + + for (const index of [...contract.indexes, ...contract.uniques]) { + let columns = indexedColumns.get(index.table); + if (!columns) { + columns = new Set(); + indexedColumns.set(index.table, columns); + } + + for (const columnName of index.columns) { + columns.add(columnName); + } + } + + return indexedColumns; +} + +function requiresMysqlIndexPrefixForColumnType(columnType: string): boolean { + const normalizedType = columnType.trim().toLowerCase(); + return normalizedType.includes('text') || normalizedType.includes('blob'); +} + +async function queryRuntimeRows( + client: RuntimeSchemaClient, + sqlText: string, + params: unknown[] = [], +): Promise>> { + const result = await client.execute(sqlText, params); + + if (!Array.isArray(result)) { + return []; + } + + const [first] = result; + if (Array.isArray(first)) { + return first as Array>; + } + + if (result.every((item) => typeof item === 'object' && item !== null && !Array.isArray(item))) { + return result as Array>; + } + + return []; +} + +async function resolveMySqlIndexPrefixRequirements( + client: RuntimeSchemaClient, + currentContract: SchemaContract, +): Promise { + const indexedColumns = collectIndexedColumns(currentContract); + if (indexedColumns.size === 0) { + return {}; + } + + const rows = await queryRuntimeRows(client, ` + SELECT + table_name AS table_name, + column_name AS column_name, + data_type AS data_type, + column_type AS column_type + FROM information_schema.columns + WHERE table_schema = DATABASE() + `); + + const requirements: MysqlIndexPrefixRequirementMap = {}; + for (const row of rows) { + const tableName = String(row.table_name || ''); + const columnName = String(row.column_name || ''); + const trackedColumns = indexedColumns.get(tableName); + if (!trackedColumns || !trackedColumns.has(columnName)) { + continue; + } + + const declaredType = String(row.column_type || row.data_type || ''); + requirements[tableName] ??= {}; + requirements[tableName][columnName] = requiresMysqlIndexPrefixForColumnType(declaredType); + } + + return requirements; +} + async function createPostgresClient(connectionString: string, ssl: boolean): Promise { const clientOptions: pg.ClientConfig = { connectionString }; if (ssl) { @@ -346,9 +432,12 @@ function buildExternalUpgradeStatements( dialect: Exclude, currentContract: SchemaContract, liveContract: SchemaContract, + mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap, ): string[] { const compatibleBaseline = buildCompatibleRuntimeBaseline(currentContract, liveContract); - return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline)); + return splitSqlStatements(generateUpgradeSql(dialect, currentContract, compatibleBaseline, { + mysqlIndexPrefixRequirements, + })); } export async function ensureRuntimeDatabaseSchema( @@ -356,13 +445,23 @@ export async function ensureRuntimeDatabaseSchema( options: EnsureRuntimeDatabaseSchemaOptions = {}, ): Promise { const currentContract = options.currentContract ?? readSchemaContract(); - const statements = client.dialect === 'sqlite' - ? splitSqlStatements(generateBootstrapSql('sqlite', currentContract)) - : buildExternalUpgradeStatements( + let statements: string[]; + + if (client.dialect === 'sqlite') { + statements = splitSqlStatements(generateBootstrapSql('sqlite', currentContract)); + } else { + const liveContract = await resolveLiveContract(client, options.liveContract); + const mysqlIndexPrefixRequirements = client.dialect === 'mysql' + ? await resolveMySqlIndexPrefixRequirements(client, currentContract) + : undefined; + + statements = buildExternalUpgradeStatements( client.dialect, currentContract, - await resolveLiveContract(client, options.liveContract), + liveContract, + mysqlIndexPrefixRequirements, ); + } for (const sqlText of statements) { await executeBootstrapStatement(client, sqlText); diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 413ea31..b6956e3 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -6,7 +6,7 @@ export const sites = sqliteTable('sites', { name: text('name').notNull(), url: text('url').notNull(), externalCheckinUrl: text('external_checkin_url'), - platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' + platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' | 'codex' | 'gemini-cli' | 'antigravity' proxyUrl: text('proxy_url'), useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false), customHeaders: text('custom_headers'), @@ -19,6 +19,7 @@ export const sites = sqliteTable('sites', { updatedAt: text('updated_at').default(sql`(datetime('now'))`), }, (table) => ({ statusIdx: index('sites_status_idx').on(table.status), + platformUrlUnique: uniqueIndex('sites_platform_url_unique').on(table.platform, table.url), })); export const siteDisabledModels = sqliteTable('site_disabled_models', { @@ -48,6 +49,9 @@ export const accounts = sqliteTable('accounts', { checkinEnabled: integer('checkin_enabled', { mode: 'boolean' }).default(true), lastCheckinAt: text('last_checkin_at'), lastBalanceRefresh: text('last_balance_refresh'), + oauthProvider: text('oauth_provider'), + oauthAccountKey: text('oauth_account_key'), + oauthProjectId: text('oauth_project_id'), extraConfig: text('extra_config'), // JSON string createdAt: text('created_at').default(sql`(datetime('now'))`), updatedAt: text('updated_at').default(sql`(datetime('now'))`), @@ -55,6 +59,8 @@ export const accounts = sqliteTable('accounts', { siteIdIdx: index('accounts_site_id_idx').on(table.siteId), statusIdx: index('accounts_status_idx').on(table.status), siteStatusIdx: index('accounts_site_status_idx').on(table.siteId, table.status), + oauthProviderIdx: index('accounts_oauth_provider_idx').on(table.oauthProvider), + oauthIdentityIdx: index('accounts_oauth_identity_idx').on(table.oauthProvider, table.oauthAccountKey, table.oauthProjectId), })); export const accountTokens = sqliteTable('account_tokens', { @@ -63,6 +69,7 @@ export const accountTokens = sqliteTable('account_tokens', { name: text('name').notNull(), token: text('token').notNull(), tokenGroup: text('token_group'), + valueStatus: text('value_status').notNull().default('ready'), source: text('source').default('manual'), // 'manual' | 'sync' | 'legacy' enabled: integer('enabled', { mode: 'boolean' }).default(true), isDefault: integer('is_default', { mode: 'boolean' }).default(false), @@ -120,6 +127,7 @@ export const tokenRoutes = sqliteTable('token_routes', { modelPattern: text('model_pattern').notNull(), displayName: text('display_name'), displayIcon: text('display_icon'), + routeMode: text('route_mode').default('pattern'), modelMapping: text('model_mapping'), // JSON decisionSnapshot: text('decision_snapshot'), // JSON decisionRefreshedAt: text('decision_refreshed_at'), @@ -132,6 +140,15 @@ export const tokenRoutes = sqliteTable('token_routes', { enabledIdx: index('token_routes_enabled_idx').on(table.enabled), })); +export const routeGroupSources = sqliteTable('route_group_sources', { + id: integer('id').primaryKey({ autoIncrement: true }), + groupRouteId: integer('group_route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }), + sourceRouteId: integer('source_route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }), +}, (table) => ({ + groupSourceUnique: uniqueIndex('route_group_sources_group_source_unique').on(table.groupRouteId, table.sourceRouteId), + sourceRouteIdx: index('route_group_sources_source_route_id_idx').on(table.sourceRouteId), +})); + export const routeChannels = sqliteTable('route_channels', { id: integer('id').primaryKey({ autoIncrement: true }), routeId: integer('route_id').notNull().references(() => tokenRoutes.id, { onDelete: 'cascade' }), @@ -165,6 +182,7 @@ export const proxyLogs = sqliteTable('proxy_logs', { routeId: integer('route_id'), channelId: integer('channel_id'), accountId: integer('account_id'), + downstreamApiKeyId: integer('downstream_api_key_id'), modelRequested: text('model_requested'), modelActual: text('model_actual'), status: text('status'), // 'success' | 'failed' | 'retried' @@ -183,6 +201,7 @@ export const proxyLogs = sqliteTable('proxy_logs', { accountCreatedIdx: index('proxy_logs_account_created_at_idx').on(table.accountId, table.createdAt), statusCreatedIdx: index('proxy_logs_status_created_at_idx').on(table.status, table.createdAt), modelActualCreatedIdx: index('proxy_logs_model_actual_created_at_idx').on(table.modelActual, table.createdAt), + downstreamKeyCreatedIdx: index('proxy_logs_downstream_api_key_created_at_idx').on(table.downstreamApiKeyId, table.createdAt), })); export const proxyVideoTasks = sqliteTable('proxy_video_tasks', { @@ -236,6 +255,8 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', { name: text('name').notNull(), key: text('key').notNull(), description: text('description'), + groupName: text('group_name'), + tags: text('tags'), // JSON array enabled: integer('enabled', { mode: 'boolean' }).default(true), expiresAt: text('expires_at'), maxCost: real('max_cost'), diff --git a/src/server/db/schemaArtifactGenerator.test.ts b/src/server/db/schemaArtifactGenerator.test.ts index 8d6378d..8738516 100644 --- a/src/server/db/schemaArtifactGenerator.test.ts +++ b/src/server/db/schemaArtifactGenerator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; -import { generateDialectArtifacts, type GeneratedDialectArtifacts } from './schemaArtifactGenerator.js'; +import { generateDialectArtifacts, generateUpgradeSql, type GeneratedDialectArtifacts } from './schemaArtifactGenerator.js'; import type { SchemaContract, SchemaContractColumn } from './schemaContract.js'; const dbDir = dirname(fileURLToPath(import.meta.url)); @@ -123,6 +123,45 @@ describe('schema artifact generator', () => { expect(artifacts.mysqlBootstrap).not.toContain('`read`(191)'); }); + it('allows mysql upgrade generation to force prefix lengths for live text-backed columns', () => { + const previousContract: SchemaContract = { + tables: { + proxy_logs: { + columns: { + downstream_api_key_id: makeColumn({ logicalType: 'integer' }), + created_at: makeColumn({ logicalType: 'datetime', defaultValue: "datetime('now')" }), + }, + }, + }, + indexes: [], + uniques: [], + foreignKeys: [], + }; + const currentContract: SchemaContract = { + ...previousContract, + indexes: [ + { + name: 'proxy_logs_downstream_api_key_created_at_idx', + table: 'proxy_logs', + columns: ['downstream_api_key_id', 'created_at'], + unique: false, + }, + ], + }; + + const mysqlUpgrade = generateUpgradeSql('mysql', currentContract, previousContract, { + mysqlIndexPrefixRequirements: { + proxy_logs: { + created_at: true, + }, + }, + }); + + expect(mysqlUpgrade).toContain( + 'CREATE INDEX `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`(191))', + ); + }); + it('rejects destructive diffs when generating additive upgrades', () => { const current = readSchemaContract(); const previous = structuredClone(current); diff --git a/src/server/db/schemaArtifactGenerator.ts b/src/server/db/schemaArtifactGenerator.ts index c29d16b..dd898e5 100644 --- a/src/server/db/schemaArtifactGenerator.ts +++ b/src/server/db/schemaArtifactGenerator.ts @@ -17,8 +17,13 @@ export interface GeneratedDialectArtifacts { postgresUpgrade: string; } +export type MysqlIndexPrefixRequirementMap = Record>; + type Dialect = 'mysql' | 'postgres'; type SqlDialect = 'sqlite' | Dialect; +type SqlGenerationOptions = { + mysqlIndexPrefixRequirements?: MysqlIndexPrefixRequirementMap; +}; function resolveDbDir(): string { return dirname(fileURLToPath(import.meta.url)); @@ -36,6 +41,21 @@ function escapeMysqlTextPrefix(columnType: LogicalColumnType): string { return columnType === 'text' ? '(191)' : ''; } +function resolveMysqlIndexPrefix( + tableName: string, + columnName: string, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { + const override = options?.mysqlIndexPrefixRequirements?.[tableName]?.[columnName]; + if (override !== undefined) { + return override ? '(191)' : ''; + } + + const column = contract.tables[tableName]?.columns[columnName]; + return column ? escapeMysqlTextPrefix(column.logicalType) : ''; +} + function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaContractColumn): string { if (dialect === 'sqlite') { switch (column.logicalType) { @@ -177,22 +197,34 @@ function buildCreateTableStatement( return `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(dialect, tableName)} (${parts.join(', ')})`; } -function buildUniqueIndexStatement(dialect: SqlDialect, uniqueIndex: SchemaContractUnique, contract: SchemaContract): string { +function buildUniqueIndexStatement( + dialect: SqlDialect, + uniqueIndex: SchemaContractUnique, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { const columns = uniqueIndex.columns .map((columnName) => { - const column = contract.tables[uniqueIndex.table]?.columns[columnName]; - const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : ''; + const suffix = dialect === 'mysql' + ? resolveMysqlIndexPrefix(uniqueIndex.table, columnName, contract, options) + : ''; return `${quoteIdentifier(dialect, columnName)}${suffix}`; }) .join(', '); return `CREATE UNIQUE INDEX ${quoteIdentifier(dialect, uniqueIndex.name)} ON ${quoteIdentifier(dialect, uniqueIndex.table)} (${columns})`; } -function buildIndexStatement(dialect: SqlDialect, index: SchemaContractIndex, contract: SchemaContract): string { +function buildIndexStatement( + dialect: SqlDialect, + index: SchemaContractIndex, + contract: SchemaContract, + options?: SqlGenerationOptions, +): string { const columns = index.columns .map((columnName) => { - const column = contract.tables[index.table]?.columns[columnName]; - const suffix = dialect === 'mysql' && column ? escapeMysqlTextPrefix(column.logicalType) : ''; + const suffix = dialect === 'mysql' + ? resolveMysqlIndexPrefix(index.table, columnName, contract, options) + : ''; return `${quoteIdentifier(dialect, columnName)}${suffix}`; }) .join(', '); @@ -317,6 +349,7 @@ export function generateUpgradeSql( dialect: SqlDialect, currentContract: SchemaContract, previousContract?: SchemaContract | null, + options?: SqlGenerationOptions, ): string { if (!previousContract) { return `-- no previous schema contract available for ${dialect} additive upgrade generation\n`; @@ -357,7 +390,7 @@ export function generateUpgradeSql( .filter((unique) => !previousUniqueNames.has(unique.name)) .slice() .sort((left, right) => left.name.localeCompare(right.name, 'en')) - .map((unique) => buildUniqueIndexStatement(dialect, unique, currentContract)); + .map((unique) => buildUniqueIndexStatement(dialect, unique, currentContract, options)); const currentUniqueNames = new Set(currentContract.uniques.map((unique) => unique.name)); const previousIndexNames = new Set(previousContract.indexes.map((index) => index.name)); @@ -366,7 +399,7 @@ export function generateUpgradeSql( .filter((index) => !previousIndexNames.has(index.name)) .slice() .sort((left, right) => left.name.localeCompare(right.name, 'en')) - .map((index) => buildIndexStatement(dialect, index, currentContract)); + .map((index) => buildIndexStatement(dialect, index, currentContract, options)); const statements = [...addedTableStatements, ...addColumnStatements, ...uniqueStatements, ...indexStatements]; if (statements.length === 0) { diff --git a/src/server/db/schemaContract.test.ts b/src/server/db/schemaContract.test.ts index 136d21f..b3961fc 100644 --- a/src/server/db/schemaContract.test.ts +++ b/src/server/db/schemaContract.test.ts @@ -11,6 +11,11 @@ describe('schema contract generation', () => { primaryKey: false, }); expect(contract.tables.account_tokens.columns.token_group).toBeDefined(); + expect(contract.tables.account_tokens.columns.value_status).toMatchObject({ + logicalType: 'text', + notNull: true, + defaultValue: "'ready'", + }); expect(contract.tables.site_disabled_models).toBeDefined(); expect(contract.tables.downstream_api_keys).toBeDefined(); expect(contract.tables.proxy_files).toBeDefined(); diff --git a/src/server/db/schemaParity.test.ts b/src/server/db/schemaParity.test.ts index d3f18b4..d5f692c 100644 --- a/src/server/db/schemaParity.test.ts +++ b/src/server/db/schemaParity.test.ts @@ -3,6 +3,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { SchemaContract } from './schemaContract.js'; +import { SHARED_INDEX_COMPATIBILITY_SPECS } from './sharedIndexSchemaCompatibility.js'; const dbDir = dirname(fileURLToPath(import.meta.url)); const generatedDir = resolve(dbDir, 'generated'); @@ -63,4 +64,31 @@ describe('database schema parity', () => { expect(unknownTables).toEqual([]); expect(unknownIndexes).toEqual([]); }); + + it('does not duplicate contract-defined indexes inside shared index compatibility specs', () => { + const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; + const contractIndexNames = new Set([ + ...contract.indexes.map((index) => index.name), + ...contract.uniques.map((unique) => unique.name), + ]); + + const duplicatedSpecs = SHARED_INDEX_COMPATIBILITY_SPECS + .map((spec) => spec.indexName) + .filter((indexName) => contractIndexNames.has(indexName)); + + expect(duplicatedSpecs).toEqual([]); + }); + + it('keeps proxy_logs downstream api key schema in the generated contract artifacts', () => { + const contract = JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract; + const mysqlBootstrap = readFileSync(resolve(generatedDir, 'mysql.bootstrap.sql'), 'utf8'); + const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8'); + + expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer'); + expect(contract.indexes.some((index) => index.name === 'proxy_logs_downstream_api_key_created_at_idx')).toBe(true); + expect(mysqlBootstrap).toContain('`downstream_api_key_id`'); + expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`'); + expect(postgresBootstrap).toContain('"downstream_api_key_id"'); + expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"'); + }); }); diff --git a/src/server/db/sharedIndexSchemaCompatibility.test.ts b/src/server/db/sharedIndexSchemaCompatibility.test.ts index a551607..e7ceed5 100644 --- a/src/server/db/sharedIndexSchemaCompatibility.test.ts +++ b/src/server/db/sharedIndexSchemaCompatibility.test.ts @@ -25,31 +25,10 @@ function createInspector( describe('ensureSharedIndexSchemaCompatibility', () => { it.each([ - { - dialect: 'sqlite' as const, - expectedSql: [ - 'CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique ON model_availability (account_id, model_name);', - 'CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx ON proxy_logs (status, created_at);', - 'CREATE INDEX IF NOT EXISTS events_created_at_idx ON events (created_at);', - ], - }, - { - dialect: 'mysql' as const, - expectedSql: [ - 'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))', - 'CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`, `created_at`(191))', - 'CREATE INDEX `events_created_at_idx` ON `events` (`created_at`(191))', - ], - }, - { - dialect: 'postgres' as const, - expectedSql: [ - 'CREATE UNIQUE INDEX IF NOT EXISTS "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")', - 'CREATE INDEX IF NOT EXISTS "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at")', - 'CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at")', - ], - }, - ])('creates shared indexes for $dialect', async ({ dialect, expectedSql }) => { + 'sqlite', + 'mysql', + 'postgres', + ] as const)('is a no-op for %s now that contract-defined indexes come from runtime bootstrap', async (dialect) => { const { inspector, executedSql } = createInspector(dialect, { existingTables: [ 'sites', @@ -68,10 +47,7 @@ describe('ensureSharedIndexSchemaCompatibility', () => { }); await ensureSharedIndexSchemaCompatibility(inspector); - - for (const sqlText of expectedSql) { - expect(executedSql).toContain(sqlText); - } + expect(executedSql).toEqual([]); }); it('skips indexes for tables that do not exist', async () => { @@ -81,8 +57,6 @@ describe('ensureSharedIndexSchemaCompatibility', () => { await ensureSharedIndexSchemaCompatibility(inspector); - expect(executedSql).toEqual([ - 'CREATE INDEX IF NOT EXISTS "sites_status_idx" ON "sites" ("status")', - ]); + expect(executedSql).toEqual([]); }); }); diff --git a/src/server/db/sharedIndexSchemaCompatibility.ts b/src/server/db/sharedIndexSchemaCompatibility.ts index e0bf843..f7ddd45 100644 --- a/src/server/db/sharedIndexSchemaCompatibility.ts +++ b/src/server/db/sharedIndexSchemaCompatibility.ts @@ -12,332 +12,10 @@ type SharedIndexCompatibilitySpec = { createSql: Record; }; -export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = [ - { - table: 'sites', - indexName: 'sites_status_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS sites_status_idx ON sites (status);', - mysql: 'CREATE INDEX `sites_status_idx` ON `sites` (`status`)', - postgres: 'CREATE INDEX IF NOT EXISTS "sites_status_idx" ON "sites" ("status")', - }, - }, - { - table: 'accounts', - indexName: 'accounts_site_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS accounts_site_id_idx ON accounts (site_id);', - mysql: 'CREATE INDEX `accounts_site_id_idx` ON `accounts` (`site_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "accounts_site_id_idx" ON "accounts" ("site_id")', - }, - }, - { - table: 'accounts', - indexName: 'accounts_status_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS accounts_status_idx ON accounts (status);', - mysql: 'CREATE INDEX `accounts_status_idx` ON `accounts` (`status`)', - postgres: 'CREATE INDEX IF NOT EXISTS "accounts_status_idx" ON "accounts" ("status")', - }, - }, - { - table: 'accounts', - indexName: 'accounts_site_status_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS accounts_site_status_idx ON accounts (site_id, status);', - mysql: 'CREATE INDEX `accounts_site_status_idx` ON `accounts` (`site_id`, `status`)', - postgres: 'CREATE INDEX IF NOT EXISTS "accounts_site_status_idx" ON "accounts" ("site_id", "status")', - }, - }, - { - table: 'account_tokens', - indexName: 'account_tokens_account_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_account_id_idx ON account_tokens (account_id);', - mysql: 'CREATE INDEX `account_tokens_account_id_idx` ON `account_tokens` (`account_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_account_id_idx" ON "account_tokens" ("account_id")', - }, - }, - { - table: 'account_tokens', - indexName: 'account_tokens_account_enabled_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_account_enabled_idx ON account_tokens (account_id, enabled);', - mysql: 'CREATE INDEX `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`, `enabled`)', - postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_account_enabled_idx" ON "account_tokens" ("account_id", "enabled")', - }, - }, - { - table: 'account_tokens', - indexName: 'account_tokens_enabled_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS account_tokens_enabled_idx ON account_tokens (enabled);', - mysql: 'CREATE INDEX `account_tokens_enabled_idx` ON `account_tokens` (`enabled`)', - postgres: 'CREATE INDEX IF NOT EXISTS "account_tokens_enabled_idx" ON "account_tokens" ("enabled")', - }, - }, - { - table: 'checkin_logs', - indexName: 'checkin_logs_account_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_account_created_at_idx ON checkin_logs (account_id, created_at);', - mysql: 'CREATE INDEX `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_account_created_at_idx" ON "checkin_logs" ("account_id", "created_at")', - }, - }, - { - table: 'checkin_logs', - indexName: 'checkin_logs_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_created_at_idx ON checkin_logs (created_at);', - mysql: 'CREATE INDEX `checkin_logs_created_at_idx` ON `checkin_logs` (`created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_created_at_idx" ON "checkin_logs" ("created_at")', - }, - }, - { - table: 'checkin_logs', - indexName: 'checkin_logs_status_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS checkin_logs_status_idx ON checkin_logs (status);', - mysql: 'CREATE INDEX `checkin_logs_status_idx` ON `checkin_logs` (`status`)', - postgres: 'CREATE INDEX IF NOT EXISTS "checkin_logs_status_idx" ON "checkin_logs" ("status")', - }, - }, - { - table: 'model_availability', - indexName: 'model_availability_account_model_unique', - createSql: { - sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS model_availability_account_model_unique ON model_availability (account_id, model_name);', - mysql: 'CREATE UNIQUE INDEX `model_availability_account_model_unique` ON `model_availability` (`account_id`, `model_name`(191))', - postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "model_availability_account_model_unique" ON "model_availability" ("account_id", "model_name")', - }, - }, - { - table: 'model_availability', - indexName: 'model_availability_account_available_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS model_availability_account_available_idx ON model_availability (account_id, available);', - mysql: 'CREATE INDEX `model_availability_account_available_idx` ON `model_availability` (`account_id`, `available`)', - postgres: 'CREATE INDEX IF NOT EXISTS "model_availability_account_available_idx" ON "model_availability" ("account_id", "available")', - }, - }, - { - table: 'model_availability', - indexName: 'model_availability_model_name_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS model_availability_model_name_idx ON model_availability (model_name);', - mysql: 'CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "model_availability_model_name_idx" ON "model_availability" ("model_name")', - }, - }, - { - table: 'token_model_availability', - indexName: 'token_model_availability_token_model_unique', - createSql: { - sqlite: 'CREATE UNIQUE INDEX IF NOT EXISTS token_model_availability_token_model_unique ON token_model_availability (token_id, model_name);', - mysql: 'CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191))', - postgres: 'CREATE UNIQUE INDEX IF NOT EXISTS "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name")', - }, - }, - { - table: 'token_model_availability', - indexName: 'token_model_availability_token_available_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_token_available_idx ON token_model_availability (token_id, available);', - mysql: 'CREATE INDEX `token_model_availability_token_available_idx` ON `token_model_availability` (`token_id`, `available`)', - postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_token_available_idx" ON "token_model_availability" ("token_id", "available")', - }, - }, - { - table: 'token_model_availability', - indexName: 'token_model_availability_model_name_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_model_name_idx ON token_model_availability (model_name);', - mysql: 'CREATE INDEX `token_model_availability_model_name_idx` ON `token_model_availability` (`model_name`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_model_name_idx" ON "token_model_availability" ("model_name")', - }, - }, - { - table: 'token_model_availability', - indexName: 'token_model_availability_available_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS token_model_availability_available_idx ON token_model_availability (available);', - mysql: 'CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`)', - postgres: 'CREATE INDEX IF NOT EXISTS "token_model_availability_available_idx" ON "token_model_availability" ("available")', - }, - }, - { - table: 'token_routes', - indexName: 'token_routes_model_pattern_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS token_routes_model_pattern_idx ON token_routes (model_pattern);', - mysql: 'CREATE INDEX `token_routes_model_pattern_idx` ON `token_routes` (`model_pattern`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "token_routes_model_pattern_idx" ON "token_routes" ("model_pattern")', - }, - }, - { - table: 'token_routes', - indexName: 'token_routes_enabled_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS token_routes_enabled_idx ON token_routes (enabled);', - mysql: 'CREATE INDEX `token_routes_enabled_idx` ON `token_routes` (`enabled`)', - postgres: 'CREATE INDEX IF NOT EXISTS "token_routes_enabled_idx" ON "token_routes" ("enabled")', - }, - }, - { - table: 'route_channels', - indexName: 'route_channels_route_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_id_idx ON route_channels (route_id);', - mysql: 'CREATE INDEX `route_channels_route_id_idx` ON `route_channels` (`route_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_id_idx" ON "route_channels" ("route_id")', - }, - }, - { - table: 'route_channels', - indexName: 'route_channels_account_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_account_id_idx ON route_channels (account_id);', - mysql: 'CREATE INDEX `route_channels_account_id_idx` ON `route_channels` (`account_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_account_id_idx" ON "route_channels" ("account_id")', - }, - }, - { - table: 'route_channels', - indexName: 'route_channels_token_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_token_id_idx ON route_channels (token_id);', - mysql: 'CREATE INDEX `route_channels_token_id_idx` ON `route_channels` (`token_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_token_id_idx" ON "route_channels" ("token_id")', - }, - }, - { - table: 'route_channels', - indexName: 'route_channels_route_enabled_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_enabled_idx ON route_channels (route_id, enabled);', - mysql: 'CREATE INDEX `route_channels_route_enabled_idx` ON `route_channels` (`route_id`, `enabled`)', - postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_enabled_idx" ON "route_channels" ("route_id", "enabled")', - }, - }, - { - table: 'route_channels', - indexName: 'route_channels_route_token_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS route_channels_route_token_idx ON route_channels (route_id, token_id);', - mysql: 'CREATE INDEX `route_channels_route_token_idx` ON `route_channels` (`route_id`, `token_id`)', - postgres: 'CREATE INDEX IF NOT EXISTS "route_channels_route_token_idx" ON "route_channels" ("route_id", "token_id")', - }, - }, - { - table: 'proxy_logs', - indexName: 'proxy_logs_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_created_at_idx ON proxy_logs (created_at);', - mysql: 'CREATE INDEX `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_created_at_idx" ON "proxy_logs" ("created_at")', - }, - }, - { - table: 'proxy_logs', - indexName: 'proxy_logs_account_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_account_created_at_idx ON proxy_logs (account_id, created_at);', - mysql: 'CREATE INDEX `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_account_created_at_idx" ON "proxy_logs" ("account_id", "created_at")', - }, - }, - { - table: 'proxy_logs', - indexName: 'proxy_logs_status_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_status_created_at_idx ON proxy_logs (status, created_at);', - mysql: 'CREATE INDEX `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_status_created_at_idx" ON "proxy_logs" ("status", "created_at")', - }, - }, - { - table: 'proxy_logs', - indexName: 'proxy_logs_model_actual_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_logs_model_actual_created_at_idx ON proxy_logs (model_actual, created_at);', - mysql: 'CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at")', - }, - }, - { - table: 'proxy_video_tasks', - indexName: 'proxy_video_tasks_upstream_video_id_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_video_tasks_upstream_video_id_idx ON proxy_video_tasks (upstream_video_id);', - mysql: 'CREATE INDEX `proxy_video_tasks_upstream_video_id_idx` ON `proxy_video_tasks` (`upstream_video_id`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_video_tasks_upstream_video_id_idx" ON "proxy_video_tasks" ("upstream_video_id")', - }, - }, - { - table: 'proxy_video_tasks', - indexName: 'proxy_video_tasks_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS proxy_video_tasks_created_at_idx ON proxy_video_tasks (created_at);', - mysql: 'CREATE INDEX `proxy_video_tasks_created_at_idx` ON `proxy_video_tasks` (`created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "proxy_video_tasks_created_at_idx" ON "proxy_video_tasks" ("created_at")', - }, - }, - { - table: 'downstream_api_keys', - indexName: 'downstream_api_keys_name_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_name_idx ON downstream_api_keys (name);', - mysql: 'CREATE INDEX `downstream_api_keys_name_idx` ON `downstream_api_keys` (`name`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_name_idx" ON "downstream_api_keys" ("name")', - }, - }, - { - table: 'downstream_api_keys', - indexName: 'downstream_api_keys_enabled_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_enabled_idx ON downstream_api_keys (enabled);', - mysql: 'CREATE INDEX `downstream_api_keys_enabled_idx` ON `downstream_api_keys` (`enabled`)', - postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_enabled_idx" ON "downstream_api_keys" ("enabled")', - }, - }, - { - table: 'downstream_api_keys', - indexName: 'downstream_api_keys_expires_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS downstream_api_keys_expires_at_idx ON downstream_api_keys (expires_at);', - mysql: 'CREATE INDEX `downstream_api_keys_expires_at_idx` ON `downstream_api_keys` (`expires_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "downstream_api_keys_expires_at_idx" ON "downstream_api_keys" ("expires_at")', - }, - }, - { - table: 'events', - indexName: 'events_read_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS events_read_created_at_idx ON events (read, created_at);', - mysql: 'CREATE INDEX `events_read_created_at_idx` ON `events` (`read`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "events_read_created_at_idx" ON "events" ("read", "created_at")', - }, - }, - { - table: 'events', - indexName: 'events_type_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS events_type_created_at_idx ON events (type, created_at);', - mysql: 'CREATE INDEX `events_type_created_at_idx` ON `events` (`type`, `created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "events_type_created_at_idx" ON "events" ("type", "created_at")', - }, - }, - { - table: 'events', - indexName: 'events_created_at_idx', - createSql: { - sqlite: 'CREATE INDEX IF NOT EXISTS events_created_at_idx ON events (created_at);', - mysql: 'CREATE INDEX `events_created_at_idx` ON `events` (`created_at`(191))', - postgres: 'CREATE INDEX IF NOT EXISTS "events_created_at_idx" ON "events" ("created_at")', - }, - }, -]; +// Contract-defined indexes are owned by generated schema artifacts and +// runtimeSchemaBootstrap. This legacy hook is intentionally empty so startup +// does not maintain a second static source of index SQL. +export const SHARED_INDEX_COMPATIBILITY_SPECS: SharedIndexCompatibilitySpec[] = []; function normalizeSchemaErrorMessage(error: unknown): string { if (typeof error === 'object' && error && 'message' in error) { diff --git a/src/server/desktop.ts b/src/server/desktop.ts index bc844b0..02bb29b 100644 --- a/src/server/desktop.ts +++ b/src/server/desktop.ts @@ -3,7 +3,7 @@ import type { FastifyInstance } from 'fastify'; const DESKTOP_HEALTH_ROUTE = '/api/desktop/health'; export function isPublicApiRoute(url: string): boolean { - return url === DESKTOP_HEALTH_ROUTE; + return url === DESKTOP_HEALTH_ROUTE || url.startsWith('/api/oauth/callback/'); } export async function registerDesktopRoutes(app: FastifyInstance) { diff --git a/src/server/index.ts b/src/server/index.ts index c4f40c0..ed2fc75 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -17,6 +17,7 @@ import { taskRoutes } from './routes/api/tasks.js'; import { testRoutes } from './routes/api/test.js'; import { monitorRoutes } from './routes/api/monitor.js'; import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js'; +import { oauthRoutes } from './routes/api/oauth.js'; import { proxyRoutes } from './routes/proxy/router.js'; import { startScheduler } from './services/checkinScheduler.js'; import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js'; @@ -24,6 +25,7 @@ import { buildStartupSummaryLines } from './services/startupInfo.js'; import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js'; import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js'; import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; +import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js'; import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; import { existsSync } from 'fs'; @@ -33,9 +35,9 @@ import { normalizeLogCleanupRetentionDays } from './services/logCleanupService.j import { db, ensureProxyFileCompatibilityColumns, + ensureProxyLogDownstreamApiKeyIdColumn, ensureProxyLogBillingDetailsColumn, ensureRouteGroupingCompatibilityColumns, - ensureSharedIndexCompatibility, ensureSiteCompatibilityColumns, runtimeDbDialect, schema, @@ -265,7 +267,7 @@ try { await ensureSiteCompatibilityColumns(); await ensureRouteGroupingCompatibilityColumns(); await ensureProxyFileCompatibilityColumns(); - await ensureSharedIndexCompatibility(); + await ensureProxyLogDownstreamApiKeyIdColumn(); const finalRows = await db.select().from(schema.settings).all(); const finalMap = toSettingsMap(finalRows); applyRuntimeSettings(finalMap); @@ -312,6 +314,7 @@ await app.register(taskRoutes); await app.register(testRoutes); await app.register(monitorRoutes); await app.register(downstreamApiKeysRoutes); +await app.register(oauthRoutes); // Register OpenAI-compatible proxy routes await app.register(proxyRoutes); @@ -345,9 +348,15 @@ if (existsSync(webDir)) { // Start scheduler await startScheduler(); +try { + await startOAuthLoopbackCallbackServers(); +} catch (error) { + console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`); +} setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured); app.addHook('onClose', async () => { stopProxyLogRetentionService(); + await stopOAuthLoopbackCallbackServers(); }); // Start server diff --git a/src/server/middleware/auth.proxy.test.ts b/src/server/middleware/auth.proxy.test.ts new file mode 100644 index 0000000..5a959bc --- /dev/null +++ b/src/server/middleware/auth.proxy.test.ts @@ -0,0 +1,79 @@ +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const authorizeDownstreamTokenMock = vi.fn(); +const consumeManagedKeyRequestMock = vi.fn(); + +vi.mock('../services/downstreamApiKeyService.js', () => ({ + authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args), + consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args), +})); + +describe('proxyAuthMiddleware', () => { + beforeEach(() => { + authorizeDownstreamTokenMock.mockReset(); + consumeManagedKeyRequestMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('rejects missing proxy credentials', async () => { + const { proxyAuthMiddleware } = await import('./auth.js'); + const app = Fastify(); + app.addHook('onRequest', proxyAuthMiddleware); + app.get('/v1/ping', async () => ({ ok: true })); + + const res = await app.inject({ method: 'GET', url: '/v1/ping' }); + expect(res.statusCode).toBe(401); + expect(res.json()).toMatchObject({ error: expect.stringContaining('Missing Authorization') }); + await app.close(); + }); + + it('stores managed key context and consumes request usage', async () => { + authorizeDownstreamTokenMock.mockResolvedValue({ + ok: true, + source: 'managed', + token: 'sk-managed-001', + key: { id: 12, name: 'project-key' }, + policy: { supportedModels: ['gpt-5.2'], allowedRouteIds: [3], siteWeightMultipliers: { 1: 1.2 } }, + }); + consumeManagedKeyRequestMock.mockResolvedValue(undefined); + + const { proxyAuthMiddleware, getProxyAuthContext, getProxyResourceOwner } = await import('./auth.js'); + const app = Fastify(); + app.addHook('onRequest', proxyAuthMiddleware); + app.get('/v1/ping', async (request) => ({ + auth: getProxyAuthContext(request), + owner: getProxyResourceOwner(request), + })); + + const res = await app.inject({ + method: 'GET', + url: '/v1/ping', + headers: { Authorization: 'Bearer sk-managed-001' }, + }); + + expect(res.statusCode).toBe(200); + expect(authorizeDownstreamTokenMock).toHaveBeenCalledWith('sk-managed-001'); + expect(consumeManagedKeyRequestMock).toHaveBeenCalledWith(12); + expect(res.json()).toMatchObject({ + auth: { + source: 'managed', + keyId: 12, + keyName: 'project-key', + policy: { + supportedModels: ['gpt-5.2'], + allowedRouteIds: [3], + siteWeightMultipliers: { 1: 1.2 }, + }, + }, + owner: { + ownerType: 'managed_key', + ownerId: '12', + }, + }); + await app.close(); + }); +}); diff --git a/src/server/routes/api/accountTokens.batch.test.ts b/src/server/routes/api/accountTokens.batch.test.ts index 619bcc8..e38fe2e 100644 --- a/src/server/routes/api/accountTokens.batch.test.ts +++ b/src/server/routes/api/accountTokens.batch.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites import { mkdtempSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { eq } from 'drizzle-orm'; const { deleteApiTokenMock } = vi.hoisted(() => ({ deleteApiTokenMock: vi.fn(), @@ -105,6 +106,43 @@ describe('account token batch routes', () => { expect(rows.every((row) => row.enabled === true)).toBe(true); }); + it('rejects enabling masked_pending placeholders until they are completed', async () => { + await db.update(schema.accountTokens) + .set({ + enabled: false, + valueStatus: 'masked_pending' as any, + token: 'sk-mask***tail', + }) + .where(eq(schema.accountTokens.id, 1)) + .run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens/batch', + payload: { + ids: [1, 2], + action: 'enable', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + successIds?: number[]; + failedItems?: Array<{ id: number; message: string }>; + }; + expect(body.successIds).toEqual([2]); + expect(body.failedItems).toEqual([ + expect.objectContaining({ + id: 1, + message: expect.stringContaining('待补全令牌'), + }), + ]); + + const rows = await db.select().from(schema.accountTokens).all(); + expect(rows.find((row) => row.id === 1)?.enabled).toBe(false); + expect(rows.find((row) => row.id === 2)?.enabled).toBe(true); + }); + it('deletes selected account tokens through the upstream adapter', async () => { const response = await app.inject({ method: 'POST', diff --git a/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts b/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts new file mode 100644 index 0000000..735adc1 --- /dev/null +++ b/src/server/routes/api/accountTokens.coverage-refresh-failure.test.ts @@ -0,0 +1,115 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +const refreshModelsForAccountMock = vi.fn(); +const rebuildTokenRoutesFromAvailabilityMock = vi.fn(); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args), + rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('account token coverage refresh failure handling', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-failure-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const accountTokenRoutesModule = await import('./accountTokens.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(accountTokenRoutesModule.accountTokensRoutes); + }); + + beforeEach(async () => { + refreshModelsForAccountMock.mockReset(); + rebuildTokenRoutesFromAvailabilityMock.mockReset(); + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('returns success after token persistence even when coverage rebuild fails', async () => { + const site = await db.insert(schema.sites).values({ + name: 'site-1', + url: 'https://site-1.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'user-1', + accessToken: 'acc-token-1', + status: 'active', + }).returning().get(); + + refreshModelsForAccountMock.mockResolvedValue({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + errorMessage: '', + modelCount: 1, + modelsPreview: ['gpt-4o-mini'], + tokenScanned: 1, + discoveredByCredential: false, + discoveredApiToken: false, + }); + rebuildTokenRoutesFromAvailabilityMock.mockRejectedValue(new Error('rebuild failed')); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens', + payload: { + accountId: account.id, + name: 'manual-token', + token: 'sk-manual-token', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + coverageRefresh: { + rebuild: { + success: false, + error: 'rebuild failed', + }, + }, + }); + + const tokens = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokens).toHaveLength(1); + expect(tokens[0]?.token).toBe('sk-manual-token'); + }); +}); diff --git a/src/server/routes/api/accountTokens.coverage-refresh.test.ts b/src/server/routes/api/accountTokens.coverage-refresh.test.ts new file mode 100644 index 0000000..781ee0b --- /dev/null +++ b/src/server/routes/api/accountTokens.coverage-refresh.test.ts @@ -0,0 +1,203 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; + +const getApiTokensMock = vi.fn(); +const getApiTokenMock = vi.fn(); +const createApiTokenMock = vi.fn(); +const getUserGroupsMock = vi.fn(); +const deleteApiTokenMock = vi.fn(); +const getModelsMock = vi.fn(); + +vi.mock('../../services/platforms/index.js', () => ({ + getAdapter: () => ({ + getApiTokens: (...args: unknown[]) => getApiTokensMock(...args), + getApiToken: (...args: unknown[]) => getApiTokenMock(...args), + createApiToken: (...args: unknown[]) => createApiTokenMock(...args), + getUserGroups: (...args: unknown[]) => getUserGroupsMock(...args), + deleteApiToken: (...args: unknown[]) => deleteApiTokenMock(...args), + getModels: (...args: unknown[]) => getModelsMock(...args), + }), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('account token coverage refresh', { timeout: 15_000 }, () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + let seedId = 0; + + const nextSeed = () => { + seedId += 1; + return seedId; + }; + + const seedAccount = async (modelName: string) => { + const id = nextSeed(); + const site = await db.insert(schema.sites).values({ + name: `site-${id}`, + url: `https://site-${id}.example.com`, + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: `user-${id}`, + accessToken: `acc-token-${id}`, + status: 'active', + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName, + available: true, + }).run(); + + return { site, account }; + }; + + const readTokenCandidates = async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/models/token-candidates', + }); + expect(response.statusCode).toBe(200); + return response.json() as { + models: Record>; + modelsWithoutToken: Record>; + }; + }; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-account-token-coverage-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const accountTokenRoutesModule = await import('./accountTokens.js'); + const statsRoutesModule = await import('./stats.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(accountTokenRoutesModule.accountTokensRoutes); + await app.register(statsRoutesModule.statsRoutes); + }); + + beforeEach(async () => { + getApiTokensMock.mockReset(); + getApiTokenMock.mockReset(); + createApiTokenMock.mockReset(); + getUserGroupsMock.mockReset(); + deleteApiTokenMock.mockReset(); + getModelsMock.mockReset(); + seedId = 0; + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('refreshes token coverage after manually adding an account token', async () => { + const modelName = 'gpt-4o-mini'; + const { account } = await seedAccount(modelName); + getModelsMock.mockImplementation(async (_url: string, credential: string) => { + if (credential === account.accessToken || credential === 'sk-manual-token') { + return [modelName]; + } + return []; + }); + + const response = await app.inject({ + method: 'POST', + url: '/api/account-tokens', + payload: { + accountId: account.id, + name: 'manual-token', + token: 'sk-manual-token', + }, + }); + + expect(response.statusCode).toBe(200); + + const token = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .get(); + expect(token).toBeDefined(); + + const tokenAvailability = await db.select() + .from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token!.id)) + .all(); + expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName); + + const candidates = await readTokenCandidates(); + expect(candidates.modelsWithoutToken[modelName]).toBeUndefined(); + expect(candidates.models[modelName]).toEqual([ + expect.objectContaining({ + accountId: account.id, + tokenId: token!.id, + }), + ]); + }); + + it('refreshes token coverage after syncing account tokens from upstream', async () => { + const modelName = 'claude-sonnet-4-5-20250929'; + const { account } = await seedAccount(modelName); + getApiTokensMock.mockResolvedValue([ + { name: 'default', key: 'sk-synced-token', enabled: true }, + ]); + getModelsMock.mockImplementation(async (_url: string, credential: string) => { + if (credential === account.accessToken || credential === 'sk-synced-token') { + return [modelName]; + } + return []; + }); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + + const token = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .get(); + expect(token).toBeDefined(); + + const tokenAvailability = await db.select() + .from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, token!.id)) + .all(); + expect(tokenAvailability.map((row) => row.modelName)).toContain(modelName); + + const candidates = await readTokenCandidates(); + expect(candidates.modelsWithoutToken[modelName]).toBeUndefined(); + expect(candidates.models[modelName]).toEqual([ + expect.objectContaining({ + accountId: account.id, + tokenId: token!.id, + }), + ]); + }); +}); diff --git a/src/server/routes/api/accountTokens.sync.test.ts b/src/server/routes/api/accountTokens.sync.test.ts index 8b594d4..d993a8c 100644 --- a/src/server/routes/api/accountTokens.sync.test.ts +++ b/src/server/routes/api/accountTokens.sync.test.ts @@ -138,7 +138,7 @@ describe('account tokens sync routes with site status', () => { expect(tokenRows.length).toBe(0); }); - it('skips masked upstream token values and does not store them', async () => { + it('stores masked upstream token values as masked_pending placeholders', async () => { const { account } = await seedAccount({ siteStatus: 'active' }); getApiTokensMock.mockResolvedValue([ { name: 'masked-only', key: 'sk-abc***xyz', enabled: true }, @@ -153,12 +153,13 @@ describe('account tokens sync routes with site status', () => { expect(response.statusCode).toBe(200); expect(response.json()).toMatchObject({ success: true, - synced: false, - status: 'skipped', + synced: true, + status: 'synced', reason: 'upstream_masked_tokens', - maskedSkipped: 1, - total: 0, - created: 0, + maskedPending: 1, + pendingTokenIds: [expect.any(Number)], + total: 1, + created: 1, updated: 0, }); @@ -166,7 +167,33 @@ describe('account tokens sync routes with site status', () => { .from(schema.accountTokens) .where(eq(schema.accountTokens.accountId, account.id)) .all(); - expect(tokenRows.length).toBe(0); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0]).toMatchObject({ + name: 'masked-only', + token: 'sk-abc***xyz', + source: 'sync', + enabled: false, + isDefault: false, + }); + expect((tokenRows[0] as any).valueStatus).toBe('masked_pending'); + + const owner = await db.select() + .from(schema.accounts) + .where(eq(schema.accounts.id, account.id)) + .get(); + expect(owner?.apiToken ?? null).toBeNull(); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/account-tokens', + }); + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toMatchObject([ + expect.objectContaining({ + id: tokenRows[0].id, + valueStatus: 'masked_pending', + }), + ]); }); it('rejects sync and token management for apikey connections', async () => { @@ -491,6 +518,7 @@ describe('account tokens sync routes with site status', () => { source: 'sync', enabled: true, isDefault: false, + valueStatus: 'masked_pending' as any, }).returning().get(); const response = await app.inject({ @@ -503,4 +531,139 @@ describe('account tokens sync routes with site status', () => { success: false, }); }); + + it('upgrades an existing masked_pending placeholder when upstream later returns the full token', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-only', + token: 'sk-abc***xyz', + source: 'sync', + enabled: false, + isDefault: false, + tokenGroup: 'default', + valueStatus: 'masked_pending' as any, + }).run(); + + getApiTokensMock.mockResolvedValue([ + { name: 'masked-only', key: 'sk-real-token-1234', enabled: true, tokenGroup: 'default' }, + ]); + getApiTokenMock.mockResolvedValue(null); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/sync/${account.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + synced: true, + status: 'synced', + total: 1, + created: 0, + updated: 1, + }); + + const tokenRows = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.accountId, account.id)) + .all(); + expect(tokenRows).toHaveLength(1); + expect(tokenRows[0]).toMatchObject({ + name: 'masked-only', + token: 'sk-real-token-1234', + enabled: true, + }); + expect((tokenRows[0] as any).valueStatus).toBe('ready'); + }); + + it('does not allow setting a masked_pending placeholder as default', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: false, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const response = await app.inject({ + method: 'POST', + url: `/api/account-tokens/${token.id}/default`, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + message: expect.stringContaining('待补全令牌'), + }); + }); + + it('promotes a masked_pending placeholder to ready when a full token is saved', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: false, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const response = await app.inject({ + method: 'PUT', + url: `/api/account-tokens/${token.id}`, + payload: { + token: 'sk-real-token-updated', + enabled: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + success: true, + token: expect.objectContaining({ + id: token.id, + enabled: true, + valueStatus: 'ready', + }), + }); + + const latest = await db.select() + .from(schema.accountTokens) + .where(eq(schema.accountTokens.id, token.id)) + .get(); + expect(latest).toMatchObject({ + token: 'sk-real-token-updated', + enabled: true, + }); + expect((latest as any)?.valueStatus).toBe('ready'); + }); + + it('deletes masked_pending placeholders locally without calling upstream delete', async () => { + const { account } = await seedAccount({ siteStatus: 'active' }); + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: false, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/account-tokens/${token.id}`, + }); + + expect(response.statusCode).toBe(200); + expect(deleteApiTokenMock).not.toHaveBeenCalled(); + const removed = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, token.id)).get(); + expect(removed).toBeUndefined(); + }); }); diff --git a/src/server/routes/api/accountTokens.ts b/src/server/routes/api/accountTokens.ts index 8763485..2bf36b4 100644 --- a/src/server/routes/api/accountTokens.ts +++ b/src/server/routes/api/accountTokens.ts @@ -2,12 +2,17 @@ import { and, eq } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; import { + ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING, + ACCOUNT_TOKEN_VALUE_STATUS_READY, ensureDefaultTokenForAccount, + isMaskedPendingAccountToken, isMaskedTokenValue, + isUsableAccountToken, listTokensWithRelations, normalizeTokenForDisplay, maskToken, repairDefaultToken, + resolveAccountTokenValueStatus, setDefaultToken, syncTokensFromUpstream, } from '../../services/accountTokenService.js'; @@ -15,6 +20,11 @@ import { getAdapter } from '../../services/platforms/index.js'; import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from '../../services/accountExtraConfig.js'; import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { withAccountProxyOverride } from '../../services/siteProxy.js'; +import { + rebuildTokenRoutesFromAvailability, + refreshModelsForAccount, + type ModelRefreshResult, +} from '../../services/modelService.js'; type AccountWithSiteRow = { accounts: typeof schema.accounts.$inferSelect; @@ -34,11 +44,31 @@ type SyncExecutionResult = { synced: boolean; created: number; updated: number; - maskedSkipped?: number; + maskedPending?: number; + pendingTokenIds?: number[]; total: number; defaultTokenId?: number | null; }; +type CoverageRefreshFailureItem = { + accountId: number; + refreshed: false; + status: 'failed'; + errorCode: 'coverage_refresh_failed'; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + reason: 'coverage_refresh_failed'; + tokenScanned: 0; + discoveredByCredential: false; + discoveredApiToken: false; +}; + +type CoverageRefreshItem = ModelRefreshResult | CoverageRefreshFailureItem; +type CoverageRefreshRebuildResult = + | { success: true; result: Awaited> } + | { success: false; error: string }; + const TOKEN_SYNC_TIMEOUT_MS = 15_000; const SYNC_ALL_BATCH_SIZE = 3; @@ -267,13 +297,13 @@ async function executeAccountTokenSync(row: AccountWithSiteRow): Promise 0) { + if ((synced.maskedPending || 0) > 0) { return { ...base, - status: 'skipped', + status: 'synced', reason: 'upstream_masked_tokens', - message: `上游仅返回脱敏令牌(含*),跳过 ${synced.maskedSkipped} 条,无法同步明文令牌`, - synced: false, + message: `上游返回 ${synced.maskedPending} 条脱敏令牌,已保存为待补全记录,请手动补全明文 token。`, + synced: true, ...synced, }; } @@ -306,7 +336,7 @@ async function appendTokenSyncEvent(result: SyncExecutionResult) { ? 'info' : (result.status === 'skipped' ? 'warning' : 'error'); const detail = result.status === 'synced' - ? `新增 ${result.created},更新 ${result.updated},总数 ${result.total}` + ? `新增 ${result.created},更新 ${result.updated},待补全 ${result.maskedPending || 0},总数 ${result.total}` : (result.message || result.reason || 'sync skipped'); try { @@ -341,6 +371,12 @@ async function executeSyncAllAccountTokens() { results.push(...batchResults); } + const coverageRefresh = await refreshCoverageForAccounts( + results + .filter((item) => item.status === 'synced') + .map((item) => item.accountId), + ); + const summary = { total: results.length, synced: results.filter((item) => item.status === 'synced').length, @@ -350,7 +386,72 @@ async function executeSyncAllAccountTokens() { updated: results.reduce((acc, item) => acc + item.updated, 0), }; - return { summary, results }; + return { summary, results, coverageRefresh }; +} + +async function refreshCoverageForAccounts(accountIds: number[]) { + const uniqueAccountIds = Array.from(new Set( + accountIds.filter((id) => Number.isFinite(id) && id > 0), + )); + + if (uniqueAccountIds.length === 0) { + return { refresh: [], rebuild: null }; + } + + const refresh: CoverageRefreshItem[] = []; + for (let offset = 0; offset < uniqueAccountIds.length; offset += SYNC_ALL_BATCH_SIZE) { + const batch = uniqueAccountIds.slice(offset, offset + SYNC_ALL_BATCH_SIZE); + const settled = await Promise.allSettled( + batch.map(async (accountId) => refreshModelsForAccount(accountId)), + ); + settled.forEach((result, index) => { + if (result.status === 'fulfilled') { + refresh.push(result.value); + return; + } + + const accountId = batch[index] || 0; + const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason || 'coverage refresh failed'); + refresh.push(buildCoverageRefreshFailureItem(accountId, errorMessage)); + console.warn(`[account-tokens] coverage refresh failed for account ${accountId}: ${errorMessage}`); + }); + } + + let rebuild: CoverageRefreshRebuildResult | null = null; + try { + rebuild = { + success: true, + result: await rebuildTokenRoutesFromAvailability(), + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error || 'route rebuild failed'); + rebuild = { + success: false, + error: errorMessage, + }; + console.warn(`[account-tokens] token route rebuild failed after coverage refresh: ${errorMessage}`); + } + + return { refresh, rebuild }; +} + +function buildCoverageRefreshFailureItem( + accountId: number, + errorMessage: string, +): CoverageRefreshFailureItem { + return { + accountId, + refreshed: false, + status: 'failed', + errorCode: 'coverage_refresh_failed', + errorMessage, + modelCount: 0, + modelsPreview: [], + reason: 'coverage_refresh_failed', + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }; } export async function accountTokensRoutes(app: FastifyInstance) { @@ -394,15 +495,25 @@ export async function accountTokensRoutes(app: FastifyInstance) { const existing = await db.select().from(schema.accountTokens) .where(eq(schema.accountTokens.accountId, body.accountId)) .all(); + const valueStatus = isMaskedTokenValue(tokenValue) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; + const enabled = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY + ? (body.enabled ?? true) + : false; + const isDefault = valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY + ? (body.isDefault ?? false) + : false; const inserted = await db.insert(schema.accountTokens).values({ accountId: body.accountId, name: (body.name || '').trim() || (existing.length === 0 ? 'default' : `token-${existing.length + 1}`), token: tokenValue, tokenGroup: (body.group || '').trim() || null, + valueStatus, source: body.source || 'manual', - enabled: body.enabled ?? true, - isDefault: body.isDefault ?? false, + enabled, + isDefault, createdAt: now, updatedAt: now, }).run(); @@ -415,13 +526,13 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(500).send({ success: false, message: '创建令牌失败' }); } - if (body.isDefault || (existing.length === 0 && (body.enabled ?? true))) { + if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && (body.isDefault || (existing.length === 0 && enabled))) { await setDefaultToken(created.id); - } else if (existing.every((token) => !token.isDefault) && (body.enabled ?? true)) { + } else if (valueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY && existing.every((token) => !token.isDefault) && enabled) { await setDefaultToken(created.id); } - - return { success: true, token: created }; + const coverageRefresh = await refreshCoverageForAccounts([body.accountId]); + return { success: true, token: created, coverageRefresh }; } const account = row.accounts; @@ -503,6 +614,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { if (syncResult.status === 'skipped') { return reply.code(502).send({ success: false, message: syncResult.message || '站点未返回可用令牌' }); } + const coverageRefresh = await refreshCoverageForAccounts([account.id]); const preferred = await db.select().from(schema.accountTokens) .where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.isDefault, true))) @@ -516,6 +628,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { success: true, createdViaUpstream: true, ...syncResult, + coverageRefresh, token, }; }); @@ -539,7 +652,10 @@ export async function accountTokensRoutes(app: FastifyInstance) { const account = row.accounts; const site = row.sites; const adapter = getAdapter(site.platform); - const shouldDeleteUpstream = !isSiteDisabled(site.status) && !!account.accessToken?.trim() && !!adapter; + const shouldDeleteUpstream = !isMaskedPendingAccountToken(existing) + && !isSiteDisabled(site.status) + && !!account.accessToken?.trim() + && !!adapter; if (shouldDeleteUpstream) { const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); @@ -603,6 +719,10 @@ export async function accountTokensRoutes(app: FastifyInstance) { continue; } } else { + if (isMaskedPendingAccountToken(existing)) { + failedItems.push({ id, message: '待补全令牌不能修改启用状态,请先补全明文 token' }); + continue; + } await db.update(schema.accountTokens) .set({ enabled: action === 'enable', @@ -649,6 +769,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { const body = request.body; const updates: Record = { updatedAt: new Date().toISOString() }; + let nextValueStatus = resolveAccountTokenValueStatus(existing); if (body.name !== undefined) { updates.name = (body.name || '').trim() || existing.name; @@ -660,15 +781,24 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: '令牌不能为空' }); } updates.token = tokenValue; + nextValueStatus = isMaskedTokenValue(tokenValue) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; + updates.valueStatus = nextValueStatus; } if (body.group !== undefined) { updates.tokenGroup = (body.group || '').trim() || null; } - if (body.enabled !== undefined) updates.enabled = body.enabled; + if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + updates.enabled = false; + updates.isDefault = false; + } else { + if (body.enabled !== undefined) updates.enabled = body.enabled; + if (body.isDefault !== undefined) updates.isDefault = body.isDefault; + } if (body.source !== undefined) updates.source = body.source; - if (body.isDefault !== undefined) updates.isDefault = body.isDefault; await db.update(schema.accountTokens).set(updates).where(eq(schema.accountTokens.id, tokenId)).run(); @@ -677,11 +807,11 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(500).send({ success: false, message: '更新失败' }); } - if (body.isDefault === true) { + if (body.isDefault === true && isUsableAccountToken(latest)) { setDefaultToken(tokenId); - } else if (latest.isDefault && latest.enabled) { + } else if (latest.isDefault && isUsableAccountToken(latest)) { setDefaultToken(tokenId); - } else if (existing.isDefault && !latest.enabled) { + } else if (existing.isDefault && !isUsableAccountToken(latest)) { repairDefaultToken(existing.accountId); } else if (body.isDefault === false && existing.isDefault) { repairDefaultToken(existing.accountId); @@ -706,6 +836,9 @@ export async function accountTokensRoutes(app: FastifyInstance) { if (isApiKeyConnection(owner)) { return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' }); } + if (isMaskedPendingAccountToken(tokenRow)) { + return reply.code(400).send({ success: false, message: '待补全令牌不能设为默认,请先补全明文 token' }); + } const success = await setDefaultToken(tokenId); if (!success) { return reply.code(404).send({ success: false, message: '令牌不存在' }); @@ -733,7 +866,7 @@ export async function accountTokensRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'API Key 连接不支持管理账号令牌' }); } - if (isMaskedTokenValue(row.account_tokens.token)) { + if (isMaskedPendingAccountToken(row.account_tokens) || isMaskedTokenValue(row.account_tokens.token)) { return reply.code(409).send({ success: false, message: '当前仅保存了脱敏令牌,无法展开/复制。请在站点重新生成并同步,或手动更新为完整令牌。', @@ -836,7 +969,8 @@ export async function accountTokensRoutes(app: FastifyInstance) { if (result.status === 'failed') { return reply.code(502).send({ success: false, message: result.message || '同步失败' }); } - return { success: true, ...result }; + const coverageRefresh = await refreshCoverageForAccounts([accountId]); + return { success: true, ...result, coverageRefresh }; }); app.post<{ Body?: { wait?: boolean } }>('/api/account-tokens/sync-all', async (request, reply) => { diff --git a/src/server/routes/api/accounts.add-background-task.test.ts b/src/server/routes/api/accounts.add-background-task.test.ts new file mode 100644 index 0000000..af304e3 --- /dev/null +++ b/src/server/routes/api/accounts.add-background-task.test.ts @@ -0,0 +1,178 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const verifyTokenMock = vi.fn(); +const getApiTokensMock = vi.fn(); +const refreshBalanceMock = vi.fn(); +const refreshModelsForAccountMock = vi.fn(); +const rebuildTokenRoutesFromAvailabilityMock = vi.fn(); +const ensureDefaultTokenForAccountMock = vi.fn(); +const syncTokensFromUpstreamMock = vi.fn(); + +vi.mock('../../services/platforms/index.js', () => ({ + getAdapter: () => ({ + verifyToken: (...args: unknown[]) => verifyTokenMock(...args), + getApiTokens: (...args: unknown[]) => getApiTokensMock(...args), + }), +})); + +vi.mock('../../services/balanceService.js', () => ({ + refreshBalance: (...args: unknown[]) => refreshBalanceMock(...args), +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsForAccount: (...args: unknown[]) => refreshModelsForAccountMock(...args), + rebuildTokenRoutesFromAvailability: (...args: unknown[]) => rebuildTokenRoutesFromAvailabilityMock(...args), +})); + +vi.mock('../../services/accountTokenService.js', () => ({ + ensureDefaultTokenForAccount: (...args: unknown[]) => ensureDefaultTokenForAccountMock(...args), + syncTokensFromUpstream: (...args: unknown[]) => syncTokensFromUpstreamMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +describe('accounts background initialization', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + let resetBackgroundTasks: (() => void) | null = null; + let getBackgroundTask: ((taskId: string) => { status: string } | null) | null = null; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-accounts-background-init-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./accounts.js'); + const backgroundTaskModule = await import('../../services/backgroundTaskService.js'); + db = dbModule.db; + schema = dbModule.schema; + resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests; + getBackgroundTask = backgroundTaskModule.getBackgroundTask; + + app = Fastify(); + await app.register(routesModule.accountsRoutes); + }); + + beforeEach(async () => { + verifyTokenMock.mockReset(); + getApiTokensMock.mockReset(); + refreshBalanceMock.mockReset(); + refreshModelsForAccountMock.mockReset(); + rebuildTokenRoutesFromAvailabilityMock.mockReset(); + ensureDefaultTokenForAccountMock.mockReset(); + syncTokensFromUpstreamMock.mockReset(); + resetBackgroundTasks?.(); + + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.checkinLogs).run(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.events).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch {} + } + delete process.env.DATA_DIR; + }); + + it('returns immediately and queues background initialization when token sync is slow', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Session Site', + url: 'https://session.example.com', + platform: 'new-api', + }).returning().get(); + + verifyTokenMock.mockResolvedValue({ + tokenType: 'session', + userInfo: { username: 'demo-user' }, + apiToken: 'sk-demo', + }); + ensureDefaultTokenForAccountMock.mockResolvedValue(undefined); + refreshBalanceMock.mockResolvedValue({ balance: 1, used: 0, quota: 1 }); + refreshModelsForAccountMock.mockResolvedValue(undefined); + rebuildTokenRoutesFromAvailabilityMock.mockResolvedValue(undefined); + syncTokensFromUpstreamMock.mockResolvedValue(undefined); + + let releaseTokens: ((value: Array<{ name: string; value: string }>) => void) | null = null; + const pendingTokens = new Promise>((resolve) => { + releaseTokens = resolve; + }); + getApiTokensMock.mockReturnValue(pendingTokens); + + const responsePromise = app.inject({ + method: 'POST', + url: '/api/accounts', + payload: { + siteId: site.id, + accessToken: 'session-token', + }, + }); + + try { + const raceResult = await Promise.race([ + responsePromise.then(() => 'response'), + new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), 25)), + ]); + + expect(raceResult).toBe('response'); + + const response = await responsePromise; + expect(response.statusCode).toBe(200); + + const body = response.json() as { + id: number; + queued?: boolean; + jobId?: string; + usernameDetected?: boolean; + apiTokenFound?: boolean; + }; + + expect(body).toMatchObject({ + queued: true, + usernameDetected: true, + apiTokenFound: true, + }); + expect(body.jobId).toBeTruthy(); + + const insertedAccounts = await db.select().from(schema.accounts).all(); + expect(insertedAccounts).toHaveLength(1); + expect(getBackgroundTask?.(body.jobId!)).toMatchObject({ + status: expect.stringMatching(/pending|running/), + }); + + releaseTokens?.([{ name: 'default', value: 'sk-demo' }]); + + for (let attempt = 0; attempt < 20; attempt += 1) { + const task = getBackgroundTask?.(body.jobId!); + if (task?.status === 'succeeded') break; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(syncTokensFromUpstreamMock).toHaveBeenCalledTimes(1); + expect(refreshBalanceMock).toHaveBeenCalledTimes(1); + expect(refreshModelsForAccountMock).toHaveBeenCalledTimes(1); + expect(rebuildTokenRoutesFromAvailabilityMock).toHaveBeenCalledTimes(1); + expect(getBackgroundTask?.(body.jobId!)).toMatchObject({ status: 'succeeded' }); + } finally { + releaseTokens?.([]); + await responsePromise.catch(() => undefined); + } + }); +}); diff --git a/src/server/routes/api/accounts.credentialMode.test.ts b/src/server/routes/api/accounts.credentialMode.test.ts index 46702eb..317f49f 100644 --- a/src/server/routes/api/accounts.credentialMode.test.ts +++ b/src/server/routes/api/accounts.credentialMode.test.ts @@ -19,7 +19,7 @@ vi.mock('../../services/platforms/index.js', () => ({ type DbModule = typeof import('../../db/index.js'); -describe('accounts credential mode', () => { +describe('accounts credential mode', { timeout: 15_000 }, () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; @@ -172,6 +172,62 @@ describe('accounts credential mode', () => { }); }); + it('marks codex oauth connection as direct-routed proxy-only connection without checkin/balance capabilities', async () => { + const site = await db.insert(schema.sites).values({ + name: 'Codex Site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + checkinEnabled: false, + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + checkedAt: '2026-03-16T12:00:00.000Z', + }).run(); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/accounts', + }); + expect(listResponse.statusCode).toBe(200); + + const list = listResponse.json() as Array<{ + id: number; + credentialMode?: string; + capabilities?: { + canCheckin?: boolean; + canRefreshBalance?: boolean; + proxyOnly?: boolean; + }; + }>; + const item = list.find((entry) => entry.id === account.id); + expect(item?.credentialMode).toBe('session'); + expect(item?.capabilities).toMatchObject({ + canCheckin: false, + canRefreshBalance: false, + proxyOnly: true, + }); + }); + it('stores managed refresh token for sub2api session account', async () => { verifyTokenMock.mockResolvedValueOnce({ tokenType: 'session', diff --git a/src/server/routes/api/accounts.rebind-session.test.ts b/src/server/routes/api/accounts.rebind-session.test.ts index da558d4..f754eef 100644 --- a/src/server/routes/api/accounts.rebind-session.test.ts +++ b/src/server/routes/api/accounts.rebind-session.test.ts @@ -15,7 +15,7 @@ vi.mock('../../services/platforms/index.js', () => ({ type DbModule = typeof import('../../db/index.js'); -describe('accounts rebind-session api', () => { +describe('accounts rebind-session api', { timeout: 15_000 }, () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; diff --git a/src/server/routes/api/accounts.ts b/src/server/routes/api/accounts.ts index e9d451c..5ee9b7e 100644 --- a/src/server/routes/api/accounts.ts +++ b/src/server/routes/api/accounts.ts @@ -9,6 +9,7 @@ import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, guessPlatformUserIdFromUsername, + hasOauthProvider, getSub2ApiAuthFromExtraConfig, mergeAccountExtraConfig, normalizeCredentialMode as normalizeCredentialModeInput, @@ -49,6 +50,17 @@ type AccountCapabilities = { proxyOnly: boolean; }; +type AccountInitializationParams = { + accountId: number; + site: typeof schema.sites.$inferSelect; + adapter: NonNullable>; + tokenType: 'session' | 'apikey' | 'unknown'; + accessToken: string; + apiToken: string; + platformUserId?: number; + skipModelFetch?: boolean; +}; + type VerifyFailureReason = 'needs-user-id' | 'invalid-user-id' | 'shield-blocked' | null; const limitAccountLogin = createRateLimitGuard({ @@ -80,7 +92,15 @@ function resolveStoredCredentialMode(account: typeof schema.accounts.$inferSelec function buildCapabilitiesFromCredentialMode( credentialMode: AccountCredentialMode, hasSessionToken: boolean, + extraConfig?: string | null, ): AccountCapabilities { + if (hasOauthProvider(extraConfig)) { + return { + canCheckin: false, + canRefreshBalance: false, + proxyOnly: true, + }; + } const sessionCapable = credentialMode === 'session' ? hasSessionToken : (credentialMode === 'apikey' ? false : hasSessionToken); @@ -93,7 +113,7 @@ function buildCapabilitiesFromCredentialMode( function buildCapabilitiesForAccount(account: typeof schema.accounts.$inferSelect): AccountCapabilities { const credentialMode = resolveStoredCredentialMode(account); - return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken)); + return buildCapabilitiesFromCredentialMode(credentialMode, hasSessionTokenValue(account.accessToken), account.extraConfig); } function normalizeBatchIds(input: unknown): number[] { @@ -115,6 +135,75 @@ function normalizePinnedFlag(input: unknown): boolean | null { return null; } +async function initializeAccountInBackground({ + accountId, + site, + adapter, + tokenType, + accessToken, + apiToken, + platformUserId, + skipModelFetch, +}: AccountInitializationParams) { + const summary = { + accountId, + syncedTokenCount: 0, + refreshedBalance: false, + refreshedModels: false, + rebuiltRoutes: false, + }; + + if (tokenType === 'session' && apiToken) { + try { + await ensureDefaultTokenForAccount(accountId, apiToken, { name: 'default', source: 'manual' }); + } catch {} + } + + if (tokenType === 'session' && accessToken) { + try { + const syncedTokens = await adapter.getApiTokens(site.url, accessToken, platformUserId); + summary.syncedTokenCount = Array.isArray(syncedTokens) ? syncedTokens.length : 0; + if (summary.syncedTokenCount > 0) { + await syncTokensFromUpstream(accountId, syncedTokens); + } + } catch {} + } + + if (tokenType === 'session') { + try { + await refreshBalance(accountId); + summary.refreshedBalance = true; + } catch {} + } + + if (skipModelFetch !== true) { + try { + await refreshModelsForAccount(accountId); + summary.refreshedModels = true; + await rebuildTokenRoutesFromAvailability(); + summary.rebuiltRoutes = true; + } catch {} + } + + return summary; +} + +function buildQueuedAccountInitializationMessage( + tokenType: 'session' | 'apikey' | 'unknown', + skipModelFetch?: boolean, +) { + if (tokenType === 'session' && skipModelFetch === true) { + return '账号已添加,后台正在同步令牌和余额信息。'; + } + if (tokenType === 'session') { + return '账号已添加,后台正在同步令牌、余额和模型信息。'; + } + if (skipModelFetch === true) { + return '已添加为 API Key 账号(可用于代理转发)。'; + } + return '已添加为 API Key 账号,后台正在同步模型和路由信息。'; +} + function normalizeSortOrder(input: unknown): number | null { if (input === undefined || input === null || input === '') return null; const parsed = Number.parseInt(String(input), 10); @@ -411,6 +500,7 @@ export async function accountsRoutes(app: FastifyInstance) { capabilities: buildCapabilitiesFromCredentialMode( credentialMode, hasSessionTokenValue(r.accounts.accessToken), + r.accounts.extraConfig, ), todaySpend: Math.round((spendByAccount[r.accounts.id] || 0) * 1_000_000) / 1_000_000, todayReward: Math.round(estimateRewardWithTodayIncomeFallback({ @@ -427,6 +517,7 @@ export async function accountsRoutes(app: FastifyInstance) { sessionCapable: buildCapabilitiesFromCredentialMode( credentialMode, hasSessionTokenValue(r.accounts.accessToken), + r.accounts.extraConfig, ).canRefreshBalance, hasDiscoveredModels: (modelCountByAccount[r.accounts.id] || 0) > 0, }), @@ -972,7 +1063,7 @@ export async function accountsRoutes(app: FastifyInstance) { account: latest, tokenType: 'session', credentialMode: 'session', - capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true), + capabilities: latest ? buildCapabilitiesForAccount(latest) : buildCapabilitiesFromCredentialMode('session', true, null), apiTokenFound: !!nextApiToken, }; }, @@ -1110,39 +1201,40 @@ export async function accountsRoutes(app: FastifyInstance) { return reply.code(500).send({ success: false, message: '创建账号失败' }); } - if (tokenType === 'session' && apiToken) { - try { - await ensureDefaultTokenForAccount(result.id, apiToken, { name: 'default', source: 'manual' }); - } catch { } - } - - if (tokenType === 'session' && accessToken) { - try { - const syncedTokens = await adapter.getApiTokens(site.url, accessToken, resolvedPlatformUserId); - if (syncedTokens.length > 0) { - await syncTokensFromUpstream(result.id, syncedTokens); - } - } catch { } - } - - // Try to refresh balance - if (tokenType === 'session') { - try { await refreshBalance(result.id); } catch { } - } - - // Try to refresh models only if not skipping. - if (body.skipModelFetch !== true) { - try { - await refreshModelsForAccount(result.id); - await rebuildTokenRoutesFromAvailability(); - } catch { } + const shouldQueueInitialization = tokenType === 'session' || body.skipModelFetch !== true; + let queuedTaskId: string | undefined; + let queuedMessage: string | undefined; + if (shouldQueueInitialization) { + const taskTitle = `初始化连接 #${result.id}`; + const { task } = startBackgroundTask( + { + type: 'account-init', + title: taskTitle, + dedupeKey: `account-init-${result.id}`, + notifyOnFailure: true, + successMessage: () => `${taskTitle}已完成`, + failureMessage: (currentTask) => `${taskTitle}失败:${currentTask.error || 'unknown error'}`, + }, + async () => initializeAccountInBackground({ + accountId: result.id, + site, + adapter, + tokenType, + accessToken, + apiToken, + platformUserId: resolvedPlatformUserId, + skipModelFetch: body.skipModelFetch, + }), + ); + queuedTaskId = task.id; + queuedMessage = buildQueuedAccountInitializationMessage(tokenType, body.skipModelFetch); } const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, result.id)).get(); const finalCredentialMode = account ? resolveStoredCredentialMode(account) : resolvedCredentialMode; const capabilities = account ? buildCapabilitiesForAccount(account) - : buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session'); + : buildCapabilitiesFromCredentialMode(finalCredentialMode, tokenType === 'session', null); return { ...account, tokenType, @@ -1151,6 +1243,9 @@ export async function accountsRoutes(app: FastifyInstance) { modelCount: verifiedModels.length, apiTokenFound: !!apiToken, usernameDetected: !!(!body.username && username), + queued: !!queuedTaskId, + jobId: queuedTaskId, + message: queuedMessage, }; }); diff --git a/src/server/routes/api/downstreamApiKeys.test.ts b/src/server/routes/api/downstreamApiKeys.test.ts new file mode 100644 index 0000000..8a59c30 --- /dev/null +++ b/src/server/routes/api/downstreamApiKeys.test.ts @@ -0,0 +1,510 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { eq } from 'drizzle-orm'; +import { PgDialect } from 'drizzle-orm/pg-core'; + +type DbModule = typeof import('../../db/index.js'); + +describe('downstream api keys routes', () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-downstream-routes-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./downstreamApiKeys.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.downstreamApiKeysRoutes); + }); + + beforeEach(async () => { + await db.delete(schema.proxyLogs).run(); + await db.delete(schema.downstreamApiKeys).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('builds postgres trend buckets by casting text timestamps before date_trunc', async () => { + const routesModule = await import('./downstreamApiKeys.js') as Record; + + expect(typeof routesModule.buildBucketTsExpressionForDialect).toBe('function'); + + const expression = routesModule.buildBucketTsExpressionForDialect('postgres', schema.proxyLogs.createdAt, 3600); + const rendered = new PgDialect().sqlToQuery(expression).sql; + + expect(rendered).toContain('date_trunc'); + expect(rendered).toContain('cast'); + expect(rendered).toContain('timestamp'); + }); + + it('creates, updates, resets and deletes downstream api keys', async () => { + const site = await db.insert(schema.sites).values({ + name: 'portal-site', + url: 'https://portal.example.com', + status: 'active', + platform: 'openai', + }).returning().get(); + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2', + displayName: 'portal-route', + enabled: true, + }).returning().get(); + + const createRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'portal-key', + key: 'sk-portal-key-001', + description: 'portal consumer', + groupName: '项目A', + tags: ['移动端', 'VIP'], + enabled: true, + maxCost: 12.5, + maxRequests: 500, + supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'], + allowedRouteIds: [route.id], + siteWeightMultipliers: { [site.id]: 1.2 }, + }, + }); + + expect(createRes.statusCode).toBe(200); + const createdBody = createRes.json(); + expect(createdBody.success).toBe(true); + expect(createdBody.item).toMatchObject({ + name: 'portal-key', + keyMasked: expect.any(String), + groupName: '项目A', + tags: ['移动端', 'VIP'], + maxCost: 12.5, + maxRequests: 500, + supportedModels: ['gpt-5.2', 'claude-sonnet-4-5'], + allowedRouteIds: [route.id], + }); + + const keyId = createdBody.item.id as number; + + const updateRes = await app.inject({ + method: 'PUT', + url: `/api/downstream-keys/${keyId}`, + payload: { + name: 'portal-key-updated', + key: 'sk-portal-key-001', + groupName: '项目B', + tags: ['批量候选', 'VIP'], + enabled: false, + maxCost: 20, + maxRequests: 900, + }, + }); + + expect(updateRes.statusCode).toBe(200); + expect(updateRes.json()).toMatchObject({ + success: true, + item: { + id: keyId, + name: 'portal-key-updated', + groupName: '项目B', + tags: ['批量候选', 'VIP'], + enabled: false, + maxCost: 20, + maxRequests: 900, + }, + }); + + await db.update(schema.downstreamApiKeys).set({ + usedCost: 5.5, + usedRequests: 123, + }).where(eq(schema.downstreamApiKeys.id, keyId)).run(); + + const resetRes = await app.inject({ + method: 'POST', + url: `/api/downstream-keys/${keyId}/reset-usage`, + }); + + expect(resetRes.statusCode).toBe(200); + expect(resetRes.json()).toMatchObject({ success: true }); + + const resetRow = await db.select().from(schema.downstreamApiKeys) + .where(eq(schema.downstreamApiKeys.id, keyId)) + .get(); + expect(resetRow?.usedCost).toBe(0); + expect(resetRow?.usedRequests).toBe(0); + + const deleteRes = await app.inject({ + method: 'DELETE', + url: `/api/downstream-keys/${keyId}`, + }); + + expect(deleteRes.statusCode).toBe(200); + expect(deleteRes.json()).toMatchObject({ success: true }); + + const listRes = await app.inject({ + method: 'GET', + url: '/api/downstream-keys', + }); + expect(listRes.statusCode).toBe(200); + expect(listRes.json()).toMatchObject({ success: true, items: [] }); + }); + + it('supports batch enable/disable/reset/delete operations', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values([ + { + name: 'batch-a', + key: 'sk-batch-a-001', + enabled: true, + usedCost: 1.2, + usedRequests: 12, + }, + { + name: 'batch-b', + key: 'sk-batch-b-001', + enabled: false, + usedCost: 2.4, + usedRequests: 24, + }, + ]).returning().all(); + + const disableRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [inserted[0].id, inserted[1].id], + action: 'disable', + }, + }); + expect(disableRes.statusCode).toBe(200); + expect(disableRes.json()).toMatchObject({ + success: true, + successIds: [inserted[0].id, inserted[1].id], + failedItems: [], + }); + + const disabledRows = await db.select().from(schema.downstreamApiKeys).all(); + expect(disabledRows.every((row) => row.enabled === false)).toBe(true); + + const resetRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [inserted[0].id, inserted[1].id], + action: 'resetUsage', + }, + }); + expect(resetRes.statusCode).toBe(200); + const resetRows = await db.select().from(schema.downstreamApiKeys).all(); + expect(resetRows.every((row) => Number(row.usedCost) === 0 && Number(row.usedRequests) === 0)).toBe(true); + + const deleteRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [inserted[0].id, inserted[1].id], + action: 'delete', + }, + }); + expect(deleteRes.statusCode).toBe(200); + expect(await db.select().from(schema.downstreamApiKeys).all()).toHaveLength(0); + }); + + it('supports batch metadata update for group and tags', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values([ + { + name: 'meta-a', + key: 'sk-meta-a-001', + groupName: '旧分组', + tags: JSON.stringify(['旧标签']), + enabled: true, + }, + { + name: 'meta-b', + key: 'sk-meta-b-001', + tags: JSON.stringify(['旧标签', '公共']), + enabled: true, + }, + ]).returning().all(); + + const res = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: inserted.map((item) => item.id), + action: 'updateMetadata', + groupOperation: 'set', + groupName: '新分组', + tagOperation: 'append', + tags: ['项目A', '公共'], + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ + success: true, + successIds: inserted.map((item) => item.id), + failedItems: [], + }); + + const rows = await db.select().from(schema.downstreamApiKeys).all(); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.groupName).toBe('新分组'); + } + const views = rows.map((row) => ({ + id: row.id, + groupName: row.groupName, + tags: JSON.parse(String(row.tags || '[]')), + })); + expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '项目A', '公共'] })); + expect(views).toContainEqual(expect.objectContaining({ tags: ['旧标签', '公共', '项目A'] })); + }); + + it('rejects duplicate key creation and invalid batch action', async () => { + const firstRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'dup-a', + key: 'sk-dup-key-001', + }, + }); + expect(firstRes.statusCode).toBe(200); + + const duplicateRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'dup-b', + key: 'sk-dup-key-001', + }, + }); + expect(duplicateRes.statusCode).toBe(409); + expect(duplicateRes.json()).toMatchObject({ + success: false, + message: 'API key 已存在', + }); + + const invalidBatchRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys/batch', + payload: { + ids: [999], + action: 'archive', + }, + }); + expect(invalidBatchRes.statusCode).toBe(400); + expect(invalidBatchRes.json()).toMatchObject({ + success: false, + message: 'Invalid action', + }); + }); + + it('returns summary, overview and trend aggregated from proxy logs', async () => { + const inserted = await db.insert(schema.downstreamApiKeys).values({ + name: 'analytics-key', + key: 'sk-analytics-key-001', + enabled: true, + description: 'analytics', + usedCost: 0, + usedRequests: 0, + }).returning().get(); + + const now = Date.now(); + const within24h = new Date(now - 2 * 60 * 60 * 1000).toISOString(); + const within7d = new Date(now - 2 * 24 * 60 * 60 * 1000).toISOString(); + const outside7d = new Date(now - 10 * 24 * 60 * 60 * 1000).toISOString(); + + await db.insert(schema.proxyLogs).values([ + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 1200, + estimatedCost: 0.12, + createdAt: within24h, + }, + { + downstreamApiKeyId: inserted.id, + status: 'failed', + totalTokens: 300, + estimatedCost: 0.03, + createdAt: within24h, + }, + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 600, + estimatedCost: 0.06, + createdAt: within7d, + }, + { + downstreamApiKeyId: inserted.id, + status: 'success', + totalTokens: 900, + estimatedCost: 0.09, + createdAt: outside7d, + }, + ]).run(); + + const summaryRes = await app.inject({ + method: 'GET', + url: '/api/downstream-keys/summary?range=24h&status=enabled&search=analytics', + }); + + expect(summaryRes.statusCode).toBe(200); + expect(summaryRes.json()).toMatchObject({ + success: true, + range: '24h', + status: 'enabled', + search: 'analytics', + group: '', + tags: [], + tagMatch: 'any', + items: [ + { + id: inserted.id, + name: 'analytics-key', + rangeUsage: { + totalRequests: 2, + successRequests: 1, + failedRequests: 1, + successRate: 50, + totalTokens: 1500, + totalCost: 0.15, + }, + }, + ], + }); + + const overviewRes = await app.inject({ + method: 'GET', + url: `/api/downstream-keys/${inserted.id}/overview`, + }); + + expect(overviewRes.statusCode).toBe(200); + expect(overviewRes.json()).toMatchObject({ + success: true, + item: { id: inserted.id, name: 'analytics-key' }, + usage: { + last24h: { + totalRequests: 2, + successRequests: 1, + failedRequests: 1, + totalTokens: 1500, + totalCost: 0.15, + }, + last7d: { + totalRequests: 3, + successRequests: 2, + failedRequests: 1, + totalTokens: 2100, + totalCost: 0.21, + }, + all: { + totalRequests: 4, + successRequests: 3, + failedRequests: 1, + totalTokens: 3000, + totalCost: 0.3, + }, + }, + }); + + const trendRes = await app.inject({ + method: 'GET', + url: `/api/downstream-keys/${inserted.id}/trend?range=all`, + }); + + expect(trendRes.statusCode).toBe(200); + const trendBody = trendRes.json(); + expect(trendBody.success).toBe(true); + expect(trendBody.range).toBe('all'); + expect(trendBody.item).toMatchObject({ id: inserted.id, name: 'analytics-key' }); + expect(Array.isArray(trendBody.buckets)).toBe(true); + expect(trendBody.buckets.length).toBeGreaterThanOrEqual(3); + expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 1500)).toBe(true); + expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 600)).toBe(true); + expect(trendBody.buckets.some((bucket: any) => bucket.totalTokens === 900)).toBe(true); + }); + + it('rejects unknown route ids and site ids in downstream policy payloads', async () => { + const createRes = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'invalid-policy', + key: 'sk-invalid-policy-001', + allowedRouteIds: [999], + }, + }); + expect(createRes.statusCode).toBe(400); + expect(createRes.json()).toMatchObject({ + success: false, + message: 'allowedRouteIds 包含不存在的路由: 999', + }); + + const site = await db.insert(schema.sites).values({ + name: 'site-a', + url: 'https://example.com', + status: 'active', + platform: 'openai', + }).returning().get(); + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2', + enabled: true, + }).returning().get(); + const created = await app.inject({ + method: 'POST', + url: '/api/downstream-keys', + payload: { + name: 'valid-policy', + key: 'sk-valid-policy-001', + allowedRouteIds: [route.id], + siteWeightMultipliers: { [site.id]: 1.2 }, + }, + }); + expect(created.statusCode).toBe(200); + const keyId = created.json().item.id as number; + + const updateRes = await app.inject({ + method: 'PUT', + url: `/api/downstream-keys/${keyId}`, + payload: { + siteWeightMultipliers: { 999: 1.5 }, + }, + }); + expect(updateRes.statusCode).toBe(400); + expect(updateRes.json()).toMatchObject({ + success: false, + message: 'siteWeightMultipliers 包含不存在的站点: 999', + }); + + const filteredSummaryRes = await app.inject({ + method: 'GET', + url: '/api/downstream-keys/summary?range=all&group=__ungrouped__&tags=foo,bar&tagMatch=all', + }); + expect(filteredSummaryRes.statusCode).toBe(200); + expect(filteredSummaryRes.json()).toMatchObject({ + success: true, + range: 'all', + group: '__ungrouped__', + tags: ['foo', 'bar'], + tagMatch: 'all', + items: [], + }); + }); +}); diff --git a/src/server/routes/api/downstreamApiKeys.ts b/src/server/routes/api/downstreamApiKeys.ts index 987c489..423db26 100644 --- a/src/server/routes/api/downstreamApiKeys.ts +++ b/src/server/routes/api/downstreamApiKeys.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; -import { eq } from 'drizzle-orm'; -import { db, schema } from '../../db/index.js'; +import { and, eq, inArray, sql, type SQL, type SQLWrapper } from 'drizzle-orm'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, runtimeDbDialect, schema } from '../../db/index.js'; import { getDownstreamApiKeyById, listDownstreamApiKeys, @@ -8,6 +8,7 @@ import { toDownstreamApiKeyPolicyView, toPersistenceJson, } from '../../services/downstreamApiKeyService.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; function parseRouteId(raw: string): number | null { const id = Number.parseInt(raw, 10); @@ -24,7 +25,380 @@ function looksLikeUniqueViolation(error: unknown): boolean { return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key'); } +function normalizeBatchIds(raw: unknown): number[] { + const values = Array.isArray(raw) ? raw : []; + const ids: number[] = []; + for (const item of values) { + const parsed = Number(item); + if (!Number.isFinite(parsed)) continue; + const id = Math.trunc(parsed); + if (id <= 0 || ids.includes(id)) continue; + ids.push(id); + if (ids.length >= 500) break; + } + return ids; +} + +type DownstreamKeyRange = '24h' | '7d' | 'all'; +type DownstreamKeyStatus = 'all' | 'enabled' | 'disabled'; + +function normalizeDownstreamKeyRange(raw: unknown): DownstreamKeyRange { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (value === '24h') return '24h'; + if (value === '7d') return '7d'; + if (value === 'all') return 'all'; + return '24h'; +} + +function normalizeDownstreamKeyStatus(raw: unknown): DownstreamKeyStatus { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + if (value === 'enabled') return 'enabled'; + if (value === 'disabled') return 'disabled'; + return 'all'; +} + +function normalizeSearchQuery(raw: unknown): string { + const value = typeof raw === 'string' ? raw.trim() : ''; + if (!value) return ''; + return value.slice(0, 80); +} + +function normalizeGroupQuery(raw: unknown): string { + const value = typeof raw === 'string' ? raw.trim() : ''; + return value.slice(0, 64); +} + +function normalizeTagQuery(raw: unknown): string[] { + const value = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw.join(',') : ''; + return value + .split(/[\r\n,,]+/g) + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => item.slice(0, 32)) + .filter((item, index, arr) => arr.findIndex((candidate) => candidate.toLowerCase() === item.toLowerCase()) === index) + .slice(0, 20); +} + +function normalizeTagMatchMode(raw: unknown): 'any' | 'all' { + const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''; + return value === 'all' ? 'all' : 'any'; +} + +function resolveRangeSinceUtc(range: DownstreamKeyRange): string | null { + const nowTs = Date.now(); + if (range === '24h') return formatUtcSqlDateTime(new Date(nowTs - 24 * 60 * 60 * 1000)); + if (range === '7d') return formatUtcSqlDateTime(new Date(nowTs - 7 * 24 * 60 * 60 * 1000)); + return null; +} + +function resolveBucketSeconds(range: DownstreamKeyRange): number { + return range === 'all' ? 86400 : 3600; +} + +export function buildBucketTsExpressionForDialect( + dialect: 'sqlite' | 'mysql' | 'postgres', + createdAtColumn: SQLWrapper, + bucketSeconds: number, +) { + if (dialect === 'mysql') { + return sql`floor(unix_timestamp(${createdAtColumn}) / ${bucketSeconds}) * ${bucketSeconds}`; + } + if (dialect === 'postgres') { + const createdAtTimestamp = sql`cast(${createdAtColumn} as timestamp)`; + if (bucketSeconds === 86400) { + return sql`extract(epoch from date_trunc('day', ${createdAtTimestamp}))::bigint`; + } + return sql`extract(epoch from date_trunc('hour', ${createdAtTimestamp}))::bigint`; + } + // sqlite + return sql`cast(cast(strftime('%s', ${createdAtColumn}) as integer) / ${bucketSeconds} as integer) * ${bucketSeconds}`; +} + +function resolveBucketTsExpression(bucketSeconds: number) { + return buildBucketTsExpressionForDialect(runtimeDbDialect, schema.proxyLogs.createdAt, bucketSeconds); +} + +async function validatePolicyReferences(input: { + allowedRouteIds: number[]; + siteWeightMultipliers: Record; +}): Promise { + const routeIds = input.allowedRouteIds || []; + if (routeIds.length > 0) { + const rows = await db.select({ id: schema.tokenRoutes.id }) + .from(schema.tokenRoutes) + .where(inArray(schema.tokenRoutes.id, routeIds)) + .all(); + const existingIds = new Set(rows.map((row) => Number(row.id))); + const missingIds = routeIds.filter((id) => !existingIds.has(id)); + if (missingIds.length > 0) { + return `allowedRouteIds 包含不存在的路由: ${missingIds.join(', ')}`; + } + } + + const siteIds = Object.keys(input.siteWeightMultipliers || {}) + .map((key) => Number(key)) + .filter((value) => Number.isFinite(value) && value > 0) + .map((value) => Math.trunc(value)); + if (siteIds.length > 0) { + const rows = await db.select({ id: schema.sites.id }) + .from(schema.sites) + .where(inArray(schema.sites.id, siteIds)) + .all(); + const existingIds = new Set(rows.map((row) => Number(row.id))); + const missingIds = siteIds.filter((id) => !existingIds.has(id)); + if (missingIds.length > 0) { + return `siteWeightMultipliers 包含不存在的站点: ${missingIds.join(', ')}`; + } + } + + return null; +} + export async function downstreamApiKeysRoutes(app: FastifyInstance) { + app.get<{ Querystring: { range?: string; status?: string; search?: string; group?: string; tags?: string | string[]; tagMatch?: string } }>('/api/downstream-keys/summary', async (request) => { + const range = normalizeDownstreamKeyRange(request.query?.range); + const status = normalizeDownstreamKeyStatus(request.query?.status); + const search = normalizeSearchQuery(request.query?.search); + const group = normalizeGroupQuery(request.query?.group); + const tags = normalizeTagQuery(request.query?.tags); + const tagMatch = normalizeTagMatchMode(request.query?.tagMatch); + + const whereClauses: SQL[] = []; + if (status === 'enabled') { + whereClauses.push(eq(schema.downstreamApiKeys.enabled, true)); + } else if (status === 'disabled') { + whereClauses.push(eq(schema.downstreamApiKeys.enabled, false)); + } + if (search) { + const pattern = `%${search.toLowerCase()}%`; + whereClauses.push(sql`(lower(${schema.downstreamApiKeys.name}) like ${pattern} or lower(coalesce(${schema.downstreamApiKeys.description}, '')) like ${pattern})`); + } + + let keysQuery = db.select().from(schema.downstreamApiKeys); + if (whereClauses.length > 0) { + keysQuery = keysQuery.where(and(...whereClauses)); + } + const keys = (await keysQuery.all()) + .map((row) => toDownstreamApiKeyPolicyView(row)) + .filter((item) => { + if (group === '__ungrouped__') { + if (item.groupName) return false; + } else if (group && item.groupName !== group) { + return false; + } + + if (!search && tags.length === 0) return true; + const haystack = [ + item.name, + item.description || '', + item.keyMasked, + item.groupName || '', + ...item.tags, + ...item.supportedModels, + ].join(' ').toLowerCase(); + if (search && !haystack.includes(search.toLowerCase())) return false; + if (tags.length === 0) return true; + const itemTags = new Set(item.tags.map((tag) => tag.toLowerCase())); + return tagMatch === 'all' + ? tags.every((tag) => itemTags.has(tag.toLowerCase())) + : tags.some((tag) => itemTags.has(tag.toLowerCase())); + }) + .sort((a, b) => b.id - a.id); + + if (keys.length === 0) { + return { success: true, range, status, search, group, tags, tagMatch, items: [] }; + } + + const columnReady = await hasProxyLogDownstreamApiKeyIdColumn(); + const sinceUtc = resolveRangeSinceUtc(range); + const ids = keys.map((k) => k.id); + + const usageRows = columnReady + ? await db.select({ + keyId: schema.proxyLogs.downstreamApiKeyId, + totalRequests: sql`count(*)`, + successRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, + failedRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, + totalTokens: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + totalCost: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .where(and( + inArray(schema.proxyLogs.downstreamApiKeyId, ids), + ...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []), + )) + .groupBy(schema.proxyLogs.downstreamApiKeyId) + .all() + : []; + + const usageByKey = new Map(); + + for (const row of usageRows) { + const keyId = Number((row as any).keyId ?? 0); + if (!Number.isFinite(keyId) || keyId <= 0) continue; + usageByKey.set(keyId, { + totalRequests: Number((row as any).totalRequests || 0), + successRequests: Number((row as any).successRequests || 0), + failedRequests: Number((row as any).failedRequests || 0), + totalTokens: Number((row as any).totalTokens || 0), + totalCost: Number((row as any).totalCost || 0), + }); + } + + return { + success: true, + range, + status, + search, + group, + tags, + tagMatch, + items: keys.map((key) => { + const usage = usageByKey.get(key.id) || { + totalRequests: 0, + successRequests: 0, + failedRequests: 0, + totalTokens: 0, + totalCost: 0, + }; + const successRate = usage.totalRequests > 0 + ? Math.round((usage.successRequests / usage.totalRequests) * 1000) / 10 + : null; + return { + ...key, + rangeUsage: { + totalRequests: usage.totalRequests, + successRequests: usage.successRequests, + failedRequests: usage.failedRequests, + successRate, + totalTokens: usage.totalTokens, + totalCost: Math.round(usage.totalCost * 1_000_000) / 1_000_000, + }, + }; + }), + }; + }); + + app.get<{ Params: { id: string } }>('/api/downstream-keys/:id/overview', async (request, reply) => { + const id = parseRouteId(request.params.id); + if (!id) { + return reply.code(400).send({ success: false, message: 'id 无效' }); + } + + const item = await getDownstreamApiKeyById(id); + if (!item) { + return reply.code(404).send({ success: false, message: 'API key 不存在' }); + } + + const columnReady = await hasProxyLogDownstreamApiKeyIdColumn(); + if (!columnReady) { + return { success: true, item, usage: { last24h: null, last7d: null, all: null } }; + } + + const readAggregate = async (range: DownstreamKeyRange) => { + const sinceUtc = resolveRangeSinceUtc(range); + const row = await db.select({ + totalRequests: sql`count(*)`, + successRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, + failedRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, + totalTokens: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + totalCost: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .where(and( + eq(schema.proxyLogs.downstreamApiKeyId, id), + ...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []), + )) + .get(); + + const totalRequests = Number((row as any)?.totalRequests || 0); + const successRequests = Number((row as any)?.successRequests || 0); + const totalCost = Number((row as any)?.totalCost || 0); + return { + totalRequests, + successRequests, + failedRequests: Number((row as any)?.failedRequests || 0), + successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null, + totalTokens: Number((row as any)?.totalTokens || 0), + totalCost: Math.round(totalCost * 1_000_000) / 1_000_000, + }; + }; + + const [last24h, last7d, all] = await Promise.all([ + readAggregate('24h'), + readAggregate('7d'), + readAggregate('all'), + ]); + + return { success: true, item, usage: { last24h, last7d, all } }; + }); + + app.get<{ Params: { id: string }; Querystring: { range?: string } }>('/api/downstream-keys/:id/trend', async (request, reply) => { + const id = parseRouteId(request.params.id); + if (!id) { + return reply.code(400).send({ success: false, message: 'id 无效' }); + } + + const range = normalizeDownstreamKeyRange(request.query?.range); + const item = await getDownstreamApiKeyById(id); + if (!item) { + return reply.code(404).send({ success: false, message: 'API key 不存在' }); + } + + const columnReady = await hasProxyLogDownstreamApiKeyIdColumn(); + if (!columnReady) { + return { success: true, range, item: { id: item.id, name: item.name }, buckets: [] }; + } + + const bucketSeconds = resolveBucketSeconds(range); + const bucketTs = resolveBucketTsExpression(bucketSeconds); + const sinceUtc = resolveRangeSinceUtc(range); + + const rows = await db.select({ + bucketTs, + totalRequests: sql`count(*)`, + successRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`, + failedRequests: sql`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 0 else 1 end), 0)`, + totalTokens: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, + totalCost: sql`coalesce(sum(coalesce(${schema.proxyLogs.estimatedCost}, 0)), 0)`, + }) + .from(schema.proxyLogs) + .where(and( + eq(schema.proxyLogs.downstreamApiKeyId, id), + ...(sinceUtc ? [sql`${schema.proxyLogs.createdAt} >= ${sinceUtc}`] : []), + )) + .groupBy(bucketTs) + .orderBy(bucketTs) + .all(); + + return { + success: true, + range, + item: { id: item.id, name: item.name }, + bucketSeconds, + buckets: rows.map((row: any) => { + const tsSeconds = Number(row.bucketTs || 0); + const totalRequests = Number(row.totalRequests || 0); + const successRequests = Number(row.successRequests || 0); + return { + startUtc: tsSeconds > 0 ? new Date(tsSeconds * 1000).toISOString() : null, + totalRequests, + successRequests, + failedRequests: Number(row.failedRequests || 0), + successRate: totalRequests > 0 ? Math.round((successRequests / totalRequests) * 1000) / 10 : null, + totalTokens: Number(row.totalTokens || 0), + totalCost: Math.round(Number(row.totalCost || 0) * 1_000_000) / 1_000_000, + }; + }), + }; + }); + app.get('/api/downstream-keys', async () => { return { success: true, @@ -37,6 +411,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name?: unknown; key?: unknown; description?: unknown; + groupName?: unknown; + tags?: unknown; enabled?: unknown; expiresAt?: unknown; maxCost?: unknown; @@ -62,6 +438,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { if (!validateKeyShape(normalized.key)) { return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' }); } + const policyRefError = await validatePolicyReferences({ + allowedRouteIds: normalized.allowedRouteIds, + siteWeightMultipliers: normalized.siteWeightMultipliers, + }); + if (policyRefError) { + return reply.code(400).send({ success: false, message: policyRefError }); + } const nowIso = new Date().toISOString(); @@ -70,6 +453,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name: normalized.name, key: normalized.key, description: normalized.description, + groupName: normalized.groupName, + tags: toPersistenceJson(normalized.tags), enabled: normalized.enabled, expiresAt: normalized.expiresAt, maxCost: normalized.maxCost, @@ -111,6 +496,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name?: unknown; key?: unknown; description?: unknown; + groupName?: unknown; + tags?: unknown; enabled?: unknown; expiresAt?: unknown; maxCost?: unknown; @@ -140,6 +527,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name: request.body?.name ?? existing.name, key: request.body?.key ?? existing.key, description: request.body?.description ?? existing.description, + groupName: request.body?.groupName ?? existing.groupName, + tags: request.body?.tags ?? existingView.tags, enabled: request.body?.enabled ?? existing.enabled, expiresAt: request.body?.expiresAt ?? existing.expiresAt, maxCost: request.body?.maxCost ?? existing.maxCost, @@ -161,6 +550,13 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { if (!validateKeyShape(normalized.key)) { return reply.code(400).send({ success: false, message: 'key 必须以 sk- 开头且长度至少 6' }); } + const policyRefError = await validatePolicyReferences({ + allowedRouteIds: normalized.allowedRouteIds, + siteWeightMultipliers: normalized.siteWeightMultipliers, + }); + if (policyRefError) { + return reply.code(400).send({ success: false, message: policyRefError }); + } const nowIso = new Date().toISOString(); try { @@ -168,6 +564,8 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { name: normalized.name, key: normalized.key, description: normalized.description, + groupName: normalized.groupName, + tags: toPersistenceJson(normalized.tags), enabled: normalized.enabled, expiresAt: normalized.expiresAt, maxCost: normalized.maxCost, @@ -178,7 +576,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { updatedAt: nowIso, }).where(eq(schema.downstreamApiKeys.id, id)).run(); - const updated = getDownstreamApiKeyById(id); + const updated = await getDownstreamApiKeyById(id); return { success: true, item: updated, @@ -197,7 +595,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'id 无效' }); } - const existing = getDownstreamApiKeyById(id); + const existing = await getDownstreamApiKeyById(id); if (!existing) { return reply.code(404).send({ success: false, message: 'API key 不存在' }); } @@ -210,7 +608,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return { success: true, - item: getDownstreamApiKeyById(id), + item: await getDownstreamApiKeyById(id), }; }); @@ -220,7 +618,7 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return reply.code(400).send({ success: false, message: 'id 无效' }); } - const existing = getDownstreamApiKeyById(id); + const existing = await getDownstreamApiKeyById(id); if (!existing) { return reply.code(404).send({ success: false, message: 'API key 不存在' }); } @@ -231,4 +629,94 @@ export async function downstreamApiKeysRoutes(app: FastifyInstance) { return { success: true }; }); + + app.post<{ Body?: { + ids?: number[]; + action?: string; + groupOperation?: string; + groupName?: unknown; + tagOperation?: string; + tags?: unknown; + } }>('/api/downstream-keys/batch', async (request, reply) => { + const ids = normalizeBatchIds(request.body?.ids); + const action = String(request.body?.action || '').trim(); + if (ids.length === 0) { + return reply.code(400).send({ success: false, message: 'ids is required' }); + } + if (!['enable', 'disable', 'delete', 'resetUsage', 'updateMetadata'].includes(action)) { + return reply.code(400).send({ success: false, message: 'Invalid action' }); + } + + const groupOperation = String(request.body?.groupOperation || 'keep').trim(); + const tagOperation = String(request.body?.tagOperation || 'keep').trim(); + const normalizedGroupName = normalizeDownstreamApiKeyPayload({ groupName: request.body?.groupName }).groupName; + const normalizedTags = normalizeDownstreamApiKeyPayload({ tags: request.body?.tags }).tags; + + if (action === 'updateMetadata') { + if (!['keep', 'set', 'clear'].includes(groupOperation)) { + return reply.code(400).send({ success: false, message: 'Invalid groupOperation' }); + } + if (!['keep', 'append'].includes(tagOperation)) { + return reply.code(400).send({ success: false, message: 'Invalid tagOperation' }); + } + if (groupOperation === 'set' && !normalizedGroupName) { + return reply.code(400).send({ success: false, message: 'groupName is required when groupOperation=set' }); + } + if (tagOperation === 'append' && normalizedTags.length === 0) { + return reply.code(400).send({ success: false, message: 'tags is required when tagOperation=append' }); + } + } + + const successIds: number[] = []; + const failedItems: Array<{ id: number; message: string }> = []; + + for (const id of ids) { + try { + const existing = await getDownstreamApiKeyById(id); + if (!existing) { + failedItems.push({ id, message: 'API key 不存在' }); + continue; + } + + if (action === 'delete') { + await db.delete(schema.downstreamApiKeys) + .where(eq(schema.downstreamApiKeys.id, id)) + .run(); + } else if (action === 'resetUsage') { + await db.update(schema.downstreamApiKeys).set({ + usedCost: 0, + usedRequests: 0, + updatedAt: new Date().toISOString(), + }).where(eq(schema.downstreamApiKeys.id, id)).run(); + } else if (action === 'updateMetadata') { + const nextGroupName = groupOperation === 'keep' + ? existing.groupName + : (groupOperation === 'clear' ? null : normalizedGroupName); + const nextTags = tagOperation === 'append' + ? Array.from(new Map([...existing.tags, ...normalizedTags].map((tag) => [tag.toLowerCase(), tag])).values()) + : existing.tags; + await db.update(schema.downstreamApiKeys).set({ + groupName: nextGroupName, + tags: toPersistenceJson(nextTags), + updatedAt: new Date().toISOString(), + }).where(eq(schema.downstreamApiKeys.id, id)).run(); + } else { + await db.update(schema.downstreamApiKeys).set({ + enabled: action === 'enable', + updatedAt: new Date().toISOString(), + }).where(eq(schema.downstreamApiKeys.id, id)).run(); + } + + successIds.push(id); + } catch (error: any) { + failedItems.push({ id, message: error?.message || 'Batch operation failed' }); + } + } + + return { + success: true, + successIds, + failedItems, + }; + }); } diff --git a/src/server/routes/api/oauth.registration.test.ts b/src/server/routes/api/oauth.registration.test.ts new file mode 100644 index 0000000..291c44d --- /dev/null +++ b/src/server/routes/api/oauth.registration.test.ts @@ -0,0 +1,32 @@ +import Fastify from 'fastify'; +import { afterEach, describe, expect, it } from 'vitest'; +import { oauthRoutes } from './oauth.js'; +import { isPublicApiRoute } from '../../desktop.js'; + +describe('oauth route registration', () => { + const apps: Array>> = []; + + afterEach(async () => { + await Promise.all(apps.splice(0).map((app) => app.close())); + }); + + it('registers oauth routes on a Fastify instance at runtime', async () => { + const app = Fastify(); + apps.push(app); + await app.register(oauthRoutes); + + const routes = app.printRoutes(); + expect(routes).toContain('providers (GET, HEAD)'); + expect(routes).toContain(':provider'); + expect(routes).toContain('sessions/'); + expect(routes).toContain(':state (GET, HEAD)'); + expect(routes).toContain('onnections (GET, HEAD)'); + expect(routes).toContain('allback/'); + }); + + it('treats oauth callback route as a public desktop API route', () => { + expect(isPublicApiRoute('/api/oauth/callback/codex')).toBe(true); + expect(isPublicApiRoute('/api/oauth/callback/claude')).toBe(true); + expect(isPublicApiRoute('/api/oauth/providers')).toBe(false); + }); +}); diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts new file mode 100644 index 0000000..d00d588 --- /dev/null +++ b/src/server/routes/api/oauth.test.ts @@ -0,0 +1,1133 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const fetchMock = vi.fn(); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +type DbModule = typeof import('../../db/index.js'); + +function buildJwt(payload: Record) { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)) + .toString('base64url'); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`; +} + +describe('oauth routes', { timeout: 15_000 }, () => { + let app: FastifyInstance; + let db: DbModule['db']; + let schema: DbModule['schema']; + let dataDir = ''; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-oauth-routes-')); + process.env.DATA_DIR = dataDir; + + await import('../../db/migrate.js'); + const dbModule = await import('../../db/index.js'); + const routesModule = await import('./oauth.js'); + db = dbModule.db; + schema = dbModule.schema; + + app = Fastify(); + await app.register(routesModule.oauthRoutes); + }); + + beforeEach(async () => { + fetchMock.mockReset(); + await db.delete(schema.routeChannels).run(); + await db.delete(schema.tokenRoutes).run(); + await db.delete(schema.tokenModelAvailability).run(); + await db.delete(schema.modelAvailability).run(); + await db.delete(schema.accountTokens).run(); + await db.delete(schema.accounts).run(); + await db.delete(schema.sites).run(); + }); + + afterAll(async () => { + await app.close(); + delete process.env.DATA_DIR; + }); + + it('lists multi-provider oauth metadata', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/oauth/providers', + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + providers: expect.arrayContaining([ + expect.objectContaining({ + provider: 'codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + }), + expect.objectContaining({ + provider: 'claude', + platform: 'claude', + enabled: true, + loginType: 'oauth', + }), + expect.objectContaining({ + provider: 'gemini-cli', + platform: 'gemini-cli', + enabled: true, + loginType: 'oauth', + requiresProjectId: true, + }), + expect.objectContaining({ + provider: 'antigravity', + platform: 'antigravity', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + }), + ]), + }); + }); + + it('starts an antigravity oauth session and returns provider metadata', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/antigravity/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { + provider: string; + state: string; + authorizationUrl: string; + instructions?: { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + }; + }; + expect(startBody.provider).toBe('antigravity'); + expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/); + expect(startBody.authorizationUrl).toContain('https://accounts.google.com/o/oauth2/v2/auth?'); + expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:51121/oauth-callback')); + expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`); + expect(startBody.instructions).toMatchObject({ + redirectUri: 'http://localhost:51121/oauth-callback', + callbackPort: 51121, + callbackPath: '/oauth-callback', + manualCallbackDelayMs: 15000, + }); + }); + + it('discovers the Antigravity project via onboardUser polling when loadCodeAssist does not return one', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'antigravity-access-token', + refresh_token: 'antigravity-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'cloud-platform', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ email: 'antigravity-user@example.com' }), + text: async () => JSON.stringify({ email: 'antigravity-user@example.com' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + allowedTiers: [ + { id: 'legacy-tier', isDefault: true }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: false, + }), + text: async () => JSON.stringify({ done: false }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: true, + response: { + cloudaicompanionProject: { + id: 'antigravity-auto-project', + }, + }, + }), + text: async () => JSON.stringify({ done: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: { + 'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' }, + }, + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/antigravity/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:51121/oauth-callback?state=${encodeURIComponent(startBody.state)}&code=antigravity-oauth-code-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'antigravity', + status: 'success', + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + oauthProvider: 'antigravity', + oauthProjectId: 'antigravity-auto-project', + username: 'antigravity-user@example.com', + accessToken: 'antigravity-access-token', + }); + + const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + provider: 'antigravity', + email: 'antigravity-user@example.com', + projectId: 'antigravity-auto-project', + refreshToken: 'antigravity-refresh-token', + }); + + expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); + expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); + expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/v1internal:onboardUser'); + }); + + it('starts a codex oauth session and exposes pending status', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { + provider: string; + state: string; + authorizationUrl: string; + instructions?: { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + sshTunnelCommand?: string; + }; + }; + expect(startBody.provider).toBe('codex'); + expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/); + expect(startBody.authorizationUrl).toContain('https://auth.openai.com/oauth/authorize?'); + expect(startBody.authorizationUrl).toContain('client_id='); + expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback')); + expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`); + expect(startBody.authorizationUrl).toContain('code_challenge='); + expect(startBody.instructions).toMatchObject({ + redirectUri: 'http://localhost:1455/auth/callback', + callbackPort: 1455, + callbackPath: '/auth/callback', + manualCallbackDelayMs: 15000, + sshTunnelCommand: 'ssh -L 1455:127.0.0.1:1455 root@metapi.example -p 22', + }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'codex', + state: startBody.state, + status: 'pending', + }); + }); + + it('keeps the codex loopback callback for local origins', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'localhost:4000', + }, + }); + + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { + provider: string; + state: string; + authorizationUrl: string; + }; + expect(startBody.provider).toBe('codex'); + expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:1455/auth/callback')); + expect(startBody.authorizationUrl).not.toContain(encodeURIComponent('http://localhost:4000/api/oauth/callback/codex')); + }); + + it('handles manual codex callback submission, creates oauth-backed account, and discovers plan models', async () => { + const jwt = buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'plus', + }, + }); + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + id_token: jwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: [ + { id: 'gpt-5.2-codex' }, + { id: 'gpt-5' }, + { id: 'gpt-5.4' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + expect(String(fetchMock.mock.calls[0]?.[1]?.body || '')).toContain( + 'redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback', + ); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + const sessionBody = sessionResponse.json() as { + provider: string; + status: string; + accountId?: number; + siteId?: number; + }; + expect(sessionBody).toMatchObject({ + provider: 'codex', + status: 'success', + }); + expect(sessionBody.accountId).toBeTypeOf('number'); + expect(sessionBody.siteId).toBeTypeOf('number'); + + const sites = await db.select().from(schema.sites).all(); + expect(sites).toHaveLength(1); + expect(sites[0]).toMatchObject({ + platform: 'codex', + url: 'https://chatgpt.com/backend-api/codex', + status: 'active', + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + siteId: sites[0]?.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + checkinEnabled: false, + status: 'active', + }); + expect(JSON.parse(accounts[0]?.extraConfig || '{}')).toMatchObject({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + refreshToken: 'oauth-refresh-token', + idToken: jwt, + }, + }); + + const models = await db.select().from(schema.modelAvailability).all(); + const modelNames = models.map((row) => row.modelName); + expect(modelNames.sort()).toEqual(['gpt-5', 'gpt-5.2-codex', 'gpt-5.4']); + }); + + it('marks oauth session as error and avoids creating a connection when manual codex callback model discovery fails', async () => { + const jwt = buildJwt({ + email: 'codex-fail@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-fail', + chatgpt_plan_type: 'team', + }, + }); + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'oauth-access-token', + refresh_token: 'oauth-refresh-token', + id_token: jwt, + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ error: 'forbidden' }), + text: async () => 'forbidden', + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-456`, + }, + }); + expect(callbackResponse.statusCode).toBe(500); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('HTTP 403'), + }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'codex', + status: 'error', + error: expect.stringContaining('HTTP 403'), + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + + const connectionsResponse = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + expect(connectionsResponse.statusCode).toBe(200); + expect(connectionsResponse.json()).toMatchObject({ + items: [], + total: 0, + limit: 50, + offset: 0, + }); + }); + + it('marks gemini oauth session as error when token exchange fails before account persistence', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + error: 'invalid_grant', + error_description: 'Bad Request', + }), + text: async () => '{"error":"invalid_grant","error_description":"Bad Request"}', + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/gemini-cli/start', + payload: { + projectId: 'demo-project', + }, + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-gemini-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(500); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('invalid_grant'), + }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'gemini-cli', + status: 'error', + error: expect.stringContaining('invalid_grant'), + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + }); + + it('defaults Gemini CLI oauth to the first available Google Cloud project when projectId is omitted', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'gemini-access-token', + refresh_token: 'gemini-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'cloud-platform', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + projects: [ + { projectId: 'first-project-id' }, + { projectId: 'second-project-id' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + cloudaicompanionProject: { + id: 'first-project-id', + }, + allowedTiers: [ + { id: 'legacy-tier', isDefault: true }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: true, + response: { + cloudaicompanionProject: { + id: 'first-project-id', + }, + }, + }), + text: async () => JSON.stringify({ done: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'ENABLED' }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ email: 'gemini-user@example.com' }), + text: async () => JSON.stringify({ email: 'gemini-user@example.com' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'ENABLED' }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/gemini-cli/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + + const sessionResponse = await app.inject({ + method: 'GET', + url: `/api/oauth/sessions/${startBody.state}`, + }); + expect(sessionResponse.statusCode).toBe(200); + expect(sessionResponse.json()).toMatchObject({ + provider: 'gemini-cli', + status: 'success', + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + oauthProvider: 'gemini-cli', + oauthProjectId: 'first-project-id', + username: 'gemini-user@example.com', + accessToken: 'gemini-access-token', + }); + + const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + provider: 'gemini-cli', + email: 'gemini-user@example.com', + projectId: 'first-project-id', + refreshToken: 'gemini-refresh-token', + }); + + expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('cloudresourcemanager.googleapis.com/v1/projects'); + expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); + expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); + expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); + expect(String(fetchMock.mock.calls[6]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); + }); + + it('onboards Gemini CLI into the backend project and auto-enables Cloud AI API when Google returns a free-tier project remap', async () => { + fetchMock + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'gemini-access-token', + refresh_token: 'gemini-refresh-token', + expires_in: 3600, + token_type: 'Bearer', + scope: 'cloud-platform', + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + projects: [ + { projectId: 'gen-lang-client-source-project' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + allowedTiers: [ + { id: 'legacy-tier', isDefault: true }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + done: true, + response: { + cloudaicompanionProject: { + id: 'gen-lang-client-0123456789', + }, + }, + }), + text: async () => JSON.stringify({ done: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'DISABLED' }), + text: async () => JSON.stringify({ state: 'DISABLED' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ ok: true }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ email: 'gemini-free-user@example.com' }), + text: async () => JSON.stringify({ email: 'gemini-free-user@example.com' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'ENABLED' }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/gemini-cli/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-free-tier`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + oauthProvider: 'gemini-cli', + oauthProjectId: 'gen-lang-client-0123456789', + username: 'gemini-free-user@example.com', + }); + + const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + projectId: 'gen-lang-client-0123456789', + }); + + expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/v1internal:loadCodeAssist'); + expect(String(fetchMock.mock.calls[3]?.[0] || '')).toContain('/v1internal:onboardUser'); + expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com'); + expect(String(fetchMock.mock.calls[5]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com:enable'); + expect(String(fetchMock.mock.calls[7]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com'); + }); + + it('lists oauth connection health metadata and supports deleting the connection', async () => { + const site = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-123', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + idToken: buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'team', + chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z', + chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z', + }, + }), + quota: { + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: '2026-03-17T08:00:00.000Z', + lastLimitResetAt: '2026-03-17T13:00:00.000Z', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }, + modelDiscoveryStatus: 'abnormal', + lastModelSyncAt: '2026-03-17T08:00:00.000Z', + lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + checkedAt: '2026-03-17T08:00:00.000Z', + }).run(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2-codex', + enabled: true, + }).returning().get(); + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).run(); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + + expect(listResponse.statusCode).toBe(200); + expect(listResponse.json()).toMatchObject({ + items: expect.arrayContaining([ + expect.objectContaining({ + accountId: account.id, + provider: 'codex', + email: 'codex-user@example.com', + status: 'abnormal', + quota: expect.objectContaining({ + status: 'supported', + source: 'reverse_engineered', + lastLimitResetAt: '2026-03-17T13:00:00.000Z', + subscription: expect.objectContaining({ + planType: 'team', + activeStart: '2026-03-01T00:00:00.000Z', + activeUntil: '2026-04-01T00:00:00.000Z', + }), + }), + routeChannelCount: 1, + lastModelSyncAt: '2026-03-17T08:00:00.000Z', + lastModelSyncError: 'Codex 模型获取失败(HTTP 403: forbidden)', + }), + ]), + total: 1, + limit: 50, + offset: 0, + }); + + const deleteResponse = await app.inject({ + method: 'DELETE', + url: `/api/oauth/connections/${account.id}`, + }); + expect(deleteResponse.statusCode).toBe(200); + expect(deleteResponse.json()).toEqual({ success: true }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + }); + + it('refreshes oauth quota snapshots and marks unsupported providers explicitly', async () => { + const codexSite = await db.insert(schema.sites).values({ + name: 'ChatGPT Codex OAuth', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + const antigravitySite = await db.insert(schema.sites).values({ + name: 'Antigravity OAuth', + url: 'https://example.com/antigravity', + platform: 'antigravity', + status: 'active', + }).returning().get(); + + const codexAccount = await db.insert(schema.accounts).values({ + siteId: codexSite.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + status: 'active', + oauthProvider: 'codex', + oauthAccountKey: 'chatgpt-account-123', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + idToken: buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'plus', + chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z', + chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z', + }, + }), + }, + }), + }).returning().get(); + + const antigravityAccount = await db.insert(schema.accounts).values({ + siteId: antigravitySite.id, + username: 'ag-user@example.com', + accessToken: 'oauth-access-token', + status: 'active', + oauthProvider: 'antigravity', + oauthAccountKey: 'ag-account-123', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'antigravity', + accountId: 'ag-account-123', + email: 'ag-user@example.com', + planType: 'pro', + }, + }), + }).returning().get(); + + const codexRefresh = await app.inject({ + method: 'POST', + url: `/api/oauth/connections/${codexAccount.id}/quota/refresh`, + }); + expect(codexRefresh.statusCode).toBe(200); + expect(codexRefresh.json()).toMatchObject({ + success: true, + quota: expect.objectContaining({ + status: 'supported', + subscription: expect.objectContaining({ + planType: 'plus', + activeStart: '2026-03-01T00:00:00.000Z', + activeUntil: '2026-04-01T00:00:00.000Z', + }), + }), + }); + + const antigravityRefresh = await app.inject({ + method: 'POST', + url: `/api/oauth/connections/${antigravityAccount.id}/quota/refresh`, + }); + expect(antigravityRefresh.statusCode).toBe(200); + expect(antigravityRefresh.json()).toMatchObject({ + success: true, + quota: expect.objectContaining({ + status: 'unsupported', + providerMessage: 'official quota windows are not exposed for antigravity oauth', + }), + }); + }); + + it('keeps multiple codex team workspaces with the same email as separate oauth connections', async () => { + const buildTokenExchange = (accountId: string) => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: `oauth-access-token-${accountId}`, + refresh_token: `oauth-refresh-token-${accountId}`, + id_token: buildJwt({ + email: 'team-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: accountId, + chatgpt_plan_type: 'team', + }, + }), + expires_in: 3600, + token_type: 'Bearer', + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const buildModelDiscovery = (modelId: string) => ({ + ok: true, + status: 200, + json: async () => ({ + models: [{ id: modelId }], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + fetchMock + .mockResolvedValueOnce(buildTokenExchange('chatgpt-team-account-a')) + .mockResolvedValueOnce(buildModelDiscovery('gpt-5.4')) + .mockResolvedValueOnce(buildTokenExchange('chatgpt-team-account-b')) + .mockResolvedValueOnce(buildModelDiscovery('gpt-5.4')); + + const startFirstResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const firstSession = startFirstResponse.json() as { state: string }; + + const submitFirstCallback = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(firstSession.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(firstSession.state)}&code=oauth-code-team-a`, + }, + }); + expect(submitFirstCallback.statusCode).toBe(200); + + const startSecondResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/codex/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const secondSession = startSecondResponse.json() as { state: string }; + + const submitSecondCallback = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(secondSession.state)}/manual-callback`, + payload: { + callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(secondSession.state)}&code=oauth-code-team-b`, + }, + }); + expect(submitSecondCallback.statusCode).toBe(200); + + const accounts = await db.select().from(schema.accounts) + .where(eq(schema.accounts.oauthProvider, 'codex')) + .orderBy(schema.accounts.id) + .all(); + + expect(accounts).toHaveLength(2); + expect(accounts.map((account) => account.oauthAccountKey)).toEqual([ + 'chatgpt-team-account-a', + 'chatgpt-team-account-b', + ]); + + const connectionsResponse = await app.inject({ + method: 'GET', + url: '/api/oauth/connections', + }); + + expect(connectionsResponse.statusCode).toBe(200); + expect(connectionsResponse.json()).toMatchObject({ + total: 2, + items: expect.arrayContaining([ + expect.objectContaining({ + provider: 'codex', + email: 'team-user@example.com', + accountKey: 'chatgpt-team-account-a', + }), + expect.objectContaining({ + provider: 'codex', + email: 'team-user@example.com', + accountKey: 'chatgpt-team-account-b', + }), + ]), + }); + }); + + it('rejects malformed manual callback submissions', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/claude/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + const startBody = startResponse.json() as { state: string }; + + const callbackResponse = await app.inject({ + method: 'POST', + url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`, + payload: { + callbackUrl: 'not-a-valid-url', + }, + }); + + expect(callbackResponse.statusCode).toBe(400); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('invalid oauth callback url'), + }); + }); +}); diff --git a/src/server/routes/api/oauth.ts b/src/server/routes/api/oauth.ts new file mode 100644 index 0000000..32976eb --- /dev/null +++ b/src/server/routes/api/oauth.ts @@ -0,0 +1,275 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; +import { + deleteOauthConnection, + getOauthSessionStatus, + handleOauthCallback, + listOauthConnections, + listOauthProviders, + refreshOauthConnectionQuota, + startOauthProviderFlow, + startOauthRebindFlow, + submitOauthManualCallback, +} from '../../services/oauth/service.js'; + +const limitOauthProviderRead = createRateLimitGuard({ + bucket: 'oauth-provider-read', + max: 60, + windowMs: 60_000, +}); + +const limitOauthStart = createRateLimitGuard({ + bucket: 'oauth-start', + max: 20, + windowMs: 60_000, +}); + +const limitOauthSessionRead = createRateLimitGuard({ + bucket: 'oauth-session-read', + max: 120, + windowMs: 60_000, +}); + +const limitOauthSessionMutate = createRateLimitGuard({ + bucket: 'oauth-session-mutate', + max: 30, + windowMs: 60_000, +}); + +const limitOauthConnectionRead = createRateLimitGuard({ + bucket: 'oauth-connection-read', + max: 60, + windowMs: 60_000, +}); + +const limitOauthConnectionMutate = createRateLimitGuard({ + bucket: 'oauth-connection-mutate', + max: 20, + windowMs: 60_000, +}); + +const limitOauthCallback = createRateLimitGuard({ + bucket: 'oauth-callback', + max: 30, + windowMs: 60_000, +}); + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function renderCallbackPage(message: string): string { + return ` + + + + OAuth Callback + + + + ${escapeHtml(message)} + +`; +} + +function parsePositiveInteger(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value; + if (typeof value !== 'string') return null; + const parsed = Number.parseInt(value.trim(), 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function parseOptionalProjectId(value: unknown): string | undefined | null { + if (value === undefined || value === null) return undefined; + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function resolveRequestOrigin(request: FastifyRequest): string | undefined { + const forwardedProto = typeof request.headers['x-forwarded-proto'] === 'string' + ? request.headers['x-forwarded-proto'].split(',')[0]?.trim() + : ''; + const protocol = forwardedProto || request.protocol || 'http'; + const forwardedHost = typeof request.headers['x-forwarded-host'] === 'string' + ? request.headers['x-forwarded-host'].split(',')[0]?.trim() + : ''; + const host = forwardedHost + || (typeof request.headers.host === 'string' ? request.headers.host.trim() : ''); + if (!host) return undefined; + return `${protocol}://${host}`; +} + +export async function oauthRoutes(app: FastifyInstance) { + app.get('/api/oauth/providers', { preHandler: [limitOauthProviderRead] }, async () => ({ + providers: listOauthProviders(), + })); + + app.post<{ Params: { provider: string }; Body: { accountId?: number; projectId?: string } }>( + '/api/oauth/providers/:provider/start', + { preHandler: [limitOauthStart] }, + async (request, reply) => { + const rebindAccountId = request.body?.accountId === undefined + ? undefined + : parsePositiveInteger(request.body.accountId); + if (request.body?.accountId !== undefined && rebindAccountId === null) { + return reply.code(400).send({ message: 'invalid account id' }); + } + const projectId = parseOptionalProjectId(request.body?.projectId); + if (request.body?.projectId !== undefined && projectId === null) { + return reply.code(400).send({ message: 'invalid project id' }); + } + + try { + return await startOauthProviderFlow({ + provider: request.params.provider, + rebindAccountId: rebindAccountId ?? undefined, + projectId: projectId ?? undefined, + requestOrigin: resolveRequestOrigin(request), + }); + } catch (error: any) { + return reply.code(404).send({ message: error?.message || 'oauth provider not found' }); + } + }, + ); + + app.get<{ Params: { state: string } }>( + '/api/oauth/sessions/:state', + { preHandler: [limitOauthSessionRead] }, + async (request, reply) => { + const session = getOauthSessionStatus(request.params.state); + if (!session) { + return reply.code(404).send({ message: 'oauth session not found' }); + } + return session; + }, + ); + + app.post<{ Params: { state: string }; Body: { callbackUrl?: string } }>( + '/api/oauth/sessions/:state/manual-callback', + { preHandler: [limitOauthSessionMutate] }, + async (request, reply) => { + const callbackUrl = typeof request.body?.callbackUrl === 'string' + ? request.body.callbackUrl.trim() + : ''; + if (!callbackUrl) { + return reply.code(400).send({ message: 'invalid oauth callback url' }); + } + try { + return await submitOauthManualCallback({ + state: request.params.state, + callbackUrl, + }); + } catch (error: any) { + const message = error?.message || 'oauth callback submission failed'; + if (message === 'invalid oauth callback url' || message === 'oauth callback state mismatch') { + return reply.code(400).send({ message }); + } + if (message === 'oauth session not found') { + return reply.code(404).send({ message }); + } + return reply.code(500).send({ message }); + } + }, + ); + + app.get<{ Querystring: { limit?: string; offset?: string } }>( + '/api/oauth/connections', + { preHandler: [limitOauthConnectionRead] }, + async (request, reply) => { + const limit = request.query.limit === undefined ? undefined : parsePositiveInteger(request.query.limit); + const offset = request.query.offset === undefined + ? undefined + : (() => { + if (typeof request.query.offset !== 'string') return null; + const parsed = Number.parseInt(request.query.offset.trim(), 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; + })(); + if (request.query.limit !== undefined && limit === null) { + return reply.code(400).send({ message: 'invalid limit' }); + } + if (request.query.offset !== undefined && offset === null) { + return reply.code(400).send({ message: 'invalid offset' }); + } + return listOauthConnections({ + limit: limit ?? undefined, + offset: offset ?? undefined, + }); + }, + ); + + app.post<{ Params: { accountId: string } }>( + '/api/oauth/connections/:accountId/rebind', + { preHandler: [limitOauthConnectionMutate] }, + async (request, reply) => { + const accountId = parsePositiveInteger(request.params.accountId); + if (accountId === null) { + return reply.code(400).send({ message: 'invalid account id' }); + } + try { + return await startOauthRebindFlow(accountId, resolveRequestOrigin(request)); + } catch (error: any) { + return reply.code(404).send({ message: error?.message || 'oauth account not found' }); + } + }, + ); + + app.delete<{ Params: { accountId: string } }>( + '/api/oauth/connections/:accountId', + { preHandler: [limitOauthConnectionMutate] }, + async (request, reply) => { + const accountId = parsePositiveInteger(request.params.accountId); + if (accountId === null) { + return reply.code(400).send({ message: 'invalid account id' }); + } + try { + return await deleteOauthConnection(accountId); + } catch (error: any) { + return reply.code(404).send({ message: error?.message || 'oauth account not found' }); + } + }, + ); + + app.post<{ Params: { accountId: string } }>( + '/api/oauth/connections/:accountId/quota/refresh', + { preHandler: [limitOauthConnectionMutate] }, + async (request, reply) => { + const accountId = parsePositiveInteger(request.params.accountId); + if (accountId === null) { + return reply.code(400).send({ message: 'invalid account id' }); + } + try { + return await refreshOauthConnectionQuota(accountId); + } catch (error: any) { + return reply.code(404).send({ message: error?.message || 'oauth account not found' }); + } + }, + ); + + app.get<{ Params: { provider: string }; Querystring: { state?: string; code?: string; error?: string } }>( + '/api/oauth/callback/:provider', + { preHandler: [limitOauthCallback] }, + async (request, reply) => { + let message = 'OAuth callback received.'; + try { + await handleOauthCallback({ + provider: request.params.provider, + state: String(request.query.state || ''), + code: request.query.code, + error: request.query.error, + }); + message = 'OAuth authorization succeeded. You can close this window.'; + } catch { + message = 'OAuth authorization failed. Return to metapi and review the server logs.'; + } + + reply.type('text/html; charset=utf-8'); + return renderCallbackPage(message); + }, + ); +} diff --git a/src/server/routes/api/search.route.test.ts b/src/server/routes/api/search.route.test.ts index f467b0c..99afc2e 100644 --- a/src/server/routes/api/search.route.test.ts +++ b/src/server/routes/api/search.route.test.ts @@ -137,4 +137,57 @@ describe('search routes', () => { ], }); }); + + it('includes oauth direct-account model availability in model search results', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: mergeAccountExtraConfig(null, { + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + }).run(); + + const response = await app.inject({ + method: 'POST', + url: '/api/search', + payload: { + query: 'gpt-5.2', + limit: 20, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ + models: [ + expect.objectContaining({ + name: 'gpt-5.2-codex', + accountCount: 1, + tokenCount: 0, + siteCount: 1, + }), + ], + }); + }); }); diff --git a/src/server/routes/api/search.ts b/src/server/routes/api/search.ts index 6725ae4..674bc9c 100644 --- a/src/server/routes/api/search.ts +++ b/src/server/routes/api/search.ts @@ -2,7 +2,8 @@ import { FastifyInstance } from 'fastify'; import { db, schema } from '../../db/index.js'; import { and, like, desc, eq, or } from 'drizzle-orm'; import { getProxyLogBaseSelectFields } from '../../services/proxyLogStore.js'; -import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { getCredentialModeFromExtraConfig, supportsDirectAccountRoutingConnection } from '../../services/accountExtraConfig.js'; +import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js'; function hasSessionTokenValue(value: string | null | undefined): boolean { return typeof value === 'string' && value.trim().length > 0; @@ -134,11 +135,31 @@ export async function searchRoutes(app: FastifyInstance) { like(schema.tokenModelAvailability.modelName, q), eq(schema.tokenModelAvailability.available, true), eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), eq(schema.accounts.status, 'active'), ), ) .limit(perCategory * 20) .all(); + const directAccountModelRows = await db.select({ + modelName: schema.modelAvailability.modelName, + accountId: schema.accounts.id, + siteId: schema.sites.id, + accounts: schema.accounts, + }) + .from(schema.modelAvailability) + .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where( + and( + like(schema.modelAvailability.modelName, q), + eq(schema.modelAvailability.available, true), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + ), + ) + .limit(perCategory * 20) + .all(); const modelAgg = new Map; accountIds: Set; siteIds: Set }>(); for (const row of modelRows) { @@ -151,6 +172,16 @@ export async function searchRoutes(app: FastifyInstance) { agg.accountIds.add(row.accountId); agg.siteIds.add(row.siteId); } + for (const row of directAccountModelRows) { + if (!supportsDirectAccountRoutingConnection(row.accounts)) continue; + const key = row.modelName; + if (!modelAgg.has(key)) { + modelAgg.set(key, { tokenIds: new Set(), accountIds: new Set(), siteIds: new Set() }); + } + const agg = modelAgg.get(key)!; + agg.accountIds.add(row.accountId); + agg.siteIds.add(row.siteId); + } const models = Array.from(modelAgg.entries()) .map(([name, agg]) => ({ diff --git a/src/server/routes/api/stats.proxy-logs.test.ts b/src/server/routes/api/stats.proxy-logs.test.ts index 3fe554a..760f1ef 100644 --- a/src/server/routes/api/stats.proxy-logs.test.ts +++ b/src/server/routes/api/stats.proxy-logs.test.ts @@ -29,6 +29,7 @@ describe('stats proxy logs routes', () => { beforeEach(async () => { await db.delete(schema.proxyLogs).run(); + await db.delete(schema.downstreamApiKeys).run(); await db.delete(schema.accounts).run(); await db.delete(schema.sites).run(); }); @@ -52,6 +53,14 @@ describe('stats proxy logs routes', () => { status: 'active', }).returning().get(); + const downstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: '项目A-Key', + key: 'sk-project-a-001', + groupName: '项目A', + tags: JSON.stringify(['VIP', '灰度']), + enabled: true, + }).returning().get(); + const timestamps = [ formatUtcSqlDateTime(new Date('2026-03-09T08:00:00.000Z')), formatUtcSqlDateTime(new Date('2026-03-09T08:01:00.000Z')), @@ -62,6 +71,7 @@ describe('stats proxy logs routes', () => { await db.insert(schema.proxyLogs).values([ { accountId: account.id, + downstreamApiKeyId: downstreamKey.id, modelRequested: 'gpt-4o', modelActual: 'gpt-4o', status: 'success', @@ -74,6 +84,7 @@ describe('stats proxy logs routes', () => { }, { accountId: account.id, + downstreamApiKeyId: downstreamKey.id, modelRequested: 'gpt-4o-mini', modelActual: 'gpt-4o-mini', status: 'failed', @@ -136,6 +147,9 @@ describe('stats proxy logs routes', () => { expect(body.items).toHaveLength(1); expect(body.items[0]?.modelRequested).toBe('gpt-4o-mini'); expect(body.items[0]?.status).toBe('failed'); + expect(body.items[0]?.downstreamKeyName).toBe('项目A-Key'); + expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A'); + expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']); expect(body.items[0]).not.toHaveProperty('billingDetails'); expect(body.summary).toEqual({ totalCount: 3, @@ -160,8 +174,17 @@ describe('stats proxy logs routes', () => { status: 'active', }).returning().get(); + const downstreamKey = await db.insert(schema.downstreamApiKeys).values({ + name: 'detail-key', + key: 'sk-detail-key-001', + groupName: '测试项目', + tags: JSON.stringify(['回归', '日志']), + enabled: true, + }).returning().get(); + const inserted = await db.insert(schema.proxyLogs).values({ accountId: account.id, + downstreamApiKeyId: downstreamKey.id, modelRequested: 'gpt-5', modelActual: 'gpt-5', status: 'success', @@ -188,18 +211,93 @@ describe('stats proxy logs routes', () => { id: number; siteName: string | null; username: string | null; + downstreamKeyName: string | null; + downstreamKeyGroupName: string | null; + downstreamKeyTags: string[]; billingDetails: Record | null; }; expect(body.id).toBe(logId); expect(body.siteName).toBe('detail-site'); expect(body.username).toBe('detail-user'); + expect(body.downstreamKeyName).toBe('detail-key'); + expect(body.downstreamKeyGroupName).toBe('测试项目'); + expect(body.downstreamKeyTags).toEqual(['回归', '日志']); expect(body.billingDetails).toMatchObject({ breakdown: { totalCost: 0.12 }, usage: { promptTokens: 100, completionTokens: 20 }, }); }); + it('supports searching proxy logs by downstream key metadata', async () => { + const site = await db.insert(schema.sites).values({ + name: 'meta-site', + url: 'https://meta.example.com', + platform: 'new-api', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'meta-user', + accessToken: 'meta-token', + status: 'active', + }).returning().get(); + + const alphaKey = await db.insert(schema.downstreamApiKeys).values({ + name: '渠道-A', + key: 'sk-channel-a', + groupName: '项目甲', + tags: JSON.stringify(['商务', 'VIP']), + enabled: true, + }).returning().get(); + + const betaKey = await db.insert(schema.downstreamApiKeys).values({ + name: '渠道-B', + key: 'sk-channel-b', + groupName: '项目乙', + tags: JSON.stringify(['灰度']), + enabled: true, + }).returning().get(); + + await db.insert(schema.proxyLogs).values([ + { + accountId: account.id, + downstreamApiKeyId: alphaKey.id, + modelRequested: 'gpt-4o', + modelActual: 'gpt-4o', + status: 'success', + totalTokens: 12, + estimatedCost: 0.12, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:00:00.000Z')), + }, + { + accountId: account.id, + downstreamApiKeyId: betaKey.id, + modelRequested: 'gpt-4.1-mini', + modelActual: 'gpt-4.1-mini', + status: 'success', + totalTokens: 22, + estimatedCost: 0.22, + createdAt: formatUtcSqlDateTime(new Date('2026-03-09T10:05:00.000Z')), + }, + ]).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/stats/proxy-logs?search=%E9%A1%B9%E7%9B%AE%E7%94%B2', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + total: number; + items: Array>; + }; + + expect(body.total).toBe(1); + expect(body.items[0]?.downstreamKeyName).toBe('渠道-A'); + expect(body.items[0]?.downstreamKeyGroupName).toBe('项目甲'); + }); + it('filters proxy logs by site and time range', async () => { const alphaSite = await db.insert(schema.sites).values({ name: 'alpha-site', diff --git a/src/server/routes/api/stats.token-candidates.test.ts b/src/server/routes/api/stats.token-candidates.test.ts index e64c9a1..7b21763 100644 --- a/src/server/routes/api/stats.token-candidates.test.ts +++ b/src/server/routes/api/stats.token-candidates.test.ts @@ -157,6 +157,52 @@ describe('/api/models/token-candidates', () => { expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined(); }); + it('does not report oauth direct connections as missing account tokens', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/api/models/token-candidates', + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + modelsWithoutToken: Record>; + modelsMissingTokenGroups: Record>; + }; + + expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined(); + expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined(); + }); + it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => { const site = await db.insert(schema.sites).values({ name: 'site-b', diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 3b11cb7..d37c8ed 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -17,7 +17,8 @@ import { parseProxyLogBillingDetails, withProxyLogSelectFields, } from '../../services/proxyLogStore.js'; -import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js'; +import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js'; +import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js'; import { formatLocalDateTime, formatUtcSqlDateTime, @@ -26,6 +27,7 @@ import { parseStoredUtcDateTime, toLocalDayKeyFromStoredUtc, } from '../../services/localTimeService.js'; +import { createRateLimitGuard } from '../../middleware/requestRateLimit.js'; function parseBooleanFlag(raw?: string): boolean { if (!raw) return false; @@ -33,14 +35,13 @@ function parseBooleanFlag(raw?: string): boolean { return normalized === '1' || normalized === 'true' || normalized === 'yes'; } -function isApiKeyConnection(account: { accessToken?: string | null; extraConfig?: string | null }): boolean { - const explicit = getCredentialModeFromExtraConfig(account.extraConfig); - if (explicit && explicit !== 'auto') return explicit === 'apikey'; - return !(account.accessToken || '').trim(); -} - const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000; const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000; +const limitModelTokenCandidatesRead = createRateLimitGuard({ + bucket: 'models-token-candidates-read', + max: 30, + windowMs: 60_000, +}); type ModelsMarketplaceCacheEntry = { expiresAt: number; @@ -121,12 +122,36 @@ function normalizeProxyLogTimeBoundary(raw?: string): string | null { return formatUtcSqlDateTime(parsed); } +function parseDownstreamKeyTags(raw: unknown): string[] { + if (typeof raw !== 'string' || !raw.trim()) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const seen = new Set(); + const result: string[] = []; + for (const value of parsed) { + const text = String(value || '').trim(); + if (!text) continue; + const dedupeKey = text.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + result.push(text); + } + return result; + } catch { + return []; + } +} + function buildProxyLogSearchCondition(search: string) { if (!search) return null; const likeTerm = `%${search}%`; return sql`( lower(coalesce(${schema.proxyLogs.modelRequested}, '')) like ${likeTerm} or lower(coalesce(${schema.proxyLogs.modelActual}, '')) like ${likeTerm} + or lower(coalesce(${schema.downstreamApiKeys.name}, '')) like ${likeTerm} + or lower(coalesce(${schema.downstreamApiKeys.groupName}, '')) like ${likeTerm} + or lower(coalesce(${schema.downstreamApiKeys.tags}, '')) like ${likeTerm} )`; } @@ -316,6 +341,12 @@ function mapProxyLogRow( proxy_logs: Record & { billingDetails?: string | null }; accounts: { username?: string | null } | null; sites: { id?: number | null; name?: string | null; url?: string | null } | null; + downstream_api_keys: { + id?: number | null; + name?: string | null; + groupName?: string | null; + tags?: string | null; + } | null; }, options?: { includeBillingDetails?: boolean }, ) { @@ -328,6 +359,10 @@ function mapProxyLogRow( siteId: row.sites?.id || null, siteName: row.sites?.name || null, siteUrl: row.sites?.url || null, + downstreamKeyId: row.downstream_api_keys?.id || null, + downstreamKeyName: row.downstream_api_keys?.name || null, + downstreamKeyGroupName: row.downstream_api_keys?.groupName || null, + downstreamKeyTags: parseDownstreamKeyTags(row.downstream_api_keys?.tags), }; } @@ -530,9 +565,16 @@ export async function statsRoutes(app: FastifyInstance) { proxy_logs: fields, accounts: schema.accounts, sites: schema.sites, + downstream_api_keys: { + id: schema.downstreamApiKeys.id, + name: schema.downstreamApiKeys.name, + groupName: schema.downstreamApiKeys.groupName, + tags: schema.downstreamApiKeys.tags, + }, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) - .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)); + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)); if (listWhere) { query = query.where(listWhere) as typeof query; @@ -547,13 +589,15 @@ export async function statsRoutes(app: FastifyInstance) { proxy_logs: Record & { billingDetails?: string | null }; accounts: { username?: string | null } | null; sites: { id?: number | null; name?: string | null; url?: string | null } | null; + downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null; }>; let totalQuery = db.select({ total: sql`count(*)`, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) - .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)); + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)); if (listWhere) { totalQuery = totalQuery.where(listWhere) as typeof totalQuery; } @@ -567,7 +611,8 @@ export async function statsRoutes(app: FastifyInstance) { totalTokensAll: sql`coalesce(sum(coalesce(${schema.proxyLogs.totalTokens}, 0)), 0)`, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) - .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)); + .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)); if (summaryWhere) { summaryQuery = summaryQuery.where(summaryWhere) as typeof summaryQuery; } @@ -599,15 +644,23 @@ export async function statsRoutes(app: FastifyInstance) { proxy_logs: fields, accounts: schema.accounts, sites: schema.sites, + downstream_api_keys: { + id: schema.downstreamApiKeys.id, + name: schema.downstreamApiKeys.name, + groupName: schema.downstreamApiKeys.groupName, + tags: schema.downstreamApiKeys.tags, + }, }).from(schema.proxyLogs) .leftJoin(schema.accounts, eq(schema.proxyLogs.accountId, schema.accounts.id)) .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.downstreamApiKeys, eq(schema.proxyLogs.downstreamApiKeyId, schema.downstreamApiKeys.id)) .where(eq(schema.proxyLogs.id, id)) .get() ), { includeBillingDetails: true }) as { proxy_logs: Record & { billingDetails?: string | null }; accounts: { username?: string | null } | null; sites: { id?: number | null; name?: string | null; url?: string | null } | null; + downstream_api_keys: { id?: number | null; name?: string | null; groupName?: string | null; tags?: string | null } | null; } | undefined; if (!row) { @@ -672,6 +725,15 @@ export async function statsRoutes(app: FastifyInstance) { .innerJoin(schema.accountTokens, eq(schema.tokenModelAvailability.tokenId, schema.accountTokens.id)) .innerJoin(schema.accounts, eq(schema.accountTokens.accountId, schema.accounts.id)) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where( + and( + eq(schema.tokenModelAvailability.available, true), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + ), + ) .all(); const accountAvailability = await db.select().from(schema.modelAvailability) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) @@ -914,7 +976,7 @@ export async function statsRoutes(app: FastifyInstance) { }; }); - app.get('/api/models/token-candidates', async () => { + app.get('/api/models/token-candidates', { preHandler: [limitModelTokenCandidatesRead] }, async () => { const resolveTokenGroupLabel = (tokenGroup: string | null, tokenName: string | null): string | null => { const explicit = (tokenGroup || '').trim(); if (explicit) return explicit; @@ -937,6 +999,7 @@ export async function statsRoutes(app: FastifyInstance) { and( eq(schema.tokenModelAvailability.available, true), eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), eq(schema.accounts.status, 'active'), eq(schema.sites.status, 'active'), ), @@ -949,6 +1012,7 @@ export async function statsRoutes(app: FastifyInstance) { siteId: schema.sites.id, siteName: schema.sites.name, accessToken: schema.accounts.accessToken, + apiToken: schema.accounts.apiToken, extraConfig: schema.accounts.extraConfig, }) .from(schema.modelAvailability) @@ -1027,7 +1091,7 @@ export async function statsRoutes(app: FastifyInstance) { } for (const row of availableModelRows) { - if (isApiKeyConnection(row)) continue; + if (!requiresManagedAccountTokens(row)) continue; const modelName = (row.modelName || '').trim(); if (!modelName) continue; const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`; @@ -1044,7 +1108,7 @@ export async function statsRoutes(app: FastifyInstance) { const accountIdsForGroupHints = new Set( availableModelRows - .filter((row) => !isApiKeyConnection(row)) + .filter((row) => requiresManagedAccountTokens(row)) .map((row) => row.accountId), ); const requiredGroupsByAccountModel = new Map>(); @@ -1106,7 +1170,7 @@ export async function statsRoutes(app: FastifyInstance) { } for (const row of availableModelRows) { - if (isApiKeyConnection(row)) continue; + if (!requiresManagedAccountTokens(row)) continue; const modelName = (row.modelName || '').trim(); if (!modelName) continue; const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`; diff --git a/src/server/routes/api/tokens.route-update-rebuild.test.ts b/src/server/routes/api/tokens.route-update-rebuild.test.ts index ea25501..d3479be 100644 --- a/src/server/routes/api/tokens.route-update-rebuild.test.ts +++ b/src/server/routes/api/tokens.route-update-rebuild.test.ts @@ -149,4 +149,180 @@ describe('PUT /api/routes/:id route rebuild', () => { expect(rebuiltAuto?.priority).toBe(0); expect(rebuiltAuto?.weight).toBe(10); }); + + it('creates explicit-group routes with sourceRouteIds and aggregates source channels', async () => { + const sourceA = await seedAccountWithToken('claude-opus-4-5'); + const sourceB = await seedAccountWithToken('claude-sonnet-4-5'); + + const exactRouteA = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + }).returning().get(); + const exactRouteB = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-sonnet-4-5', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: exactRouteA.id, + accountId: sourceA.account.id, + tokenId: sourceA.token.id, + sourceModel: 'claude-opus-4-5', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + { + routeId: exactRouteB.id, + accountId: sourceB.account.id, + tokenId: sourceB.token.id, + sourceModel: 'claude-sonnet-4-5', + priority: 1, + weight: 8, + enabled: true, + manualOverride: false, + }, + ]).run(); + + const createResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + routingStrategy: 'weighted', + }, + }); + + expect(createResponse.statusCode).toBe(200); + expect(createResponse.json()).toMatchObject({ + displayName: 'claude-opus-4-6', + routeMode: 'explicit_group', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + }); + + const createdRouteId = (createResponse.json() as { id: number }).id; + + const storedChannels = await db.select().from(schema.routeChannels) + .where(eq(schema.routeChannels.routeId, createdRouteId)) + .all(); + expect(storedChannels).toHaveLength(0); + + const summaryResponse = await app.inject({ + method: 'GET', + url: '/api/routes/summary', + }); + expect(summaryResponse.statusCode).toBe(200); + expect(summaryResponse.json()).toContainEqual(expect.objectContaining({ + id: createdRouteId, + routeMode: 'explicit_group', + sourceRouteIds: [exactRouteA.id, exactRouteB.id], + channelCount: 2, + enabledChannelCount: 2, + siteNames: expect.arrayContaining([sourceA.site.name, sourceB.site.name]), + })); + + const channelsResponse = await app.inject({ + method: 'GET', + url: `/api/routes/${createdRouteId}/channels`, + }); + expect(channelsResponse.statusCode).toBe(200); + expect(channelsResponse.json()).toEqual(expect.arrayContaining([ + expect.objectContaining({ + routeId: exactRouteA.id, + accountId: sourceA.account.id, + sourceModel: 'claude-opus-4-5', + }), + expect.objectContaining({ + routeId: exactRouteB.id, + accountId: sourceB.account.id, + sourceModel: 'claude-sonnet-4-5', + }), + ])); + }); + + it('rejects invalid explicit-group payloads', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: '', + sourceRouteIds: [], + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ + success: false, + }); + }); + + it('prefers an exact route over a colliding explicit-group display name', async () => { + const exactCandidate = await seedAccountWithToken('claude-opus-4-6'); + const groupedCandidate = await seedAccountWithToken('claude-opus-4-5'); + + const exactRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-6', + enabled: true, + }).returning().get(); + const sourceRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: exactRoute.id, + accountId: exactCandidate.account.id, + tokenId: exactCandidate.token.id, + sourceModel: 'claude-opus-4-6', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + { + routeId: sourceRoute.id, + accountId: groupedCandidate.account.id, + tokenId: groupedCandidate.token.id, + sourceModel: 'claude-opus-4-5', + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }, + ]).run(); + + const groupResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [sourceRoute.id], + }, + }); + + expect(groupResponse.statusCode).toBe(200); + + const decisionResponse = await app.inject({ + method: 'GET', + url: '/api/routes/decision?model=claude-opus-4-6', + }); + + expect(decisionResponse.statusCode).toBe(200); + expect(decisionResponse.json()).toMatchObject({ + success: true, + decision: { + matched: true, + routeId: exactRoute.id, + modelPattern: 'claude-opus-4-6', + actualModel: 'claude-opus-4-6', + }, + }); + }); }); diff --git a/src/server/routes/api/tokens.ts b/src/server/routes/api/tokens.ts index 7b0f6a5..298f531 100644 --- a/src/server/routes/api/tokens.ts +++ b/src/server/routes/api/tokens.ts @@ -1,10 +1,14 @@ import { FastifyInstance } from 'fastify'; -import { and, eq, inArray } from 'drizzle-orm'; -import { db, schema } from '../../db/index.js'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; import { rebuildTokenRoutesFromAvailability, refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; +import { + ACCOUNT_TOKEN_VALUE_STATUS_READY, + isUsableAccountToken, +} from '../../services/accountTokenService.js'; import { normalizeRouteRoutingStrategy } from '../../services/routeRoutingStrategy.js'; import { invalidateTokenRouterCache, matchesModelPattern, tokenRouter } from '../../services/tokenRouter.js'; -import { startBackgroundTask } from '../../services/backgroundTaskService.js'; +import { startBackgroundTask } from '../../services/backgroundTaskService.js'; import { clearRouteDecisionSnapshot, clearRouteDecisionSnapshots, @@ -12,19 +16,156 @@ import { saveRouteDecisionSnapshots, } from '../../services/routeDecisionSnapshotStore.js'; -function isExactModelPattern(modelPattern: string): boolean { - const normalized = modelPattern.trim(); - if (!normalized) return false; - if (normalized.toLowerCase().startsWith('re:')) return false; - return !/[\*\?\[]/.test(normalized); -} +function isExactModelPattern(modelPattern: string): boolean { + const normalized = modelPattern.trim(); + if (!normalized) return false; + if (normalized.toLowerCase().startsWith('re:')) return false; + return !/[\*\?]/.test(normalized); +} + +type RouteMode = 'pattern' | 'explicit_group'; +type RouteRow = typeof schema.tokenRoutes.$inferSelect & { + routeMode: RouteMode; + sourceRouteIds: number[]; +}; + +function normalizeRouteMode(routeMode: unknown): RouteMode { + return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; +} + +function isExplicitGroupRoute(route: Pick | Pick): boolean { + return normalizeRouteMode(route.routeMode) === 'explicit_group'; +} + +function normalizeSourceRouteIdsInput(input: unknown): number[] { + const rawValues = Array.isArray(input) ? input : []; + const normalized: number[] = []; + for (const raw of rawValues) { + const value = Number(raw); + if (!Number.isFinite(value)) continue; + const routeId = Math.trunc(value); + if (routeId <= 0 || normalized.includes(routeId)) continue; + normalized.push(routeId); + if (normalized.length >= 500) break; + } + return normalized; +} + +async function loadRouteSourceIdsMap(routeIds: number[]): Promise> { + const normalizedRouteIds = Array.from(new Set(routeIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0))); + if (normalizedRouteIds.length === 0) return new Map(); + + const rows = await db.select().from(schema.routeGroupSources) + .where(inArray(schema.routeGroupSources.groupRouteId, normalizedRouteIds)) + .all(); + const sourceRouteIdsByRouteId = new Map(); + for (const row of rows) { + if (!sourceRouteIdsByRouteId.has(row.groupRouteId)) { + sourceRouteIdsByRouteId.set(row.groupRouteId, []); + } + sourceRouteIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId); + } + for (const [routeId, sourceRouteIds] of sourceRouteIdsByRouteId.entries()) { + sourceRouteIdsByRouteId.set(routeId, Array.from(new Set(sourceRouteIds))); + } + return sourceRouteIdsByRouteId; +} + +function decorateRoutesWithSources( + routes: Array, + sourceRouteIdsByRouteId: Map, +): RouteRow[] { + return routes.map((route) => ({ + ...route, + routeMode: normalizeRouteMode(route.routeMode), + sourceRouteIds: sourceRouteIdsByRouteId.get(route.id) ?? [], + })); +} + +async function listRoutesWithSources(): Promise { + const routes = await db.select().from(schema.tokenRoutes).all(); + const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap(routes.map((route) => route.id)); + return decorateRoutesWithSources(routes, sourceRouteIdsByRouteId); +} + +async function getRouteWithSources(routeId: number): Promise { + const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); + if (!route) return null; + const sourceRouteIdsByRouteId = await loadRouteSourceIdsMap([routeId]); + return decorateRoutesWithSources([route], sourceRouteIdsByRouteId)[0] ?? null; +} + +async function validateExplicitGroupSourceRoutes(sourceRouteIds: number[], currentRouteId?: number): Promise<{ ok: true } | { ok: false; message: string }> { + if (sourceRouteIds.length === 0) { + return { ok: false, message: '显式群组至少需要选择一个来源模型' }; + } + + const routes = await db.select().from(schema.tokenRoutes) + .where(inArray(schema.tokenRoutes.id, sourceRouteIds)) + .all(); + if (routes.length !== sourceRouteIds.length) { + return { ok: false, message: '来源模型中存在不存在的路由' }; + } + + for (const route of routes) { + if (currentRouteId && route.id === currentRouteId) { + return { ok: false, message: '显式群组不能引用自身作为来源模型' }; + } + if (normalizeRouteMode(route.routeMode) === 'explicit_group') { + return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' }; + } + if (!isExactModelPattern(route.modelPattern)) { + return { ok: false, message: '显式群组只能选择精确模型路由作为来源模型' }; + } + } + + return { ok: true }; +} + +async function replaceRouteSourceRouteIds(routeId: number, sourceRouteIds: number[]): Promise { + await db.delete(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, routeId)).run(); + if (sourceRouteIds.length === 0) return; + await db.insert(schema.routeGroupSources).values( + sourceRouteIds.map((sourceRouteId) => ({ + groupRouteId: routeId, + sourceRouteId, + })), + ).run(); +} + +async function clearDependentExplicitGroupSnapshotsBySourceRouteIds(sourceRouteIds: number[]): Promise { + const normalizedSourceRouteIds = Array.from(new Set( + sourceRouteIds.filter((routeId): routeId is number => Number.isFinite(routeId) && routeId > 0), + )); + if (normalizedSourceRouteIds.length === 0) return; + + const rows = await db.select({ groupRouteId: schema.routeGroupSources.groupRouteId }) + .from(schema.routeGroupSources) + .where(inArray(schema.routeGroupSources.sourceRouteId, normalizedSourceRouteIds)) + .all(); + const dependentRouteIdSet = new Set(); + for (const row of rows) { + const routeId = Number(row.groupRouteId); + if (Number.isFinite(routeId) && routeId > 0) { + dependentRouteIdSet.add(routeId); + } + } + const dependentRouteIds = Array.from(dependentRouteIdSet); + if (dependentRouteIds.length === 0) return; + await clearRouteDecisionSnapshots(dependentRouteIds); +} -async function getDefaultTokenId(accountId: number): Promise { - const token = await db.select().from(schema.accountTokens) - .where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true), eq(schema.accountTokens.isDefault, true))) - .get(); - return token?.id ?? null; -} +async function getDefaultTokenId(accountId: number): Promise { + const token = await db.select().from(schema.accountTokens) + .where(and( + eq(schema.accountTokens.accountId, accountId), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.isDefault, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + )) + .get(); + return isUsableAccountToken(token ?? null) ? token!.id : null; +} function canonicalModelAlias(modelName: string): string { const normalized = modelName.trim().toLowerCase(); @@ -58,12 +199,12 @@ async function tokenSupportsModel(tokenId: number, modelName: string): Promise { - const row = await db.select().from(schema.accountTokens) - .where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId))) - .get(); - return !!row; -} +async function checkTokenBelongsToAccount(tokenId: number, accountId: number): Promise { + const row = await db.select().from(schema.accountTokens) + .where(and(eq(schema.accountTokens.id, tokenId), eq(schema.accountTokens.accountId, accountId))) + .get(); + return isUsableAccountToken(row ?? null); +} async function getPatternTokenCandidates(modelPattern: string): Promise> { const rows = await db.select().from(schema.tokenModelAvailability) @@ -72,18 +213,20 @@ async function getPatternTokenCandidates(modelPattern: string): Promise = []; - for (const row of rows) { - const modelName = row.token_model_availability.modelName?.trim(); - if (!modelName) continue; + eq(schema.tokenModelAvailability.available, true), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + ), + ) + .all(); + + const result: Array<{ tokenId: number; accountId: number; sourceModel: string }> = []; + for (const row of rows) { + if (!isUsableAccountToken(row.account_tokens)) continue; + const modelName = row.token_model_availability.modelName?.trim(); + if (!modelName) continue; if (!matchesModelPattern(modelName, modelPattern)) continue; result.push({ tokenId: row.account_tokens.id, @@ -379,25 +522,54 @@ function parseBatchRouteWideDecisionRouteIds( }; } -async function fetchChannelsForRoutes(routeIds: number[]): Promise> { - if (routeIds.length === 0) return new Map(); - - const channelRows = await db.select().from(schema.routeChannels) - .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) - .where(inArray(schema.routeChannels.routeId, routeIds)) - .all(); - - const channelsByRoute = new Map(); - - for (const row of channelRows) { - const routeId = row.route_channels.routeId; - if (!channelsByRoute.has(routeId)) channelsByRoute.set(routeId, []); - channelsByRoute.get(routeId)!.push({ - ...row.route_channels, - account: row.accounts, - site: row.sites, +type RouteChannelSummary = { + channelCount: number; + enabledChannelCount: number; + siteNames: Set; +}; + +async function fetchChannelsForRouteRows(routes: RouteRow[]): Promise> { + if (routes.length === 0) return new Map(); + + const explicitSourceRouteIds = Array.from(new Set(routes + .filter((route) => isExplicitGroupRoute(route)) + .flatMap((route) => route.sourceRouteIds))); + const enabledExplicitSourceRouteIds = explicitSourceRouteIds.length > 0 + ? (await db.select({ + id: schema.tokenRoutes.id, + modelPattern: schema.tokenRoutes.modelPattern, + routeMode: schema.tokenRoutes.routeMode, + enabled: schema.tokenRoutes.enabled, + }).from(schema.tokenRoutes) + .where(inArray(schema.tokenRoutes.id, explicitSourceRouteIds)) + .all()) + .filter((route) => route.enabled && !isExplicitGroupRoute(route) && isExactModelPattern(route.modelPattern)) + .map((route) => route.id) + : []; + const actualRouteIds = Array.from(new Set([ + ...routes.filter((route) => !isExplicitGroupRoute(route)).map((route) => route.id), + ...enabledExplicitSourceRouteIds, + ])); + if (actualRouteIds.length === 0) { + return new Map(routes.map((route) => [route.id, []])); + } + + const channelRows = await db.select().from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) + .where(inArray(schema.routeChannels.routeId, actualRouteIds)) + .all(); + + const channelsByActualRouteId = new Map(); + + for (const row of channelRows) { + const routeId = row.route_channels.routeId; + if (!channelsByActualRouteId.has(routeId)) channelsByActualRouteId.set(routeId, []); + channelsByActualRouteId.get(routeId)!.push({ + ...row.route_channels, + account: row.accounts, + site: row.sites, token: row.account_tokens ? { id: row.account_tokens.id, @@ -406,63 +578,83 @@ async function fetchChannelsForRoutes(routeIds: number[]): Promise { - return await db.select({ - id: schema.tokenRoutes.id, - modelPattern: schema.tokenRoutes.modelPattern, - displayName: schema.tokenRoutes.displayName, - displayIcon: schema.tokenRoutes.displayIcon, - routingStrategy: schema.tokenRoutes.routingStrategy, - enabled: schema.tokenRoutes.enabled, - }).from(schema.tokenRoutes).all(); + : null, + }); + } + + const channelsByRoute = new Map(); + for (const route of routes) { + if (isExplicitGroupRoute(route)) { + channelsByRoute.set(route.id, route.sourceRouteIds.flatMap((sourceRouteId) => channelsByActualRouteId.get(sourceRouteId) || [])); + continue; + } + channelsByRoute.set(route.id, channelsByActualRouteId.get(route.id) || []); + } + + return channelsByRoute; +} + +async function fetchChannelsForRoutes(routeIds: number[]): Promise> { + if (routeIds.length === 0) return new Map(); + return await fetchChannelsForRouteRows(await listRoutesWithSources()).then((channelsByRoute) => { + const filtered = new Map(); + for (const routeId of routeIds) { + filtered.set(routeId, channelsByRoute.get(routeId) || []); + } + return filtered; }); +} + +async function buildRouteChannelSummaryMap(routes: RouteRow[]): Promise> { + const channelsByRoute = await fetchChannelsForRouteRows(routes); + const summaryByRoute = new Map(); + for (const route of routes) { + const channels = channelsByRoute.get(route.id) || []; + const siteNames = new Set(); + let enabledChannelCount = 0; + for (const channel of channels) { + if (channel.enabled) enabledChannelCount += 1; + if (channel.site?.name) siteNames.add(channel.site.name); + } + summaryByRoute.set(route.id, { + channelCount: channels.length, + enabledChannelCount, + siteNames, + }); + } + return summaryByRoute; +} - // Route summary (no channel details) for first-screen rendering - app.get('/api/routes/summary', async () => { - const routes = await db.select().from(schema.tokenRoutes).all(); - if (routes.length === 0) return []; - - const routeIds = routes.map((route) => route.id); - - // Aggregate channel counts and site names per route - const channelRows = await db.select({ - routeId: schema.routeChannels.routeId, - enabled: schema.routeChannels.enabled, - siteName: schema.sites.name, - }).from(schema.routeChannels) - .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .where(inArray(schema.routeChannels.routeId, routeIds)) - .all(); - - const aggByRoute = new Map }>(); - for (const row of channelRows) { - let agg = aggByRoute.get(row.routeId); - if (!agg) { - agg = { channelCount: 0, enabledChannelCount: 0, siteNames: new Set() }; - aggByRoute.set(row.routeId, agg); - } - agg.channelCount += 1; - if (row.enabled) agg.enabledChannelCount += 1; - if (row.siteName) agg.siteNames.add(row.siteName); - } - - return routes.map((route) => { - const agg = aggByRoute.get(route.id); - return { - id: route.id, - modelPattern: route.modelPattern, - displayName: route.displayName ?? null, +export async function tokensRoutes(app: FastifyInstance) { + // List routes with basic info only (lightweight for selectors) + app.get('/api/routes/lite', async () => { + return (await listRoutesWithSources()).map((route) => ({ + id: route.id, + modelPattern: route.modelPattern, + displayName: route.displayName, + displayIcon: route.displayIcon, + routeMode: route.routeMode, + sourceRouteIds: route.sourceRouteIds, + routingStrategy: route.routingStrategy, + enabled: route.enabled, + })); + }); + + // Route summary (no channel details) for first-screen rendering + app.get('/api/routes/summary', async () => { + const routes = await listRoutesWithSources(); + if (routes.length === 0) return []; + const aggByRoute = await buildRouteChannelSummaryMap(routes); + + return routes.map((route) => { + const agg = aggByRoute.get(route.id); + return { + id: route.id, + modelPattern: route.modelPattern, + displayName: route.displayName ?? null, displayIcon: route.displayIcon ?? null, + routeMode: route.routeMode, + sourceRouteIds: route.sourceRouteIds, modelMapping: route.modelMapping ?? null, routingStrategy: route.routingStrategy ?? 'weighted', enabled: route.enabled, @@ -475,26 +667,29 @@ export async function tokensRoutes(app: FastifyInstance) { }); }); - // Get channels for a single route (on-demand loading) - app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => { - const routeId = parseInt(request.params.id, 10); - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); - if (!route) { - return reply.code(404).send({ success: false, message: '路由不存在' }); - } - const channelsByRoute = await fetchChannelsForRoutes([routeId]); - return channelsByRoute.get(routeId) || []; - }); + // Get channels for a single route (on-demand loading) + app.get<{ Params: { id: string } }>('/api/routes/:id/channels', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + const route = await getRouteWithSources(routeId); + if (!route) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + const channelsByRoute = await fetchChannelsForRouteRows([route]); + return channelsByRoute.get(routeId) || []; + }); // Batch add channels to a route - app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => { - const routeId = parseInt(request.params.id, 10); - const body = request.body; - - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); - if (!route) { - return reply.code(404).send({ success: false, message: '路由不存在' }); - } + app.post<{ Params: { id: string }; Body: { channels: Array<{ accountId: number; tokenId?: number; sourceModel?: string }> } }>('/api/routes/:id/channels/batch', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + const body = request.body; + + const route = await getRouteWithSources(routeId); + if (!route) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + if (isExplicitGroupRoute(route)) { + return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' }); + } if (!body?.channels || !Array.isArray(body.channels) || body.channels.length === 0) { return reply.code(400).send({ success: false, message: 'channels 必须是非空数组' }); @@ -555,24 +750,24 @@ export async function tokensRoutes(app: FastifyInstance) { } } - if (created > 0) { - await clearRouteDecisionSnapshot(routeId); - invalidateTokenRouterCache(); - } + if (created > 0) { + await clearRouteDecisionSnapshot(routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]); + invalidateTokenRouterCache(); + } return { success: true, created, skipped, errors }; }); - // List all routes - app.get('/api/routes', async () => { - const routes = await db.select().from(schema.tokenRoutes).all(); - if (routes.length === 0) return []; - - const routeIds = routes.map((route) => route.id); - const channelsByRoute = await fetchChannelsForRoutes(routeIds); - - return routes.map((route) => ({ - ...route, + // List all routes + app.get('/api/routes', async () => { + const routes = await listRoutesWithSources(); + if (routes.length === 0) return []; + + const channelsByRoute = await fetchChannelsForRouteRows(routes); + + return routes.map((route) => ({ + ...route, decisionSnapshot: parseRouteDecisionSnapshot(route.decisionSnapshot), decisionRefreshedAt: route.decisionRefreshedAt ?? null, channels: channelsByRoute.get(route.id) || [], @@ -678,87 +873,143 @@ export async function tokensRoutes(app: FastifyInstance) { return { success: true, decisions }; }); - // Create a route - app.post<{ Body: { modelPattern: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean } }>('/api/routes', async (request) => { + // Create a route + app.post<{ Body: { routeMode?: string; modelPattern?: string; displayName?: string; displayIcon?: string; modelMapping?: string; routingStrategy?: string; enabled?: boolean; sourceRouteIds?: number[] } }>('/api/routes', async (request, reply) => { const body = request.body; + const routeMode = normalizeRouteMode(body.routeMode); + const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : ''; + const sourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds); + const modelPattern = routeMode === 'explicit_group' + ? displayName + : (typeof body.modelPattern === 'string' ? body.modelPattern.trim() : ''); + + if (routeMode === 'explicit_group') { + if (!displayName) { + return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' }); + } + const validation = await validateExplicitGroupSourceRoutes(sourceRouteIds); + if (!validation.ok) { + return reply.code(400).send({ success: false, message: validation.message }); + } + } else if (!modelPattern) { + return reply.code(400).send({ success: false, message: '模型匹配不能为空' }); + } + const insertedRoute = await db.insert(schema.tokenRoutes).values({ - modelPattern: body.modelPattern, - displayName: body.displayName, + modelPattern, + displayName: displayName || body.displayName, displayIcon: body.displayIcon, + routeMode, modelMapping: body.modelMapping, routingStrategy: normalizeRouteRoutingStrategy(body.routingStrategy), enabled: body.enabled ?? true, }).run(); - const routeId = Number(insertedRoute.lastInsertRowid || 0); - if (routeId <= 0) { - return { success: false, message: '创建路由失败' }; - } - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); - if (!route) { - return { success: false, message: '创建路由失败' }; - } - - await populateRouteChannelsByModelPattern(route.id, body.modelPattern); - invalidateTokenRouterCache(); - return route; - }); - - // Update a route - app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => { - const id = parseInt(request.params.id, 10); - const body = request.body as Record; - const existingRoute = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); - if (!existingRoute) { - return reply.code(404).send({ success: false, message: '路由不存在' }); - } - - const updates: Record = {}; - let nextModelPattern = existingRoute.modelPattern; - - if (body.displayName !== undefined) updates.displayName = body.displayName; - if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon; - if (body.modelPattern !== undefined) { + const routeId = Number(insertedRoute.lastInsertRowid || 0); + if (routeId <= 0) { + return { success: false, message: '创建路由失败' }; + } + const route = await getRouteWithSources(routeId); + if (!route) { + return { success: false, message: '创建路由失败' }; + } + + if (routeMode === 'explicit_group') { + await replaceRouteSourceRouteIds(route.id, sourceRouteIds); + } else { + await populateRouteChannelsByModelPattern(route.id, modelPattern); + } + invalidateTokenRouterCache(); + return await getRouteWithSources(routeId); + }); + + // Update a route + app.put<{ Params: { id: string }; Body: any }>('/api/routes/:id', async (request, reply) => { + const id = parseInt(request.params.id, 10); + const body = request.body as Record; + const existingRoute = await getRouteWithSources(id); + if (!existingRoute) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + const routeMode = normalizeRouteMode(body.routeMode ?? existingRoute.routeMode); + if (routeMode !== existingRoute.routeMode) { + return reply.code(400).send({ success: false, message: '暂不支持在不同群组模式之间直接切换' }); + } + + const updates: Record = {}; + let nextModelPattern = existingRoute.modelPattern; + let nextDisplayName = existingRoute.displayName ?? ''; + let nextSourceRouteIds = existingRoute.sourceRouteIds; + + if (body.displayName !== undefined) { + nextDisplayName = String(body.displayName || '').trim(); + updates.displayName = nextDisplayName || null; + } + if (body.displayIcon !== undefined) updates.displayIcon = body.displayIcon; + if (routeMode === 'explicit_group') { + nextModelPattern = nextDisplayName; + updates.modelPattern = nextModelPattern; + if (body.sourceRouteIds !== undefined) { + nextSourceRouteIds = normalizeSourceRouteIdsInput(body.sourceRouteIds); + } + if (!nextDisplayName) { + return reply.code(400).send({ success: false, message: '显式群组必须填写对外模型名' }); + } + const validation = await validateExplicitGroupSourceRoutes(nextSourceRouteIds, id); + if (!validation.ok) { + return reply.code(400).send({ success: false, message: validation.message }); + } + } else if (body.modelPattern !== undefined) { nextModelPattern = String(body.modelPattern); updates.modelPattern = nextModelPattern; } if (body.modelMapping !== undefined) updates.modelMapping = body.modelMapping; if (body.routingStrategy !== undefined) updates.routingStrategy = normalizeRouteRoutingStrategy(body.routingStrategy); if (body.enabled !== undefined) updates.enabled = body.enabled; + if (body.routeMode !== undefined) updates.routeMode = routeMode; updates.updatedAt = new Date().toISOString(); await db.update(schema.tokenRoutes).set(updates).where(eq(schema.tokenRoutes.id, id)).run(); - const modelPatternChanged = body.modelPattern !== undefined && nextModelPattern !== existingRoute.modelPattern; + if (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined) { + await replaceRouteSourceRouteIds(id, nextSourceRouteIds); + } + const modelPatternChanged = nextModelPattern !== existingRoute.modelPattern; const routeBehaviorChanged = modelPatternChanged + || (routeMode === 'explicit_group' && body.sourceRouteIds !== undefined) || body.modelMapping !== undefined || body.routingStrategy !== undefined || body.enabled !== undefined; - if (modelPatternChanged) { - await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); - } - if (routeBehaviorChanged) { - await clearRouteDecisionSnapshot(id); - } - invalidateTokenRouterCache(); - return await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).get(); - }); + if (routeMode === 'pattern' && modelPatternChanged) { + await rebuildAutomaticRouteChannelsByModelPattern(id, nextModelPattern); + } + if (routeBehaviorChanged) { + await clearRouteDecisionSnapshot(id); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]); + } + invalidateTokenRouterCache(); + return await getRouteWithSources(id); + }); // Delete a route - app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => { - const id = parseInt(request.params.id, 10); - await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run(); - invalidateTokenRouterCache(); - return { success: true }; - }); + app.delete<{ Params: { id: string } }>('/api/routes/:id', async (request) => { + const id = parseInt(request.params.id, 10); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([id]); + await db.delete(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, id)).run(); + invalidateTokenRouterCache(); + return { success: true }; + }); // Add a channel to a route - app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => { - const routeId = parseInt(request.params.id, 10); - const body = request.body; - - const route = await db.select().from(schema.tokenRoutes).where(eq(schema.tokenRoutes.id, routeId)).get(); - if (!route) { - return reply.code(404).send({ success: false, message: '路由不存在' }); - } + app.post<{ Params: { id: string }; Body: { accountId: number; tokenId?: number; sourceModel?: string; priority?: number; weight?: number } }>('/api/routes/:id/channels', async (request, reply) => { + const routeId = parseInt(request.params.id, 10); + const body = request.body; + + const route = await getRouteWithSources(routeId); + if (!route) { + return reply.code(404).send({ success: false, message: '路由不存在' }); + } + if (isExplicitGroupRoute(route)) { + return reply.code(400).send({ success: false, message: '显式群组不支持直接维护通道' }); + } const sourceModel = typeof body.sourceModel === 'string' ? body.sourceModel.trim() @@ -800,11 +1051,12 @@ export async function tokensRoutes(app: FastifyInstance) { const created = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); if (!created) { return reply.code(500).send({ success: false, message: '创建通道失败' }); - } - await clearRouteDecisionSnapshot(routeId); - invalidateTokenRouterCache(); - return created; - }); + } + await clearRouteDecisionSnapshot(routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([routeId]); + invalidateTokenRouterCache(); + return created; + }); // Batch update channel priorities app.put<{ Body: { updates: Array<{ id: number; priority: number }> } }>('/api/channels/batch', async (request, reply) => { @@ -830,13 +1082,14 @@ export async function tokensRoutes(app: FastifyInstance) { }).where(eq(schema.routeChannels.id, update.id)).run(); } - const updatedChannels = await db.select().from(schema.routeChannels) - .where(inArray(schema.routeChannels.id, channelIds)) - .all(); - await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId)); - invalidateTokenRouterCache(); - return { success: true, channels: updatedChannels }; - }); + const updatedChannels = await db.select().from(schema.routeChannels) + .where(inArray(schema.routeChannels.id, channelIds)) + .all(); + await clearRouteDecisionSnapshots(existingChannels.map((channel) => channel.routeId)); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds(existingChannels.map((channel) => channel.routeId)); + invalidateTokenRouterCache(); + return { success: true, channels: updatedChannels }; + }); // Update a channel app.put<{ Params: { channelId: string }; Body: any }>('/api/channels/:channelId', async (request, reply) => { @@ -878,23 +1131,25 @@ export async function tokensRoutes(app: FastifyInstance) { if (body[key] !== undefined) updates[key] = body[key]; } - await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); - await clearRouteDecisionSnapshot(channel.routeId); - invalidateTokenRouterCache(); - return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - }); + await db.update(schema.routeChannels).set(updates).where(eq(schema.routeChannels.id, channelId)).run(); + await clearRouteDecisionSnapshot(channel.routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]); + invalidateTokenRouterCache(); + return await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); + }); // Delete a channel app.delete<{ Params: { channelId: string } }>('/api/channels/:channelId', async (request) => { const channelId = parseInt(request.params.channelId, 10); const channel = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run(); - if (channel) { - await clearRouteDecisionSnapshot(channel.routeId); - } - invalidateTokenRouterCache(); - return { success: true }; - }); + await db.delete(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).run(); + if (channel) { + await clearRouteDecisionSnapshot(channel.routeId); + await clearDependentExplicitGroupSnapshotsBySourceRouteIds([channel.routeId]); + } + invalidateTokenRouterCache(); + return { success: true }; + }); // Rebuild routes/channels from model availability. app.post<{ Body?: { refreshModels?: boolean; wait?: boolean } }>('/api/routes/rebuild', async (request, reply) => { diff --git a/src/server/routes/proxy/chat.codex-oauth.test.ts b/src/server/routes/proxy/chat.codex-oauth.test.ts new file mode 100644 index 0000000..60f6bc1 --- /dev/null +++ b/src/server/routes/proxy/chat.codex-oauth.test.ts @@ -0,0 +1,248 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ + ...usage, + estimatedCostFromQuota: 0, + recoveredFromSelfLog: false, +})); +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: () => ({ + run: () => undefined, + }), +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: ({ status }: { status?: number }) => status === 401, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: async () => 0, + buildProxyBillingDetails: async () => null, + fetchModelPricingCatalog: async () => null, +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, + schema: { + proxyLogs: {}, + }, +})); + +describe('chat proxy codex oauth compatibility', () => { + let app: FastifyInstance; + + const createSseResponse = (chunks: string[], status = 200) => { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }), { + status, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + }); + }; + + beforeAll(async () => { + const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js'); + app = Fastify(); + await app.register(chatProxyRoute); + await app.register(claudeMessagesProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + dbInsertMock.mockClear(); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' }, + account: { + id: 33, + username: 'codex-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }, + tokenName: 'default', + tokenValue: 'oauth-access-token', + actualModel: 'gpt-5.4', + }); + selectNextChannelMock.mockReturnValue(null); + }); + + afterAll(async () => { + await app.close(); + }); + + it('strips codex-unsupported responses fields and aggregates the SSE result for /v1/messages callers', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_claude","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude","delta":"pong from codex"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_claude","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'gpt-5.4', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello codex' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.stream).toBe(true); + expect(forwardedBody.instructions).toBe(''); + expect(forwardedBody.store).toBe(false); + expect(forwardedBody.parallel_tool_calls).toBe(true); + expect(forwardedBody.include).toEqual(['reasoning.encrypted_content']); + expect(forwardedBody.max_output_tokens).toBeUndefined(); + expect(forwardedBody.max_completion_tokens).toBeUndefined(); + + const body = response.json(); + expect(body.type).toBe('message'); + expect(body.role).toBe('assistant'); + expect(body.model).toBe('gpt-5.4'); + expect(body.content?.[0]?.type).toBe('text'); + expect(body.content?.[0]?.text).toContain('pong from codex'); + }); + + it('translates codex SSE into Claude messages stream events instead of leaking raw response events', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.content_part.added\n', + 'data: {"type":"response.content_part.added","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":""}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude_stream","content_index":0,"delta":"pong from codex"}\n\n', + 'event: response.content_part.done\n', + 'data: {"type":"response.content_part.done","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":"pong from codex"}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'gpt-5.4', + stream: true, + max_tokens: 256, + messages: [{ role: 'user', content: 'hello codex' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('event: message_start'); + expect(response.body).toContain('event: content_block_delta'); + expect(response.body).toContain('pong from codex'); + expect(response.body).not.toContain('event: response.created'); + expect(response.body).not.toContain('"type":"response.output_text.delta"'); + }); + + it('translates codex SSE into OpenAI chat completion chunks instead of leaking raw response events', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_openai_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_openai_stream","delta":"pong from codex"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-5.4', + stream: true, + messages: [{ role: 'user', content: 'hello codex' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('"object":"chat.completion.chunk"'); + expect(response.body).toContain('pong from codex'); + expect(response.body).not.toContain('event: response.created'); + expect(response.body).not.toContain('"type":"response.output_text.delta"'); + }); +}); diff --git a/src/server/routes/proxy/chat.count-tokens.test.ts b/src/server/routes/proxy/chat.count-tokens.test.ts new file mode 100644 index 0000000..795055b --- /dev/null +++ b/src/server/routes/proxy/chat.count-tokens.test.ts @@ -0,0 +1,157 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ + ...usage, + estimatedCostFromQuota: 0, + recoveredFromSelfLog: false, +})); +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: () => ({ + run: () => undefined, + }), +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: () => false, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: async () => 0, + buildProxyBillingDetails: async () => null, + fetchModelPricingCatalog: async () => null, +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), +})); + +vi.mock('../../services/oauth/quota.js', () => ({ + recordOauthQuotaResetHint: async () => undefined, +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, + schema: { + proxyLogs: {}, + }, +})); + +describe('claude count_tokens proxy route', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const { claudeMessagesProxyRoute } = await import('./chat.js'); + app = Fastify(); + await app.register(claudeMessagesProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + dbInsertMock.mockClear(); + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'claude-site', url: 'https://api.anthropic.com', platform: 'claude' }, + account: { id: 33, username: 'claude-user@example.com' }, + tokenName: 'default', + tokenValue: 'sk-claude', + actualModel: 'claude-opus-4-6', + }); + selectNextChannelMock.mockReturnValue(null); + }); + + afterAll(async () => { + await app.close(); + }); + + it('forwards /v1/messages/count_tokens to the claude count_tokens upstream path', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + input_tokens: 42, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages/count_tokens', + payload: { + model: 'claude-opus-4-6', + tools: [ + { name: 'lookup', input_schema: { type: 'object' } }, + ], + messages: [ + { + role: 'user', + content: [{ type: 'text', text: 'count these tokens' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ input_tokens: 42 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toBe('https://api.anthropic.com/v1/messages/count_tokens?beta=true'); + expect(options.headers['anthropic-version']).toBe('2023-06-01'); + expect(options.headers['anthropic-beta']).toContain('claude-code-20250219'); + expect(options.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd'); + + const forwardedBody = JSON.parse(String(options.body)); + expect(forwardedBody.model).toBe('claude-opus-4-6'); + expect(forwardedBody.messages).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'count these tokens', cache_control: { type: 'ephemeral' } }], + }, + ]); + }); +}); diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index f566a57..80cfcaf 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -1384,6 +1384,15 @@ describe('chat proxy stream behavior', () => { status: 400, headers: { 'content-type': 'application/json' }, })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { + message: 'request validation failed', + type: 'invalid_request_error', + }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })) .mockResolvedValueOnce(new Response(JSON.stringify({ id: 'resp_retry_minimal_headers', object: 'response', @@ -1413,21 +1422,27 @@ describe('chat proxy stream behavior', () => { const body = response.json(); expect(body.output_text).toContain('ok after minimal headers retry'); - expect(fetchMock).toHaveBeenCalledTimes(3); + expect(fetchMock).toHaveBeenCalledTimes(4); const [, firstOptions] = fetchMock.mock.calls[0] as [string, any]; const [, secondOptions] = fetchMock.mock.calls[1] as [string, any]; const [, thirdOptions] = fetchMock.mock.calls[2] as [string, any]; + const [, fourthOptions] = fetchMock.mock.calls[3] as [string, any]; expect(firstOptions.headers['openai-beta']).toBe('responses-2025-03-11'); expect(secondOptions.headers['openai-beta']).toBe('responses-2025-03-11'); - expect(thirdOptions.headers['openai-beta']).toBeUndefined(); + expect(thirdOptions.headers['openai-beta']).toBe('responses-2025-03-11'); + expect(fourthOptions.headers['openai-beta']).toBeUndefined(); const firstBody = JSON.parse(firstOptions.body); const secondBody = JSON.parse(secondOptions.body); const thirdBody = JSON.parse(thirdOptions.body); + const fourthBody = JSON.parse(fourthOptions.body); expect(firstBody.user).toBe('user-123'); expect(secondBody.user).toBeUndefined(); expect(thirdBody.user).toBeUndefined(); + expect(secondBody.include).toEqual(['reasoning.encrypted_content']); + expect(thirdBody.include).toBeUndefined(); + expect(fourthBody.user).toBeUndefined(); }); it('returns concise Cloudflare host error on /v1/responses 502 html failures', async () => { @@ -3193,6 +3208,8 @@ describe('chat proxy stream behavior', () => { expect(response.statusCode).toBe(200); expect(response.body).toContain('"finish_reason":"error"'); expect(response.body).toContain('[DONE]'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); }); it('preserves non-stream function_call output when /v1/chat/completions falls back to /v1/responses', async () => { diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index de186e4..bfe5ac4 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -1,7 +1,6 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { tokenRouter } from '../../services/tokenRouter.js'; -import { db, schema } from '../../db/index.js'; -import { fetch } from 'undici'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; @@ -12,6 +11,7 @@ import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../se import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; import { type DownstreamFormat } from '../../transformers/shared/normalized.js'; import { + buildClaudeCountTokensUpstreamRequest, buildUpstreamEndpointRequest, resolveUpstreamEndpointCandidates, } from './upstreamEndpoint.js'; @@ -21,21 +21,41 @@ import { recordDownstreamCostUsage, } from './downstreamPolicy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; -import { executeEndpointFlow } from './endpointFlow.js'; +import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; import { openAiChatTransformer } from '../../transformers/openai/chat/index.js'; import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js'; import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js'; -import { getProxyResourceOwner } from '../../middleware/auth.js'; +import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; import { ProxyInputFileResolutionError, hasNonImageFileInputInOpenAiBody, resolveOpenAiBodyInputFiles, } from '../../services/proxyInputFileResolver.js'; +import { + buildOauthProviderHeaders, +} from '../../services/oauth/service.js'; +import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; +import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; +import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; +import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js'; +import { + createGeminiCliStreamReader, + unwrapGeminiCliPayload, +} from './geminiCliCompat.js'; +import { dispatchRuntimeRequest } from './runtimeExecutor.js'; const MAX_RETRIES = 2; +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + export async function chatProxyRoute(app: FastifyInstance) { app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) => handleChatProxyRequest(request, reply, 'openai')); @@ -44,6 +64,8 @@ export async function chatProxyRoute(app: FastifyInstance) { export async function claudeMessagesProxyRoute(app: FastifyInstance) { app.post('/v1/messages', async (request: FastifyRequest, reply: FastifyReply) => handleChatProxyRequest(request, reply, 'claude')); + app.post('/v1/messages/count_tokens', async (request: FastifyRequest, reply: FastifyReply) => + handleClaudeCountTokensRequest(request, reply)); } async function handleChatProxyRequest( @@ -87,6 +109,15 @@ async function handleChatProxyRequest( } } const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody); + const codexSessionCacheKey = deriveCodexSessionCacheKey({ + downstreamFormat, + body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body, + requestedModel, + proxyToken: getProxyAuthContext(request)?.token || null, + }); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -114,6 +145,8 @@ async function handleChatProxyRequest( excludeChannelIds.push(selected.channel.id); const modelName = selected.actualModel || requestedModel; + const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex'; const endpointCandidates = [ ...await resolveUpstreamEndpointCandidates( { @@ -128,15 +161,24 @@ async function handleChatProxyRequest( }, ), ]; + const buildProviderHeaders = () => ( + buildOauthProviderHeaders({ + extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + downstreamHeaders: request.headers as Record, + }) + ); const buildEndpointRequest = ( endpoint: 'chat' | 'messages' | 'responses', options: { forceNormalizeClaudeBody?: boolean } = {}, ) => { + const upstreamStream = isStream || (isCodexSite && endpoint === 'responses'); const endpointRequest = buildUpstreamEndpointRequest({ endpoint, modelName, - stream: isStream, + stream: upstreamStream, tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + oauthProjectId: oauth?.projectId, sitePlatform: selected.site.platform, siteUrl: selected.site.url, openaiBody: resolvedOpenAiBody, @@ -144,34 +186,73 @@ async function handleChatProxyRequest( claudeOriginalBody, forceNormalizeClaudeBody: options.forceNormalizeClaudeBody, downstreamHeaders: request.headers as Record, + providerHeaders: buildProviderHeaders(), + codexSessionCacheKey, }); return { endpoint, path: endpointRequest.path, headers: endpointRequest.headers, body: endpointRequest.body as Record, + runtime: endpointRequest.runtime, }; }; + const dispatchRequest = ( + compatibilityRequest: BuiltEndpointRequest, + targetUrl?: string, + ) => ( + dispatchRuntimeRequest({ + siteUrl: selected.site.url, + targetUrl, + request: compatibilityRequest, + buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }) + ); const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({ downstreamFormat, endpointCandidates, modelName, requestedModelHint: requestedModel, sitePlatform: selected.site.platform, - isStream, + isStream: isStream || isCodexSite, buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( endpoint, { forceNormalizeClaudeBody }, ), - dispatchRequest: (compatibilityRequest, targetUrl) => fetch( - targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`, - withSiteRecordProxyRequestInit(selected.site, { - method: 'POST', - headers: compatibilityRequest.headers, - body: JSON.stringify(compatibilityRequest.body), - }, getProxyUrlFromExtraConfig(selected.account.extraConfig)), - ), + dispatchRequest, }); + const tryRecover = async (ctx: Parameters>[0]) => { + if (ctx.response.status === 401 && oauth) { + try { + const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); + selected.tokenValue = refreshed.accessToken; + selected.account = { + ...selected.account, + accessToken: refreshed.accessToken, + extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, + }; + const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); + const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`; + const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); + if (refreshedResponse.ok) { + return { + upstream: refreshedResponse, + upstreamPath: refreshedRequest.path, + }; + } + ctx.request = refreshedRequest; + ctx.response = refreshedResponse; + ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); + } catch { + return endpointStrategy.tryRecover(ctx); + } + } + return endpointStrategy.tryRecover(ctx); + }; let startTime = Date.now(); try { @@ -180,7 +261,8 @@ async function handleChatProxyRequest( proxyUrl: resolveChannelProxyUrl(selected.site, selected.account.extraConfig), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), - tryRecover: endpointStrategy.tryRecover, + dispatchRequest, + tryRecover, shouldDowngrade: endpointStrategy.shouldDowngrade, onDowngrade: (ctx) => { logProxy( @@ -192,20 +274,22 @@ async function handleChatProxyRequest( ctx.errText, retryCount, downstreamPath, - 0, - 0, - 0, - 0, - null, - null, - clientContext, - ); - }, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + }, }); if (!endpointResult.ok) { const status = endpointResult.status || 502; const errText = endpointResult.errText || 'unknown error'; + const rawErrText = endpointResult.rawErrText || errText; tokenRouter.recordFailure(selected.channel.id); logProxy( selected, @@ -223,7 +307,13 @@ async function handleChatProxyRequest( null, null, clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); + await recordOauthQuotaResetHint({ + accountId: selected.account.id, + statusCode: status, + errorText: rawErrText, + }); if (isTokenExpiredError({ status, message: errText })) { await reportTokenExpired({ @@ -277,6 +367,7 @@ async function handleChatProxyRequest( const streamSession = openAiChatTransformer.proxyStream.createSession({ downstreamFormat, modelName, + successfulUpstreamPath, onParsedPayload: (payload) => { if (payload && typeof payload === 'object') { parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload)); @@ -297,9 +388,35 @@ async function handleChatProxyRequest( } catch { fallbackData = fallbackText; } - streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw); + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + fallbackData = unwrapGeminiCliPayload(fallbackData); + } + const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw); const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ site: selected.site, account: selected.account, @@ -341,14 +458,42 @@ async function handleChatProxyRequest( estimatedCost, billingDetails, successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); return; } - const reader = upstream.body?.getReader(); - await streamSession.run(reader, reply.raw); + const upstreamReader = upstream.body?.getReader(); + const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader + ? createGeminiCliStreamReader(upstreamReader) + : upstreamReader; + const streamResult = await streamSession.run(reader, reply.raw); const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ site: selected.site, account: selected.account, @@ -390,16 +535,30 @@ async function handleChatProxyRequest( estimatedCost, billingDetails, successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); return; } - const rawText = await upstream.text(); - let upstreamData: unknown = rawText; - try { - upstreamData = JSON.parse(rawText); - } catch { + const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); + let rawText = ''; + let upstreamData: unknown; + if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) { + const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName); + rawText = collected.rawText; + upstreamData = collected.payload; + } else { + rawText = await upstream.text(); upstreamData = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch { + upstreamData = rawText; + } + } + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + upstreamData = unwrapGeminiCliPayload(upstreamData); } const latency = Date.now() - startTime; @@ -448,6 +607,8 @@ async function handleChatProxyRequest( estimatedCost, billingDetails, successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); return reply.send(downstreamResponse); @@ -469,6 +630,7 @@ async function handleChatProxyRequest( null, null, clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); if (retryCount < MAX_RETRIES) { @@ -491,6 +653,251 @@ async function handleChatProxyRequest( } } +function deriveCodexSessionCacheKey(input: { + downstreamFormat: DownstreamFormat | 'responses'; + body: unknown; + requestedModel: string; + proxyToken: string | null; +}): string | null { + if (isRecord(input.body)) { + if (input.downstreamFormat === 'claude' && isRecord(input.body.metadata)) { + const userId = asTrimmedString(input.body.metadata.user_id); + if (userId) return `${input.requestedModel}:claude:${userId}`; + } + const promptCacheKey = asTrimmedString(input.body.prompt_cache_key); + if (promptCacheKey) return `${input.requestedModel}:responses:${promptCacheKey}`; + } + + const proxyToken = asTrimmedString(input.proxyToken); + if (proxyToken) { + return `${input.requestedModel}:proxy:${proxyToken}`; + } + + return null; +} + +async function handleClaudeCountTokensRequest( + request: FastifyRequest, + reply: FastifyReply, +) { + const rawBody = isRecord(request.body) ? { ...request.body } : null; + if (!rawBody) { + return reply.code(400).send({ + error: { + message: 'Request body must be a JSON object', + type: 'invalid_request_error', + }, + }); + } + + const requestedModel = asTrimmedString(rawBody.model); + if (!requestedModel) { + return reply.code(400).send({ + error: { + message: 'model is required', + type: 'invalid_request_error', + }, + }); + } + + if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; + const downstreamPath = '/v1/messages/count_tokens'; + const clientContext = detectDownstreamClientContext({ + downstreamPath, + headers: request.headers as Record, + body: rawBody, + }); + const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); + const excludeChannelIds: number[] = []; + let retryCount = 0; + + while (retryCount <= MAX_RETRIES) { + let selected = retryCount === 0 + ? await tokenRouter.selectChannel(requestedModel, downstreamPolicy) + : await tokenRouter.selectNextChannel(requestedModel, excludeChannelIds, downstreamPolicy); + + if (!selected && retryCount === 0) { + await refreshModelsAndRebuildRoutes(); + selected = await tokenRouter.selectChannel(requestedModel, downstreamPolicy); + } + + if (!selected) { + await reportProxyAllFailed({ + model: requestedModel, + reason: 'No available channels after retries', + }); + return reply.code(503).send({ + error: { message: 'No available channels for this model', type: 'server_error' }, + }); + } + + excludeChannelIds.push(selected.channel.id); + if (String(selected.site.platform || '').trim().toLowerCase() !== 'claude') { + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(501).send({ + error: { + message: 'Claude count_tokens compatibility is not implemented for this upstream', + type: 'invalid_request_error', + }, + }); + } + + const modelName = selected.actualModel || requestedModel; + const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const startTime = Date.now(); + + const buildRequest = () => { + const upstreamRequest = buildClaudeCountTokensUpstreamRequest({ + modelName, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + sitePlatform: selected.site.platform, + claudeBody: rawBody, + downstreamHeaders: request.headers as Record, + }); + return { + endpoint: 'messages' as const, + path: upstreamRequest.path, + headers: upstreamRequest.headers, + body: upstreamRequest.body, + runtime: upstreamRequest.runtime, + }; + }; + + try { + let upstreamRequest = buildRequest(); + let upstream = await dispatchRuntimeRequest({ + siteUrl: selected.site.url, + request: upstreamRequest, + buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }); + + if (upstream.status === 401 && oauth) { + try { + const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); + selected.tokenValue = refreshed.accessToken; + selected.account = { + ...selected.account, + accessToken: refreshed.accessToken, + extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, + }; + upstreamRequest = buildRequest(); + upstream = await dispatchRuntimeRequest({ + siteUrl: selected.site.url, + request: upstreamRequest, + buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }); + } catch { + // Fall through to the regular upstream error handling below. + } + } + + const latency = Date.now() - startTime; + const contentType = upstream.headers.get('content-type') || 'application/json'; + const text = await upstream.text(); + let payload: unknown = text; + try { + payload = JSON.parse(text); + } catch { + payload = text; + } + + if (!upstream.ok) { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + upstream.status, + latency, + typeof payload === 'string' ? payload : JSON.stringify(payload), + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + upstreamRequest.path, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(upstream.status).type(contentType).send(payload); + } + + tokenRouter.recordSuccess(selected.channel.id, latency, 0); + recordDownstreamCostUsage(request, 0); + logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + upstreamRequest.path, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return reply.code(upstream.status).type(contentType).send(payload); + } catch (error: any) { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + error?.message || 'network error', + retryCount, + downstreamPath, + 0, + 0, + 0, + 0, + null, + null, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(502).send({ + error: { + message: `Upstream error: ${error?.message || 'network failure'}`, + type: 'upstream_error', + }, + }); + } + } +} + async function logProxy( selected: any, modelRequested: string, @@ -507,6 +914,7 @@ async function logProxy( billingDetails: unknown = null, upstreamPath: string | null = null, clientContext: DownstreamClientContext | null = null, + downstreamApiKeyId: number | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); @@ -524,6 +932,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 65eb286..9867519 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -14,6 +14,7 @@ import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; +import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -27,6 +28,9 @@ export async function completionsProxyRoute(app: FastifyInstance) { } if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const isStream = body.stream === true; const excludeChannelIds: number[] = []; @@ -71,7 +75,7 @@ export async function completionsProxyRoute(app: FastifyInstance) { if (!upstream.ok) { const errText = await upstream.text().catch(() => 'unknown error'); tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (isTokenExpiredError({ status: upstream.status, message: errText })) { await reportTokenExpired({ @@ -172,7 +176,7 @@ export async function completionsProxyRoute(app: FastifyInstance) { tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, + selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, ); return; @@ -207,13 +211,13 @@ export async function completionsProxyRoute(app: FastifyInstance) { tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); logProxy( - selected, requestedModel, 'success', 200, latency, null, retryCount, + selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, ); return reply.send(data); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (retryCount < MAX_RETRIES) { retryCount++; continue; @@ -238,6 +242,7 @@ async function logProxy( latencyMs: number, errorMessage: string | null, retryCount: number, + downstreamApiKeyId: number | null = null, promptTokens = 0, completionTokens = 0, totalTokens = 0, @@ -254,6 +259,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 10412a7..f79f55b 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -14,6 +14,7 @@ import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; +import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -27,6 +28,9 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { } if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -68,7 +72,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { const text = await upstream.text(); if (!upstream.ok) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ @@ -91,43 +95,16 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { return reply.code(upstream.status).send({ error: { message: text, type: 'upstream_error' } }); } - let data: any = {}; - try { data = JSON.parse(text); } catch { data = {}; } - const latency = Date.now() - startTime; - const parsedUsage = parseProxyUsage(data); - const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ - site: selected.site, - account: selected.account, - tokenValue: selected.tokenValue, - tokenName: selected.tokenName, - modelName: selected.actualModel || requestedModel, - requestStartedAtMs: startTime, - requestEndedAtMs: startTime + latency, - localLatencyMs: latency, - usage: { - promptTokens: parsedUsage.promptTokens, - completionTokens: parsedUsage.completionTokens, - totalTokens: parsedUsage.totalTokens, - }, - }); - const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - parsedUsage, - resolvedUsage, - }); - tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); logProxy( - selected, requestedModel, 'success', upstream.status, latency, null, retryCount, + selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, ); return reply.code(upstream.status).send(data); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (retryCount < MAX_RETRIES) { retryCount++; continue; @@ -150,6 +127,7 @@ async function logProxy( latencyMs: number, errorMessage: string | null, retryCount: number, + downstreamApiKeyId: number | null = null, promptTokens = 0, completionTokens = 0, totalTokens = 0, @@ -166,6 +144,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts index 1ec37d1..8ae89a3 100644 --- a/src/server/routes/proxy/endpointFlow.test.ts +++ b/src/server/routes/proxy/endpointFlow.test.ts @@ -58,6 +58,24 @@ describe('executeEndpointFlow', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('uses the injected dispatchRequest hook instead of the default fetch path', async () => { + const dispatchRequest = vi.fn(async () => toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + const result = await executeEndpointFlow({ + siteUrl: 'https://example.com', + endpointCandidates: ['responses'], + buildRequest: () => requestFor('/v1/responses'), + dispatchRequest, + }); + + expect(result.ok).toBe(true); + expect(dispatchRequest).toHaveBeenCalledTimes(1); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('avoids duplicated /v1 when base url already ends with /v1', async () => { fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { status: 200, @@ -167,6 +185,22 @@ describe('executeEndpointFlow', () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it('uses proxyUrl for the default fetch path when no dispatch hook is provided', async () => { + fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }))); + + await executeEndpointFlow({ + siteUrl: 'https://example.com', + proxyUrl: 'https://proxy.internal/base', + endpointCandidates: ['responses'], + buildRequest: () => requestFor('/v1/responses'), + }); + + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://proxy.internal/base/v1/responses'); + }); + it('returns normalized final error when all endpoints fail', async () => { fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ error: { message: 'upstream_error', type: 'upstream_error' }, diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index 33ebc72..03e5e42 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -1,5 +1,5 @@ import { fetch } from 'undici'; -import { withExplicitProxyRequestInit, withSiteProxyRequestInit } from '../../services/siteProxy.js'; +import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; import { summarizeUpstreamError } from './upstreamError.js'; import type { UpstreamEndpoint } from './upstreamEndpoint.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; @@ -9,6 +9,13 @@ export type BuiltEndpointRequest = { path: string; headers: Record; body: Record; + runtime?: { + executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude'; + modelName?: string; + stream?: boolean; + oauthProjectId?: string | null; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }; }; export type EndpointAttemptContext = { @@ -35,6 +42,7 @@ export type EndpointFlowResult = ok: false; status: number; errText: string; + rawErrText?: string; }; export function withUpstreamPath(path: string, message: string): string { @@ -46,11 +54,21 @@ type ExecuteEndpointFlowInput = { proxyUrl?: string | null; endpointCandidates: UpstreamEndpoint[]; buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest; + dispatchRequest?: ( + request: BuiltEndpointRequest, + targetUrl: string, + ) => Promise>>; tryRecover?: (ctx: EndpointAttemptContext) => Promise; shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean; onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise; }; +function buildAbsoluteUrl(base: string, path: string): string { + const normalizedBase = base.replace(/\/+$/, ''); + const normalizedPath = path.replace(/^\/+/, ''); + return normalizedPath ? `${normalizedBase}/${normalizedPath}` : normalizedBase; +} + export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise { const endpointCount = input.endpointCandidates.length; if (endpointCount <= 0) { @@ -63,19 +81,19 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom let finalStatus = 0; let finalErrText = 'unknown error'; + let finalRawErrText: string | undefined; for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) { const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint; const request = input.buildRequest(endpoint, endpointIndex); - const targetUrl = buildUpstreamUrl(input.siteUrl, request.path); + const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path); + const targetUrl = input.proxyUrl + ? buildAbsoluteUrl(input.proxyUrl, request.path) + : defaultTarget; - let response = await fetch(targetUrl, input.proxyUrl - ? withExplicitProxyRequestInit(input.proxyUrl, { - method: 'POST', - headers: request.headers, - body: JSON.stringify(request.body), - }) - : await withSiteProxyRequestInit(targetUrl, { + let response = input.dispatchRequest + ? await input.dispatchRequest(request, targetUrl) + : await fetch(targetUrl, await withSiteProxyRequestInit(targetUrl, { method: 'POST', headers: request.headers, body: JSON.stringify(request.body), @@ -130,6 +148,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom finalStatus = response.status; finalErrText = errText; + finalRawErrText = rawErrText; break; } @@ -137,5 +156,6 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom ok: false, status: finalStatus || 502, errText: finalErrText || 'unknown error', + rawErrText: finalRawErrText, }; } diff --git a/src/server/routes/proxy/gemini.test.ts b/src/server/routes/proxy/gemini.test.ts index b0abebe..1569b2d 100644 --- a/src/server/routes/proxy/gemini.test.ts +++ b/src/server/routes/proxy/gemini.test.ts @@ -4,23 +4,61 @@ import path from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const fetchMock = vi.fn(); +const fetchModelPricingCatalogMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); const selectChannelMock = vi.fn(); const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); const recordFailureMock = vi.fn(); +const explainSelectionMock = vi.fn(); const invalidateTokenRouterCacheMock = vi.fn(); const authorizeDownstreamTokenMock = vi.fn(); const consumeManagedKeyRequestMock = vi.fn(); +const isModelAllowedByPolicyOrAllowedRoutesMock = vi.fn(); +const dbSelectAllMock = vi.fn(); +const dbSelectGetMock = vi.fn(); +const dbInsertValuesMock = vi.fn((_values?: unknown) => ({ + run: () => undefined, +})); +const dbInsertMock = vi.fn((_table?: unknown) => ({ + values: (values: unknown) => dbInsertValuesMock(values), +})); + +function createDbSelectChain() { + return { + from() { + return this; + }, + innerJoin() { + return this; + }, + where() { + return this; + }, + all: (...args: unknown[]) => dbSelectAllMock(...args), + get: (...args: unknown[]) => dbSelectGetMock(...args), + }; +} vi.mock('undici', () => ({ fetch: (...args: unknown[]) => fetchMock(...args), })); +vi.mock('../../services/modelPricingService.js', () => ({ + fetchModelPricingCatalog: (...args: unknown[]) => fetchModelPricingCatalogMock(...args), +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + vi.mock('../../services/tokenRouter.js', () => ({ tokenRouter: { selectChannel: (...args: unknown[]) => selectChannelMock(...args), selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), recordFailure: (...args: unknown[]) => recordFailureMock(...args), - explainSelection: vi.fn(async () => ({ selectedChannelId: 11 })), + explainSelection: (...args: unknown[]) => explainSelectionMock(...args), }, invalidateTokenRouterCache: (...args: unknown[]) => invalidateTokenRouterCacheMock(...args), })); @@ -28,10 +66,40 @@ vi.mock('../../services/tokenRouter.js', () => ({ vi.mock('../../services/downstreamApiKeyService.js', () => ({ authorizeDownstreamToken: (...args: unknown[]) => authorizeDownstreamTokenMock(...args), consumeManagedKeyRequest: (...args: unknown[]) => consumeManagedKeyRequestMock(...args), + isModelAllowedByPolicyOrAllowedRoutes: (...args: unknown[]) => isModelAllowedByPolicyOrAllowedRoutesMock(...args), +})); + +vi.mock('../../db/index.js', () => ({ + db: { + select: (..._args: unknown[]) => createDbSelectChain(), + insert: (arg: unknown) => dbInsertMock(arg), + }, + schema: { + proxyLogs: {}, + modelAvailability: { + modelName: Symbol('modelAvailability.modelName'), + accountId: Symbol('modelAvailability.accountId'), + available: Symbol('modelAvailability.available'), + }, + accounts: { + id: Symbol('accounts.id'), + siteId: Symbol('accounts.siteId'), + status: Symbol('accounts.status'), + }, + sites: { + id: Symbol('sites.id'), + status: Symbol('sites.status'), + }, + tokenRoutes: { + displayName: Symbol('tokenRoutes.displayName'), + enabled: Symbol('tokenRoutes.enabled'), + }, + }, })); function parseSsePayloads(body: string): Array> { return body + .replace(/\r\n/g, '\n') .split('\n\n') .map((block) => block.trim()) .filter(Boolean) @@ -80,11 +148,20 @@ describe('gemini native proxy routes', () => { beforeEach(() => { fetchMock.mockReset(); + fetchModelPricingCatalogMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); selectChannelMock.mockReset(); selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); recordFailureMock.mockReset(); + explainSelectionMock.mockReset(); authorizeDownstreamTokenMock.mockReset(); consumeManagedKeyRequestMock.mockReset(); + isModelAllowedByPolicyOrAllowedRoutesMock.mockReset(); + dbInsertMock.mockClear(); + dbInsertValuesMock.mockClear(); + dbSelectAllMock.mockReset(); + dbSelectGetMock.mockReset(); authorizeDownstreamTokenMock.mockResolvedValue({ ok: true, @@ -92,6 +169,10 @@ describe('gemini native proxy routes', () => { token: 'sk-managed-gemini', policy: {}, }); + fetchModelPricingCatalogMock.mockResolvedValue(null); + refreshModelsAndRebuildRoutesMock.mockResolvedValue(undefined); + dbSelectGetMock.mockResolvedValue(null); + dbSelectAllMock.mockResolvedValue([]); selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, @@ -102,7 +183,10 @@ describe('gemini native proxy routes', () => { actualModel: 'gemini-2.5-flash', }); selectNextChannelMock.mockReturnValue(null); + recordSuccessMock.mockResolvedValue(undefined); recordFailureMock.mockResolvedValue(undefined); + explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 }); + isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true); }); afterAll(async () => { @@ -181,6 +265,98 @@ describe('gemini native proxy routes', () => { expect(secondUrl).toContain('key=gemini-key-2'); }); + it('serves gemini-cli model list from local static catalog without upstream fetch', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 21, routeId: 22 }, + site: { id: 55, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 35, + username: 'gemini-cli-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'oauth-access-token', + actualModel: 'gemini-2.5-pro', + }); + explainSelectionMock.mockImplementation(async (modelName: string) => ( + modelName === 'gemini-2.5-pro' + ? { selectedChannelId: 21 } + : { selectedChannelId: undefined } + )); + + const response = await app.inject({ + method: 'GET', + url: '/v1beta/models', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).not.toHaveBeenCalled(); + expect(response.json()).toMatchObject({ + models: expect.arrayContaining([ + { + name: 'models/gemini-2.5-pro', + displayName: 'Gemini 2.5 Pro', + }, + ]), + }); + }); + + it('synthesizes /v1beta/models from locally available routed models for non-gemini upstreams', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 41, routeId: 22 }, + site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 37, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + dbSelectAllMock + .mockResolvedValueOnce([ + { modelName: 'gpt-4.1' }, + { modelName: 'claude-sonnet-4-5-20250929' }, + ]) + .mockResolvedValueOnce([ + { displayName: 'gemini-2.5-flash' }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/v1beta/models', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).not.toHaveBeenCalled(); + expect(response.json()).toEqual({ + models: [ + { + name: 'models/claude-sonnet-4-5-20250929', + displayName: 'claude-sonnet-4-5-20250929', + }, + { + name: 'models/gemini-2.5-flash', + displayName: 'gemini-2.5-flash', + }, + { + name: 'models/gpt-4.1', + displayName: 'gpt-4.1', + }, + ], + }); + }); + it('forwards native generateContent requests through the gemini route group', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ candidates: [ @@ -241,6 +417,609 @@ describe('gemini native proxy routes', () => { }); }); + it('wraps gemini-cli native generateContent requests and unwraps the response payload', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 31, routeId: 22 }, + site: { id: 66, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 36, + username: 'gemini-cli-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'oauth-access-token', + actualModel: 'gemini-2.5-pro', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + response: { + candidates: [ + { + content: { + parts: [{ text: 'hello from gemini cli' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-pro:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://cloudcode-pa.googleapis.com/v1internal:generateContent'); + expect(requestInit.headers).toMatchObject({ + Authorization: 'Bearer oauth-access-token', + }); + expect((requestInit.headers as Record)['User-Agent']).toContain('GeminiCLI/'); + expect((requestInit.headers as Record)['X-Goog-Api-Client']).toContain('google-genai-sdk/'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + project: 'project-demo', + model: 'gemini-2.5-pro', + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + expect(response.json()).toEqual({ + responseId: '', + modelVersion: '', + candidates: [ + { + index: 0, + content: { + parts: [{ text: 'hello from gemini cli' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + }); + }); + + it('routes Gemini native generateContent requests to openai upstreams and serializes the response back to Gemini shape', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 41, routeId: 22 }, + site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 37, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-openai-1', + object: 'chat.completion', + created: 1_742_160_000, + model: 'gpt-4.1', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'hello from openai', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://api.openai.com/v1/chat/completions'); + expect(requestInit.headers).toMatchObject({ + Authorization: 'Bearer openai-access-token', + 'Content-Type': 'application/json', + }); + expect(JSON.parse(String(requestInit.body))).toEqual({ + model: 'gpt-4.1', + stream: false, + messages: [ + { + role: 'user', + content: 'hello', + }, + ], + }); + expect(response.json()).toEqual({ + responseId: 'chatcmpl-openai-1', + modelVersion: 'gpt-4.1', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'hello from openai' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 4, + candidatesTokenCount: 3, + totalTokenCount: 7, + }, + }); + }); + + it('exposes GeminiCLI downstream generateContent endpoint and wraps the downstream response payload', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 42, routeId: 22 }, + site: { id: 78, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 38, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-openai-2', + object: 'chat.completion', + created: 1_742_160_001, + model: 'gpt-4.1', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'hello from gemini cli downstream', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 6, + completion_tokens: 5, + total_tokens: 11, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1internal:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + model: 'gpt-4.1', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://api.openai.com/v1/chat/completions'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + model: 'gpt-4.1', + stream: false, + messages: [ + { + role: 'user', + content: 'hello', + }, + ], + }); + expect(response.json()).toEqual({ + response: { + responseId: 'chatcmpl-openai-2', + modelVersion: 'gpt-4.1', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'hello from gemini cli downstream' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 6, + candidatesTokenCount: 5, + totalTokenCount: 11, + }, + }, + }); + }); + + it('routes Gemini native generateContent requests to antigravity upstreams through the internal content endpoint', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 43, routeId: 22 }, + site: { id: 79, name: 'antigravity-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'antigravity' }, + account: { + id: 39, + username: 'antigravity-user@example.com', + extraConfig: JSON.stringify({ + oauth: { + provider: 'antigravity', + email: 'antigravity-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'antigravity-access-token', + actualModel: 'gemini-3-pro-preview', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + response: { + responseId: 'antigravity-response-1', + modelVersion: 'gemini-3-pro-preview', + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'hello from antigravity' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 4, + totalTokenCount: 12, + }, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-3-pro-preview:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent'); + expect(requestInit.headers).toMatchObject({ + Authorization: 'Bearer antigravity-access-token', + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + }); + const upstreamBody = JSON.parse(String(requestInit.body)); + expect(upstreamBody).toMatchObject({ + project: 'project-demo', + model: 'gemini-3-pro-preview', + userAgent: 'antigravity', + requestType: 'agent', + request: { + sessionId: expect.any(String), + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + expect(upstreamBody.requestId).toMatch(/^agent-[0-9a-f-]{36}$/i); + expect(String(upstreamBody.request.sessionId)).toMatch(/^-\d+$/); + expect(response.json()).toEqual({ + responseId: 'antigravity-response-1', + modelVersion: 'gemini-3-pro-preview', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'hello from antigravity' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 4, + totalTokenCount: 12, + }, + }); + }); + + it('exposes GeminiCLI downstream streamGenerateContent endpoint and preserves GeminiCLI response envelopes', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 44, routeId: 22 }, + site: { id: 80, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 40, + username: 'gemini-cli-user@example.com', + extraConfig: JSON.stringify({ + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'gemini-cli-access-token', + actualModel: 'gemini-2.5-pro', + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"response":{"responseId":"cli-stream-1","candidates":[{"content":{"role":"model","parts":[{"text":"hello from cli stream"}]},"finishReason":"STOP"}]}}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1internal:streamGenerateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('data: {"response":{"responseId":"cli-stream-1"'); + expect(response.body).toContain('hello from cli stream'); + }); + + it('exposes GeminiCLI downstream countTokens endpoint', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 45, routeId: 22 }, + site: { id: 81, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 41, + username: 'gemini-cli-user@example.com', + extraConfig: JSON.stringify({ + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'gemini-cli-access-token', + actualModel: 'gemini-2.5-pro', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + totalTokens: 13, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 13, + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1internal:countTokens', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://cloudcode-pa.googleapis.com/v1internal:countTokens'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + expect(response.json()).toEqual({ + totalTokens: 13, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 13, + }, + ], + }); + }); + + it('filters Gemini native model list by downstream managed-key policy', async () => { + authorizeDownstreamTokenMock.mockResolvedValue({ + ok: true, + source: 'managed', + token: 'sk-managed-gemini', + key: { id: 91 }, + policy: { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} }, + }); + isModelAllowedByPolicyOrAllowedRoutesMock.mockImplementation(async (modelName: string) => modelName === 'gemini-2.5-flash'); + explainSelectionMock.mockImplementation(async (modelName: string) => ( + modelName === 'gemini-2.5-flash' + ? { selectedChannelId: 11 } + : { selectedChannelId: undefined } + )); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + models: [ + { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, + { name: 'models/gemini-2.0-flash', displayName: 'Gemini 2.0 Flash' }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'GET', + url: '/v1beta/models', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + models: [ + { + name: 'models/gemini-2.5-flash', + displayName: 'Gemini 2.5 Flash', + }, + ], + }); + expect(isModelAllowedByPolicyOrAllowedRoutesMock).toHaveBeenCalledWith('gemini-2.5-flash', { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} }); + expect(isModelAllowedByPolicyOrAllowedRoutesMock).toHaveBeenCalledWith('gemini-2.0-flash', { supportedModels: ['gemini-2.5-flash'], allowedRouteIds: [], siteWeightMultipliers: {} }); + }); + + it('writes a proxy log row for successful native generateContent requests', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + candidates: [ + { + content: { + parts: [{ text: 'hello from gemini' }], + role: 'model', + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:generateContent', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(recordSuccessMock).toHaveBeenCalledWith(11, expect.any(Number), 0); + expect(dbInsertMock).toHaveBeenCalledTimes(1); + expect(dbInsertValuesMock).toHaveBeenCalledWith(expect.objectContaining({ + routeId: 22, + channelId: 11, + accountId: 33, + modelRequested: 'gemini-2.5-flash', + modelActual: 'gemini-2.5-flash', + status: 'success', + httpStatus: 200, + retryCount: 0, + promptTokens: 10, + completionTokens: 5, + totalTokens: 15, + errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:generateContent] [upstream:/v1beta/models/gemini-2.5-flash:generateContent]', + createdAt: expect.any(String), + })); + }); + it('forwards explicit gemini version paths through transformer-owned parsing helpers', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ candidates: [ @@ -390,21 +1169,32 @@ describe('gemini native proxy routes', () => { }); }); - it('keeps non-sse json-array streaming payloads on the wire as chunk responses', async () => { - fetchMock.mockResolvedValue(new Response(JSON.stringify([ + it('keeps non-sse json-array streaming payloads on the wire as raw chunk responses', async () => { + const upstreamPayload = [ { + promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' }, candidates: [ { - content: { role: 'model', parts: [{ text: 'first' }] }, + content: { + role: 'model', + parts: [ + { + functionCall: { id: 'tool-1', name: 'lookup_weather', args: { city: 'Shanghai' } }, + thoughtSignature: 'sig-tool-1', + }, + ], + }, groundingMetadata: { source: 'web' }, }, ], }, { + serverContent: { modelTurn: { parts: [{ text: 'tool result received' }] } }, candidates: [ { content: { role: 'model', parts: [{ text: 'second', thoughtSignature: 'sig-1' }] }, citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] }, + finishReason: 'STOP', }, ], usageMetadata: { @@ -415,7 +1205,9 @@ describe('gemini native proxy routes', () => { thoughtsTokenCount: 3, }, }, - ]), { + ]; + + fetchMock.mockResolvedValue(new Response(JSON.stringify(upstreamPayload), { status: 200, headers: { 'content-type': 'application/json' }, })); @@ -437,45 +1229,7 @@ describe('gemini native proxy routes', () => { }); expect(response.statusCode).toBe(200); - expect(response.json()).toEqual([ - { - responseId: '', - modelVersion: '', - candidates: [ - { - index: 0, - finishReason: 'STOP', - content: { - role: 'model', - parts: [{ text: 'first' }], - }, - groundingMetadata: { source: 'web' }, - }, - ], - }, - { - responseId: '', - modelVersion: '', - candidates: [ - { - index: 0, - finishReason: 'STOP', - content: { - role: 'model', - parts: [{ text: 'second', thoughtSignature: 'sig-1' }], - }, - citationMetadata: { citations: [{ startIndex: 0, endIndex: 5 }] }, - }, - ], - usageMetadata: { - promptTokenCount: 11, - candidatesTokenCount: 6, - totalTokenCount: 17, - cachedContentTokenCount: 2, - thoughtsTokenCount: 3, - }, - }, - ]); + expect(response.json()).toEqual(upstreamPayload); }); it('derives gemini-3 thinkingLevel from OpenAI-style reasoning inputs in the runtime request path', async () => { @@ -535,12 +1289,13 @@ describe('gemini native proxy routes', () => { }); }); - it('streams SSE payloads as normalized chunk events instead of cumulative aggregate snapshots', async () => { + it('streams SSE payloads as raw upstream events without reserializing tool-calling chunks', async () => { const encoder = new TextEncoder(); const upstreamBody = new ReadableStream({ start(controller) { - controller.enqueue(encoder.encode('data: {"responseId":"resp-sse","modelVersion":"gemini-2.5-flash","candidates":[{"content":{"role":"model","parts":[{"text":"first"}]},"groundingMetadata":{"source":"web"}}]}\r\n\r\n')); - controller.enqueue(encoder.encode('data: {"candidates":[{"content":{"role":"model","parts":[{"text":"second","thoughtSignature":"sig-1"}]},"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17,"cachedContentTokenCount":2,"thoughtsTokenCount":3}}\r\n\r\n')); + controller.enqueue(encoder.encode('data: {"promptFeedback":{"blockReason":"BLOCK_REASON_UNSPECIFIED"},"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"id":"tool-1","name":"lookup_weather","args":{"city":"Shanghai"}},"thoughtSignature":"sig-tool-1"}]},"groundingMetadata":{"source":"web"}}]}\r\n\r\n')); + controller.enqueue(encoder.encode('data: {"serverContent":{"modelTurn":{"parts":[{"text":"tool result received"}]}},"candidates":[{"content":{"role":"model","parts":[{"text":"second","thoughtSignature":"sig-1"}]},"citationMetadata":{"citations":[{"startIndex":0,"endIndex":5}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17,"cachedContentTokenCount":2,"thoughtsTokenCount":3}}\r\n\r\n')); + controller.enqueue(encoder.encode('data: [DONE]\r\n\r\n')); controller.close(); }, }); @@ -572,25 +1327,30 @@ describe('gemini native proxy routes', () => { expect(events).toHaveLength(2); expect(events[0]).toMatchObject({ - responseId: 'resp-sse', - modelVersion: 'gemini-2.5-flash', + promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' }, candidates: [ { content: { - parts: [{ text: 'first' }], + parts: [ + { + functionCall: { id: 'tool-1', name: 'lookup_weather', args: { city: 'Shanghai' } }, + thoughtSignature: 'sig-tool-1', + }, + ], }, groundingMetadata: { source: 'web' }, }, ], }); - expect(events[0]).not.toMatchObject({ - candidates: [ - { - citationMetadata: expect.anything(), - }, - ], - }); + expect(events[0]).not.toHaveProperty('responseId'); + expect(events[0]).not.toHaveProperty('modelVersion'); + expect(events[0].candidates?.[0]).not.toHaveProperty('finishReason'); expect(events[1]).toMatchObject({ + serverContent: { + modelTurn: { + parts: [{ text: 'tool result received' }], + }, + }, usageMetadata: { promptTokenCount: 11, candidatesTokenCount: 6, @@ -608,14 +1368,8 @@ describe('gemini native proxy routes', () => { }, ], }); - expect(events[1]).not.toMatchObject({ - candidates: [ - { - groundingMetadata: expect.anything(), - }, - ], - }); - expect(response.body).not.toContain('\r\n\r\n'); + expect(response.body).toContain('\r\n\r\n'); + expect(response.body).toContain('data: [DONE]\r\n\r\n'); }); it('falls back to the next channel when first Gemini channel returns 400 before any bytes are written', async () => { @@ -842,4 +1596,67 @@ describe('gemini native proxy routes', () => { expect(recordFailureMock).toHaveBeenCalledWith(11); expect(response.body).toContain('hello from fallback sse'); }); + + it('writes failed and successful proxy log rows for Gemini-native stream retries', async () => { + selectNextChannelMock.mockReturnValue({ + channel: { id: 12, routeId: 22 }, + site: { id: 45, name: 'gemini-site-2', url: 'https://generativelanguage.googleapis.com', platform: 'gemini' }, + account: { id: 34, username: 'demo-user-2' }, + tokenName: 'fallback', + tokenValue: 'gemini-key-2', + actualModel: 'gemini-2.5-flash', + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"responseId":"resp-fallback","candidates":[{"content":{"role":"model","parts":[{"text":"hello from fallback sse"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":11,"candidatesTokenCount":6,"totalTokenCount":17}}\r\n\r\n')); + controller.close(); + }, + }); + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'upstream unavailable' }, + }), { + status: 500, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse', + headers: { + 'x-goog-api-key': 'sk-managed-gemini', + }, + payload: { + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(dbInsertMock).toHaveBeenCalledTimes(2); + expect(dbInsertValuesMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + channelId: 11, + status: 'failed', + httpStatus: 500, + retryCount: 0, + errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] [upstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] {\"error\":{\"message\":\"upstream unavailable\"}}', + })); + expect(dbInsertValuesMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + channelId: 12, + status: 'success', + httpStatus: 200, + retryCount: 1, + promptTokens: 11, + completionTokens: 6, + totalTokens: 17, + errorMessage: '[downstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent] [upstream:/v1beta/models/gemini-2.5-flash:streamGenerateContent]', + })); + expect(recordSuccessMock).toHaveBeenCalledWith(12, expect.any(Number), 0); + }); }); diff --git a/src/server/routes/proxy/gemini.ts b/src/server/routes/proxy/gemini.ts index 7a80d84..afcfc94 100644 --- a/src/server/routes/proxy/gemini.ts +++ b/src/server/routes/proxy/gemini.ts @@ -1,11 +1,34 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { TextDecoder } from 'node:util'; import { fetch } from 'undici'; +import { and, eq } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { parseProxyUsage } from '../../services/proxyUsageParser.js'; +import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstreamApiKeyService.js'; import { tokenRouter } from '../../services/tokenRouter.js'; +import { buildOauthProviderHeaders } from '../../services/oauth/service.js'; +import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; +import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; +import { executeEndpointFlow, type BuiltEndpointRequest } from './endpointFlow.js'; +import { composeProxyLogMessage } from './logPathMeta.js'; +import { + buildUpstreamEndpointRequest, + resolveUpstreamEndpointCandidates, +} from './upstreamEndpoint.js'; import { geminiGenerateContentTransformer, } from '../../transformers/gemini/generate-content/index.js'; +import { createChatEndpointStrategy } from '../../transformers/shared/chatEndpointStrategy.js'; +import { normalizeUpstreamFinalResponse } from '../../transformers/shared/normalized.js'; +import { + createGeminiCliStreamReader, + unwrapGeminiCliPayload, + wrapGeminiCliRequest, +} from './geminiCliCompat.js'; +import { dispatchRuntimeRequest } from './runtimeExecutor.js'; const MAX_RETRIES = 2; const GEMINI_MODEL_PROBES = [ @@ -14,6 +37,52 @@ const GEMINI_MODEL_PROBES = [ 'gemini-1.5-flash', 'gemini-pro', ]; +const GEMINI_CLI_STATIC_MODELS = [ + { name: 'models/gemini-2.5-pro', displayName: 'Gemini 2.5 Pro' }, + { name: 'models/gemini-2.5-flash', displayName: 'Gemini 2.5 Flash' }, + { name: 'models/gemini-2.5-flash-lite', displayName: 'Gemini 2.5 Flash Lite' }, + { name: 'models/gemini-3-pro-preview', displayName: 'Gemini 3 Pro Preview' }, + { name: 'models/gemini-3.1-pro-preview', displayName: 'Gemini 3.1 Pro Preview' }, + { name: 'models/gemini-3-flash-preview', displayName: 'Gemini 3 Flash Preview' }, + { name: 'models/gemini-3.1-flash-lite-preview', displayName: 'Gemini 3.1 Flash Lite Preview' }, +]; +const EMPTY_PROXY_USAGE = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, +}; + +function isGeminiCliPlatform(platform: unknown): boolean { + return String(platform || '').trim().toLowerCase() === 'gemini-cli'; +} + +function isAntigravityPlatform(platform: unknown): boolean { + return String(platform || '').trim().toLowerCase() === 'antigravity'; +} + +function isInternalGeminiPlatform(platform: unknown): boolean { + return isGeminiCliPlatform(platform) || isAntigravityPlatform(platform); +} + +function buildGeminiCliActionPath(input: { + isStreamAction: boolean; + isCountTokensAction: boolean; +}) { + if (input.isCountTokensAction) return '/v1internal:countTokens'; + if (input.isStreamAction) return '/v1internal:streamGenerateContent?alt=sse'; + return '/v1internal:generateContent'; +} + +function isDirectGeminiFamilyPlatform(platform: unknown): boolean { + const normalized = String(platform || '').trim().toLowerCase(); + return normalized === 'gemini' || normalized === 'gemini-cli' || normalized === 'antigravity'; +} + +function omitGeminiCliModelField(body: unknown): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) return {}; + const { model: _model, ...rest } = body as Record; + return rest; +} async function selectGeminiChannel(request: FastifyRequest) { const policy = getDownstreamRoutingPolicy(request); @@ -33,6 +102,141 @@ async function selectNextGeminiProbeChannel(request: FastifyRequest, excludeChan return null; } +function resolveDownstreamPath(request: FastifyRequest): string { + const rawUrl = request.raw.url || request.url || ''; + const withoutQuery = rawUrl.split('?')[0] || rawUrl; + return withoutQuery || '/v1beta/models'; +} + +function resolveUpstreamPath(apiVersion: string, modelActionPath: string): string { + const normalizedVersion = apiVersion.replace(/^\/+/, ''); + const normalizedAction = modelActionPath.replace(/^\/+/, ''); + return `/${normalizedVersion}/${normalizedAction}`; +} + +function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown }): boolean { + const supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : []; + const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : []; + return supportedModels.length > 0 || allowedRouteIds.length > 0; +} + +function extractGeminiListedModelName(item: unknown): string { + if (!item || typeof item !== 'object') return ''; + const rawName = typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name.trim() + : ''; + if (!rawName) return ''; + return rawName.startsWith('models/') ? rawName.slice('models/'.length) : rawName; +} + +async function filterGeminiListedModelsForPolicy( + payload: unknown, + request: FastifyRequest, +): Promise { + if (!payload || typeof payload !== 'object' || !Array.isArray((payload as { models?: unknown[] }).models)) { + return payload; + } + + const policy = getDownstreamRoutingPolicy(request); + if (!hasDownstreamModelRestrictions(policy)) { + return payload; + } + + const filteredModels: unknown[] = []; + for (const item of (payload as { models: unknown[] }).models) { + const modelName = extractGeminiListedModelName(item); + if (!modelName) continue; + if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue; + const decision = await tokenRouter.explainSelection?.(modelName, [], policy); + if (decision && typeof decision.selectedChannelId !== 'number') continue; + filteredModels.push(item); + } + + return { + ...(payload as Record), + models: filteredModels, + }; +} + +async function readRouteAwareGeminiModels(request: FastifyRequest): Promise> { + const policy = getDownstreamRoutingPolicy(request); + const rows = await db.select({ modelName: schema.modelAvailability.modelName }) + .from(schema.modelAvailability) + .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(and( + eq(schema.modelAvailability.available, true), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + )) + .all(); + const routeAliases = await db.select({ displayName: schema.tokenRoutes.displayName }) + .from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.enabled, true)) + .all(); + const deduped = Array.from(new Set([ + ...rows.map((row) => String(row.modelName || '').trim()).filter(Boolean), + ...routeAliases.map((row) => String(row.displayName || '').trim()).filter(Boolean), + ])).sort(); + + const allowed: Array<{ name: string; displayName: string }> = []; + for (const modelName of deduped) { + if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue; + const decision = await tokenRouter.explainSelection?.(modelName, [], policy); + if (decision && typeof decision.selectedChannelId !== 'number') continue; + allowed.push({ + name: `models/${modelName}`, + displayName: modelName, + }); + } + + return allowed; +} + +async function logProxy( + selected: any, + modelRequested: string, + status: string, + httpStatus: number, + latencyMs: number, + errorMessage: string | null, + retryCount: number, + downstreamPath: string, + upstreamPath: string | null, + promptTokens = 0, + completionTokens = 0, + totalTokens = 0, +) { + try { + const createdAt = formatUtcSqlDateTime(new Date()); + const normalizedErrorMessage = composeProxyLogMessage({ + downstreamPath, + upstreamPath, + errorMessage, + }); + await db.insert(schema.proxyLogs).values({ + routeId: selected.channel.routeId, + channelId: selected.channel.id, + accountId: selected.account.id, + modelRequested, + modelActual: selected.actualModel || modelRequested, + status, + httpStatus, + latencyMs, + promptTokens, + completionTokens, + totalTokens, + estimatedCost: 0, + billingDetails: null, + errorMessage: normalizedErrorMessage, + retryCount, + createdAt, + }).run(); + } catch (error) { + console.warn('[proxy/gemini] failed to write proxy log', error); + } +} + export async function geminiProxyRoute(app: FastifyInstance) { const listModels = async (request: FastifyRequest, reply: FastifyReply) => { const apiVersion = geminiGenerateContentTransformer.resolveProxyApiVersion( @@ -55,6 +259,23 @@ export async function geminiProxyRoute(app: FastifyInstance) { excludeChannelIds.push(selected.channel.id); try { + if (!isDirectGeminiFamilyPlatform(selected.site.platform)) { + let models = await readRouteAwareGeminiModels(request); + if (models.length <= 0) { + await refreshModelsAndRebuildRoutes(); + models = await readRouteAwareGeminiModels(request); + } + return reply.code(200).send({ models }); + } + + if (isGeminiCliPlatform(selected.site.platform)) { + const filtered = await filterGeminiListedModelsForPolicy( + { models: GEMINI_CLI_STATIC_MODELS }, + request, + ); + return reply.code(200).send(filtered); + } + const upstream = await fetch( geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue), { method: 'GET' }, @@ -72,7 +293,9 @@ export async function geminiProxyRoute(app: FastifyInstance) { } try { - return reply.code(upstream.status).send(JSON.parse(text)); + const parsed = JSON.parse(text); + const filtered = await filterGeminiListedModelsForPolicy(parsed, request); + return reply.code(upstream.status).send(filtered); } catch { return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text); } @@ -94,12 +317,36 @@ export async function geminiProxyRoute(app: FastifyInstance) { } }; - const generateContent = async (request: FastifyRequest, reply: FastifyReply) => { - const parsedPath = geminiGenerateContentTransformer.parseProxyRequestPath({ - rawUrl: request.raw.url || request.url || '', - params: request.params as { geminiApiVersion?: string } | undefined, - }); + const handleGenerateContent = async ( + request: FastifyRequest, + reply: FastifyReply, + options?: { + downstreamProtocol?: 'gemini' | 'gemini-cli'; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }, + ) => { + const downstreamProtocol = options?.downstreamProtocol || 'gemini'; + const isGeminiCliDownstream = downstreamProtocol === 'gemini-cli'; + const cliRequestedModel = isGeminiCliDownstream + ? (typeof (request.body as Record | null | undefined)?.model === 'string' + ? String((request.body as Record).model).trim() + : '') + : ''; + const parsedPath = isGeminiCliDownstream + ? { + apiVersion: 'v1beta', + modelActionPath: `models/${cliRequestedModel}:${options?.action || 'generateContent'}`, + isStreamAction: options?.action === 'streamGenerateContent', + requestedModel: cliRequestedModel, + } + : geminiGenerateContentTransformer.parseProxyRequestPath({ + rawUrl: request.raw.url || request.url || '', + params: request.params as { geminiApiVersion?: string } | undefined, + }); const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath; + const isCountTokensAction = isGeminiCliDownstream + ? options?.action === 'countTokens' + : modelActionPath.endsWith(':countTokens'); if (!requestedModel) { return reply.code(400).send({ error: { message: 'Gemini model path is required', type: 'invalid_request_error' }, @@ -107,6 +354,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { } const policy = getDownstreamRoutingPolicy(request); + const downstreamPath = resolveDownstreamPath(request); const excludeChannelIds: number[] = []; let retryCount = 0; let lastStatus = 503; @@ -123,109 +371,424 @@ export async function geminiProxyRoute(app: FastifyInstance) { excludeChannelIds.push(selected.channel.id); - const body = geminiGenerateContentTransformer.inbound.normalizeRequest( - request.body || {}, - selected.actualModel || requestedModel, + const actualModel = selected.actualModel || requestedModel; + const normalizedBody = geminiGenerateContentTransformer.inbound.normalizeRequest( + isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}), + actualModel, ); + const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const isGeminiCli = isGeminiCliPlatform(selected.site.platform); + const isInternalGemini = isInternalGeminiPlatform(selected.site.platform); + const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform); + const startTime = Date.now(); + let upstreamPath = ''; - const actualModelAction = modelActionPath.replace( - /^models\/[^:]+/, - `models/${selected.actualModel || requestedModel}`, - ); - const query = new URLSearchParams(request.query as Record).toString(); try { - const upstream = await fetch( - geminiGenerateContentTransformer.resolveActionUrl( - selected.site.url, - apiVersion, - actualModelAction, - selected.tokenValue, - query, - ), - { - method: 'POST', - headers: { + if (isDirectGeminiFamily) { + if (isGeminiCli && !oauth?.projectId) { + lastStatus = 500; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: 'Gemini CLI OAuth project is missing', + type: 'server_error', + }, + }); + await tokenRouter.recordFailure?.(selected.channel.id); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + const actualModelAction = modelActionPath.replace( + /^models\/[^:]+/, + `models/${actualModel}`, + ); + upstreamPath = isInternalGemini + ? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction }) + : resolveUpstreamPath(apiVersion, actualModelAction); + const query = new URLSearchParams(request.query as Record).toString(); + const requestBody = isInternalGemini + ? ( + isCountTokensAction + ? { request: normalizedBody } + : wrapGeminiCliRequest({ + modelName: actualModel, + projectId: oauth?.projectId || '', + request: normalizedBody as Record, + }) + ) + : normalizedBody; + const requestHeaders = isInternalGemini + ? { 'Content-Type': 'application/json', + ...(isStreamAction ? { Accept: 'text/event-stream' } : {}), + Authorization: `Bearer ${selected.tokenValue}`, + ...buildOauthProviderHeaders({ + extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + downstreamHeaders: request.headers as Record, + }), + } + : { + 'Content-Type': 'application/json', + }; + const targetUrl = isInternalGemini + ? `${selected.site.url}${upstreamPath}` + : geminiGenerateContentTransformer.resolveActionUrl( + selected.site.url, + apiVersion, + actualModelAction, + selected.tokenValue, + query, + ); + const upstream = isInternalGemini + ? await dispatchRuntimeRequest({ + siteUrl: selected.site.url, + targetUrl, + request: { + endpoint: 'chat', + path: upstreamPath, + headers: requestHeaders, + body: requestBody as Record, + runtime: { + executor: isGeminiCli ? 'gemini-cli' : 'antigravity', + modelName: actualModel, + stream: isStreamAction, + oauthProjectId: oauth?.projectId || null, + action: isCountTokensAction + ? 'countTokens' + : (isStreamAction ? 'streamGenerateContent' : 'generateContent'), + }, + }, + buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }) + : await fetch(targetUrl, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(requestBody), + }); + const contentType = upstream.headers.get('content-type') || 'application/json'; + if (!upstream.ok) { + lastStatus = upstream.status; + lastContentType = contentType; + lastText = await upstream.text(); + await tokenRouter.recordFailure?.(selected.channel.id); + await logProxy( + selected, + requestedModel, + 'failed', + lastStatus, + Date.now() - startTime, + lastText, + retryCount, + downstreamPath, + upstreamPath, + ); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + try { + return reply.code(lastStatus).send(JSON.parse(lastText)); + } catch { + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + } + + if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) { + reply.hijack(); + reply.raw.statusCode = upstream.status; + reply.raw.setHeader('Content-Type', contentType || 'text/event-stream'); + const upstreamReader = upstream.body?.getReader(); + const reader = isInternalGemini && !isGeminiCliDownstream && upstreamReader + ? createGeminiCliStreamReader(upstreamReader) + : upstreamReader; + if (!reader) { + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + ); + reply.raw.end(); + return; + } + const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + const decoder = new TextDecoder(); + let rest = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + const chunkText = decoder.decode(value, { stream: true }); + const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( + aggregateState, + rest + chunkText, + ); + rest = consumed.rest; + for (const line of consumed.lines) { + reply.raw.write(line); + } + } + const tail = decoder.decode(); + if (tail) { + const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( + aggregateState, + rest + tail, + ); + for (const line of consumed.lines) { + reply.raw.write(line); + } + } + } finally { + reader.releaseLock(); + reply.raw.end(); + } + const parsedUsage = parseProxyUsage(aggregateState); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return; + } + + const text = await upstream.text(); + const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + let parsedUsage = EMPTY_PROXY_USAGE; + try { + const parsed = JSON.parse(text); + const unwrappedPayload = isInternalGemini && !isGeminiCliDownstream + ? unwrapGeminiCliPayload(parsed) + : parsed; + const responsePayload = isCountTokensAction + ? unwrappedPayload + : geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload( + aggregateState, + unwrappedPayload, + isStreamAction, + ); + parsedUsage = parseProxyUsage(aggregateState); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return reply.code(upstream.status).send( + isGeminiCliDownstream && !isCountTokensAction + ? { response: responsePayload } + : responsePayload, + ); + } catch { + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + ); + return reply.code(upstream.status).type(contentType || 'application/json').send(text); + } + } + + if (isCountTokensAction) { + lastStatus = 501; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: 'Gemini countTokens compatibility is not implemented for this upstream', + type: 'invalid_request_error', }, - body: JSON.stringify(body), + }); + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + const openAiBody = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({ + body: normalizedBody as Record, + modelName: actualModel, + stream: isStreamAction, + }); + const endpointCandidates = await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, }, + actualModel, + 'openai', + requestedModel, ); - const contentType = upstream.headers.get('content-type') || 'application/json'; - if (!upstream.ok) { - lastStatus = upstream.status; - lastContentType = contentType; - lastText = await upstream.text(); + const buildEndpointRequest = ( + endpoint: 'chat' | 'messages' | 'responses', + requestOptions: { forceNormalizeClaudeBody?: boolean } = {}, + ) => { + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName: actualModel, + stream: isStreamAction, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + oauthProjectId: oauth?.projectId, + sitePlatform: selected.site.platform, + siteUrl: selected.site.url, + openaiBody: openAiBody, + downstreamFormat: 'openai', + forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody, + downstreamHeaders: request.headers as Record, + providerHeaders: buildOauthProviderHeaders({ + extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + downstreamHeaders: request.headers as Record, + }), + }); + return { + endpoint, + path: endpointRequest.path, + headers: endpointRequest.headers, + body: endpointRequest.body as Record, + runtime: endpointRequest.runtime, + }; + }; + const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => ( + dispatchRuntimeRequest({ + siteUrl: selected.site.url, + targetUrl, + request: compatibilityRequest, + buildInit: async (requestUrl, requestForFetch) => withSiteProxyRequestInit(requestUrl, { + method: 'POST', + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }) + ); + const endpointStrategy = createChatEndpointStrategy({ + downstreamFormat: 'openai', + endpointCandidates, + modelName: actualModel, + requestedModelHint: requestedModel, + sitePlatform: selected.site.platform, + isStream: isStreamAction, + buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( + endpoint, + { forceNormalizeClaudeBody }, + ), + dispatchRequest, + }); + const endpointResult = await executeEndpointFlow({ + siteUrl: selected.site.url, + endpointCandidates, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + dispatchRequest, + tryRecover: endpointStrategy.tryRecover, + shouldDowngrade: endpointStrategy.shouldDowngrade, + }); + if (!endpointResult.ok) { + lastStatus = endpointResult.status; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: endpointResult.errText, + type: 'upstream_error', + }, + }); await tokenRouter.recordFailure?.(selected.channel.id); + await logProxy( + selected, + requestedModel, + 'failed', + lastStatus, + Date.now() - startTime, + endpointResult.errText, + retryCount, + downstreamPath, + null, + ); if (retryCount < MAX_RETRIES) { retryCount += 1; continue; } - - try { - return reply.code(lastStatus).send(JSON.parse(lastText)); - } catch { - return reply.code(lastStatus).type(lastContentType).send(lastText); - } + return reply.code(lastStatus).type(lastContentType).send(lastText); } - if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) { - reply.hijack(); - reply.raw.statusCode = upstream.status; - reply.raw.setHeader('Content-Type', contentType || 'text/event-stream'); - const reader = upstream.body?.getReader(); - if (!reader) { - reply.raw.end(); - return; - } - const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); - const decoder = new TextDecoder(); - let rest = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - const chunkText = decoder.decode(value, { stream: true }); - const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( - aggregateState, - rest + chunkText, - ); - rest = consumed.rest; - for (const line of consumed.lines) { - reply.raw.write(line); - } - } - const tail = decoder.decode(); - if (tail) { - const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( - aggregateState, - rest + tail, - ); - for (const line of consumed.lines) { - reply.raw.write(line); - } - } - } finally { - reader.releaseLock(); - reply.raw.end(); - } - return; - } - - const text = await upstream.text(); + upstreamPath = endpointResult.upstreamPath; + const upstream = endpointResult.upstream; + const rawText = await upstream.text(); + let upstreamData: unknown = rawText; try { - const parsed = JSON.parse(text); - const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); - return reply.code(upstream.status).send( - geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload( - aggregateState, - parsed, - isStreamAction, - ), - ); - } catch { - return reply.code(upstream.status).type(contentType || 'application/json').send(text); - } + upstreamData = JSON.parse(rawText); + } catch {} + const parsedUsage = parseProxyUsage(upstreamData); + const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, actualModel, rawText); + const geminiResponse = geminiGenerateContentTransformer.compatibility.serializeNormalizedFinalToGemini({ + normalized: normalizedFinal, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return reply.code(upstream.status).send( + isGeminiCliDownstream + ? { response: geminiResponse } + : geminiResponse, + ); } catch (error) { lastStatus = 502; lastContentType = 'application/json'; @@ -236,6 +799,17 @@ export async function geminiProxyRoute(app: FastifyInstance) { }, }); await tokenRouter.recordFailure?.(selected.channel.id); + await logProxy( + selected, + requestedModel, + 'failed', + 0, + Date.now() - startTime, + error instanceof Error ? error.message : 'Gemini upstream request failed', + retryCount, + downstreamPath, + upstreamPath || null, + ); if (retryCount < MAX_RETRIES) { retryCount += 1; continue; @@ -245,8 +819,25 @@ export async function geminiProxyRoute(app: FastifyInstance) { } }; + const generateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply); + const geminiCliGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'generateContent', + }); + const geminiCliStreamGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'streamGenerateContent', + }); + const geminiCliCountTokens = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'countTokens', + }); + app.get('/v1beta/models', listModels); app.get('/gemini/:geminiApiVersion/models', listModels); app.post('/v1beta/models/*', generateContent); app.post('/gemini/:geminiApiVersion/models/*', generateContent); + app.post('/v1internal::generateContent', geminiCliGenerateContent); + app.post('/v1internal::streamGenerateContent', geminiCliStreamGenerateContent); + app.post('/v1internal::countTokens', geminiCliCountTokens); } diff --git a/src/server/routes/proxy/geminiCliCompat.thoughtSignature.test.ts b/src/server/routes/proxy/geminiCliCompat.thoughtSignature.test.ts new file mode 100644 index 0000000..5fe466a --- /dev/null +++ b/src/server/routes/proxy/geminiCliCompat.thoughtSignature.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; +import { buildGeminiGenerateContentRequestFromOpenAi } from './geminiCliCompat.js'; + +describe('thoughtSignature injection in OpenAI→Gemini conversion', () => { + it('injects thoughtSignature from provider_specific_fields into functionCall parts', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'gemini-3-flash-preview', + messages: [ + { role: 'user', content: 'What is the weather?' }, + { + role: 'assistant', + content: 'Let me check.', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { name: 'get_weather', arguments: '{"city":"Tokyo"}' }, + provider_specific_fields: { thought_signature: 'real_sig_abc' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_123', content: '{"temp":"22C"}' }, + ], + }, + modelName: 'gemini-3-flash-preview', + }) as Record; + + const contents = result.contents as Array>; + // Find the model message with functionCall + const modelMsgs = contents.filter((c) => c.role === 'model'); + const fcParts = modelMsgs + .flatMap((m) => (m.parts as Array>)) + .filter((p) => 'functionCall' in p); + + expect(fcParts.length).toBe(1); + expect(fcParts[0].thoughtSignature).toBe('real_sig_abc'); + }); + + it('splits text and signed functionCall parts into separate model messages', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'gemini-3-flash-preview', + messages: [ + { role: 'user', content: 'Read the file.' }, + { + role: 'assistant', + content: 'I will read it.', + tool_calls: [ + { + id: 'call_456', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/tmp/x"}' }, + provider_specific_fields: { thought_signature: 'sig_split_test' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_456', content: 'file content here' }, + ], + }, + modelName: 'gemini-3-flash-preview', + }) as Record; + + const contents = result.contents as Array>; + const modelMsgs = contents.filter((c) => c.role === 'model'); + + // Should be 2 model messages: text (unsigned) + functionCall (signed) + expect(modelMsgs.length).toBe(2); + + const firstParts = modelMsgs[0].parts as Array>; + const secondParts = modelMsgs[1].parts as Array>; + + // First: text only, no thoughtSignature + expect(firstParts.every((p) => 'text' in p)).toBe(true); + expect(firstParts.every((p) => !('thoughtSignature' in p))).toBe(true); + + // Second: functionCall with thoughtSignature + expect(secondParts.every((p) => 'functionCall' in p)).toBe(true); + expect(secondParts[0].thoughtSignature).toBe('sig_split_test'); + }); + + it('injects dummy sentinel when thinking enabled but no signature available', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'gemini-3-flash-preview', + reasoning_effort: 'high', + messages: [ + { role: 'user', content: 'Do something.' }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_no_sig', + type: 'function', + function: { name: 'Bash', arguments: '{"command":"ls"}' }, + // No provider_specific_fields — signature missing + }, + ], + }, + { role: 'tool', tool_call_id: 'call_no_sig', content: 'file1\nfile2' }, + ], + }, + modelName: 'gemini-3-flash-preview', + }) as Record; + + const contents = result.contents as Array>; + const modelMsgs = contents.filter((c) => c.role === 'model'); + const fcParts = modelMsgs + .flatMap((m) => (m.parts as Array>)) + .filter((p) => 'functionCall' in p); + + expect(fcParts.length).toBe(1); + // Should have a dummy sentinel, not be missing + expect(typeof fcParts[0].thoughtSignature).toBe('string'); + expect((fcParts[0].thoughtSignature as string).length).toBeGreaterThan(0); + }); + + it('does not inject thoughtSignature when thinking is not enabled', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'gemini-2.5-flash', + messages: [ + { role: 'user', content: 'Hello' }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_no_think', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/x"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_no_think', content: 'data' }, + ], + }, + modelName: 'gemini-2.5-flash', + }) as Record; + + const contents = result.contents as Array>; + const modelMsgs = contents.filter((c) => c.role === 'model'); + const fcParts = modelMsgs + .flatMap((m) => (m.parts as Array>)) + .filter((p) => 'functionCall' in p); + + expect(fcParts.length).toBe(1); + // No thinking → no signature injected + expect(fcParts[0].thoughtSignature).toBeUndefined(); + }); + + it('preserves functionResponse count matching functionCall count', () => { + const result = buildGeminiGenerateContentRequestFromOpenAi({ + body: { + model: 'gemini-3-flash-preview', + messages: [ + { role: 'user', content: 'Read two files.' }, + { + role: 'assistant', + tool_calls: [ + { + id: 'call_a', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/a"}' }, + provider_specific_fields: { thought_signature: 'sig_a' }, + }, + { + id: 'call_b', + type: 'function', + function: { name: 'Read', arguments: '{"path":"/b"}' }, + provider_specific_fields: { thought_signature: 'sig_b' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'call_a', content: 'content a' }, + { role: 'tool', tool_call_id: 'call_b', content: 'content b' }, + ], + }, + modelName: 'gemini-3-flash-preview', + }) as Record; + + const contents = result.contents as Array>; + + // Count functionCall and functionResponse parts + let fcCount = 0; + let frCount = 0; + for (const content of contents) { + for (const part of (content.parts as Array>)) { + if ('functionCall' in part) fcCount++; + if ('functionResponse' in part) frCount++; + } + } + + expect(fcCount).toBe(2); + expect(frCount).toBe(2); + }); +}); diff --git a/src/server/routes/proxy/geminiCliCompat.ts b/src/server/routes/proxy/geminiCliCompat.ts new file mode 100644 index 0000000..090d60c --- /dev/null +++ b/src/server/routes/proxy/geminiCliCompat.ts @@ -0,0 +1,396 @@ +import { TextDecoder, TextEncoder } from 'node:util'; +import { resolveGeminiThinkingConfigFromRequest } from '../../transformers/gemini/generate-content/convert.js'; + +// Dummy sentinel used when no real thoughtSignature is available but thinking +// mode is enabled. Gemini accepts any base64 string and won't reject this. +const DUMMY_THOUGHT_SIGNATURE = 'c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I='; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseDataUrl(url: string): { mimeType: string; data: string } | null { + if (!url.startsWith('data:')) return null; + const [, rest] = url.split('data:', 2); + const [meta, data] = rest.split(',', 2); + if (!meta || !data) return null; + const [mimeType] = meta.split(';', 1); + return { + mimeType: mimeType || 'application/octet-stream', + data, + }; +} + +function normalizeFunctionResponseResult(value: unknown): unknown { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed) return value; + try { + return JSON.parse(trimmed); + } catch { + return value; + } +} + +function convertContentToGeminiParts(content: unknown): Array> { + if (typeof content === 'string') { + const trimmed = content.trim(); + return trimmed ? [{ text: trimmed }] : []; + } + + if (isRecord(content)) { + if (typeof content.text === 'string') { + const trimmed = content.text.trim(); + return trimmed ? [{ text: trimmed }] : []; + } + return []; + } + + if (!Array.isArray(content)) return []; + + const parts: Array> = []; + for (const item of content) { + if (!isRecord(item)) continue; + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'text') { + const text = asTrimmedString(item.text); + if (text) parts.push({ text }); + continue; + } + if (type === 'image_url') { + const imageUrl = asTrimmedString(item.image_url && isRecord(item.image_url) ? item.image_url.url : item.url); + const parsed = imageUrl ? parseDataUrl(imageUrl) : null; + if (parsed) { + parts.push({ + inlineData: { + mime_type: parsed.mimeType, + data: parsed.data, + }, + }); + } + continue; + } + if (type === 'input_audio') { + const data = asTrimmedString(item.data); + if (data) { + parts.push({ + inlineData: { + mime_type: 'audio/wav', + data, + }, + }); + } + } + } + return parts; +} + +function buildGeminiTools(tools: unknown): Array> | undefined { + if (!Array.isArray(tools)) return undefined; + const declarations = tools + .filter((item) => isRecord(item)) + .flatMap((item) => { + if (asTrimmedString(item.type) !== 'function' || !isRecord(item.function)) return []; + const fn = item.function as Record; + const name = asTrimmedString(fn.name); + if (!name) return []; + return [{ + name, + ...(asTrimmedString(fn.description) ? { description: asTrimmedString(fn.description) } : {}), + parametersJsonSchema: isRecord(fn.parameters) ? fn.parameters : { type: 'object', properties: {} }, + }]; + }); + + if (declarations.length <= 0) return undefined; + return [{ functionDeclarations: declarations }]; +} + +function buildGeminiToolConfig(toolChoice: unknown): Record | undefined { + if (typeof toolChoice === 'string') { + const normalized = toolChoice.trim().toLowerCase(); + if (normalized === 'none') { + return { functionCallingConfig: { mode: 'NONE' } }; + } + if (normalized === 'required') { + return { functionCallingConfig: { mode: 'ANY' } }; + } + return { functionCallingConfig: { mode: 'AUTO' } }; + } + return undefined; +} + +export function buildGeminiGenerateContentRequestFromOpenAi(input: { + body: Record; + modelName: string; + instructions?: string; +}) { + const request: Record = { + contents: [], + }; + + const messages = Array.isArray(input.body.messages) ? input.body.messages : []; + const toolNameById = new Map(); + for (const message of messages) { + if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue; + const id = asTrimmedString(toolCall.id); + const name = asTrimmedString(toolCall.function.name); + if (id && name) { + toolNameById.set(id, name); + } + } + } + + // Detect if thinking mode is enabled (needed for dummy signature fallback) + const hasThinkingEnabled = !!resolveGeminiThinkingConfigFromRequest(input.modelName, input.body); + + // Collect thoughtSignatures from assistant tool_calls for injection. + // OpenAI format stores signatures in provider_specific_fields or encoded in tool call IDs. + const thoughtSignatureById = new Map(); + for (const message of messages) { + if (!isRecord(message) || asTrimmedString(message.role) !== 'assistant') continue; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall)) continue; + const id = asTrimmedString(toolCall.id); + if (!id) continue; + // Check provider_specific_fields first + const providerFields = isRecord(toolCall.provider_specific_fields) ? toolCall.provider_specific_fields : null; + if (providerFields && typeof providerFields.thought_signature === 'string') { + thoughtSignatureById.set(id, providerFields.thought_signature); + } + } + } + + const systemParts: Array> = []; + if (typeof input.instructions === 'string' && input.instructions.trim()) { + systemParts.push({ text: input.instructions.trim() }); + } + + for (const message of messages) { + if (!isRecord(message)) continue; + const role = asTrimmedString(message.role).toLowerCase(); + if (role === 'system' || role === 'developer') { + systemParts.push(...convertContentToGeminiParts(message.content)); + continue; + } + if (role === 'tool') { + const toolCallId = asTrimmedString(message.tool_call_id); + const name = toolNameById.get(toolCallId) || 'unknown'; + const result = normalizeFunctionResponseResult(message.content); + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { + role: 'user', + parts: [{ + functionResponse: { + name, + response: { + result, + }, + }, + }], + }, + ]; + continue; + } + + const textParts = convertContentToGeminiParts(message.content); + const fcParts: Array> = []; + const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : []; + for (const toolCall of toolCalls) { + if (!isRecord(toolCall) || !isRecord(toolCall.function)) continue; + const name = asTrimmedString(toolCall.function.name); + if (!name) continue; + const rawArguments = toolCall.function.arguments; + let args: unknown = {}; + if (typeof rawArguments === 'string' && rawArguments.trim()) { + try { + args = JSON.parse(rawArguments); + } catch { + args = { raw: rawArguments }; + } + } else if (isRecord(rawArguments)) { + args = rawArguments; + } + const fcPart: Record = { + functionCall: { name, args }, + }; + // Inject thoughtSignature if available from provider_specific_fields. + // If no signature is found, use a dummy sentinel so Gemini doesn't reject + // the request outright. Gemini 3+ requires thoughtSignature on all + // functionCall parts when thinking is enabled. + // Ref: https://ai.google.dev/gemini-api/docs/thought-signatures + const id = asTrimmedString(toolCall.id); + const signature = thoughtSignatureById.get(id); + if (signature) { + fcPart.thoughtSignature = signature; + } else if (hasThinkingEnabled) { + fcPart.thoughtSignature = DUMMY_THOUGHT_SIGNATURE; + } + fcParts.push(fcPart); + } + + const geminiRole = role === 'assistant' ? 'model' : 'user'; + + // Gemini requires that parts WITH thoughtSignature and parts WITHOUT + // are not mixed in the same message. Split if needed. + // Ref: https://ai.google.dev/gemini-api/docs/thought-signatures + const hasSigned = fcParts.some((p) => 'thoughtSignature' in p); + if (hasSigned && textParts.length > 0 && fcParts.length > 0) { + // Emit text parts first, then functionCall parts in a separate message + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { role: geminiRole, parts: textParts }, + { role: geminiRole, parts: fcParts }, + ]; + } else { + const allParts = [...textParts, ...fcParts]; + if (allParts.length <= 0) continue; + request.contents = [ + ...(Array.isArray(request.contents) ? request.contents : []), + { role: geminiRole, parts: allParts }, + ]; + } + } + + if (systemParts.length > 0) { + request.systemInstruction = { + role: 'user', + parts: systemParts, + }; + } + + const generationConfig: Record = {}; + const maxOutputTokens = Number( + input.body.max_output_tokens + ?? input.body.max_completion_tokens + ?? input.body.max_tokens + ?? 0, + ); + if (Number.isFinite(maxOutputTokens) && maxOutputTokens > 0) { + generationConfig.maxOutputTokens = Math.trunc(maxOutputTokens); + } + const temperature = Number(input.body.temperature); + if (Number.isFinite(temperature)) generationConfig.temperature = temperature; + const topP = Number(input.body.top_p); + if (Number.isFinite(topP)) generationConfig.topP = topP; + const topK = Number(input.body.top_k); + if (Number.isFinite(topK)) generationConfig.topK = topK; + if (Array.isArray(input.body.stop) && input.body.stop.length > 0) { + generationConfig.stopSequences = input.body.stop.filter((item): item is string => typeof item === 'string' && item.trim().length > 0); + } + const thinkingConfig = resolveGeminiThinkingConfigFromRequest(input.modelName, input.body); + if (thinkingConfig) { + generationConfig.thinkingConfig = thinkingConfig; + } + if (Object.keys(generationConfig).length > 0) { + request.generationConfig = generationConfig; + } + + const geminiTools = buildGeminiTools(input.body.tools); + if (geminiTools) { + request.tools = geminiTools; + } + const toolConfig = buildGeminiToolConfig(input.body.tool_choice); + if (toolConfig) { + request.toolConfig = toolConfig; + } + + return request; +} + +export function wrapGeminiCliRequest(input: { + modelName: string; + projectId: string; + request: Record; +}) { + const { model, ...requestPayload } = input.request; + return { + project: input.projectId, + model: input.modelName, + request: requestPayload, + }; +} + +export function unwrapGeminiCliPayload(payload: T): unknown { + if (!isRecord(payload)) return payload; + if (payload.response !== undefined) { + return payload.response; + } + return payload; +} + +function rewriteGeminiCliSseEventBlock(block: string): string { + const lines = block.split(/\r?\n/g); + return lines.map((line) => { + if (!line.startsWith('data:')) return line; + const data = line.slice(5).trim(); + if (!data || data === '[DONE]') return line; + try { + const parsed = JSON.parse(data); + return `data: ${JSON.stringify(unwrapGeminiCliPayload(parsed))}`; + } catch { + return line; + } + }).join('\n'); +} + +export function createGeminiCliStreamReader(reader: { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(reason?: unknown): Promise; + releaseLock(): void; +}) { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const outputQueue: Uint8Array[] = []; + let buffer = ''; + let done = false; + + async function fillQueue() { + while (outputQueue.length <= 0 && !done) { + const result = await reader.read(); + if (result.done) { + done = true; + const tail = decoder.decode(); + if (tail) buffer += tail; + if (buffer.trim()) { + outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(buffer)}\n\n`)); + buffer = ''; + } + break; + } + if (!result.value) continue; + buffer += decoder.decode(result.value, { stream: true }); + let separatorIndex = buffer.indexOf('\n\n'); + while (separatorIndex >= 0) { + const block = buffer.slice(0, separatorIndex); + buffer = buffer.slice(separatorIndex + 2); + outputQueue.push(encoder.encode(`${rewriteGeminiCliSseEventBlock(block)}\n\n`)); + separatorIndex = buffer.indexOf('\n\n'); + } + } + } + + return { + async read() { + await fillQueue(); + if (outputQueue.length > 0) { + return { done: false, value: outputQueue.shift() }; + } + return { done: true, value: undefined }; + }, + cancel(reason?: unknown) { + return reader.cancel(reason); + }, + releaseLock() { + reader.releaseLock(); + }, + }; +} diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index 4cc0eaa..7b42dad 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -13,6 +13,7 @@ import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js'; +import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -25,6 +26,9 @@ export async function imagesProxyRoute(app: FastifyInstance) { const requestedModel = body?.model || 'gpt-image-1'; if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -67,7 +71,7 @@ export async function imagesProxyRoute(app: FastifyInstance) { const text = await upstream.text(); if (!upstream.ok) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ accountId: selected.account.id, @@ -101,27 +105,11 @@ export async function imagesProxyRoute(app: FastifyInstance) { }); tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost); return reply.code(upstream.status).send(data); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount); - if (retryCount < MAX_RETRIES) { - retryCount++; - continue; - } - await reportProxyAllFailed({ - model: requestedModel, - reason: err.message || 'network failure', - }); - return reply.code(502).send({ - error: { message: `Upstream error: ${err.message}`, type: 'upstream_error' }, - }); - } - } - }); - - app.post('/v1/images/edits', async (request: FastifyRequest, reply: FastifyReply) => { + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);, async (request: FastifyRequest, reply: FastifyReply) => { const multipartForm = await parseMultipartFormData(request); const jsonBody = (!multipartForm && request.body && typeof request.body === 'object') ? request.body as Record @@ -132,6 +120,9 @@ export async function imagesProxyRoute(app: FastifyInstance) { if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -160,7 +151,6 @@ export async function imagesProxyRoute(app: FastifyInstance) { const startTime = Date.now(); try { - const accountProxy = getProxyUrlFromExtraConfig(selected.account.extraConfig); const requestInit = multipartForm ? withSiteRecordProxyRequestInit(selected.site, { method: 'POST', @@ -170,7 +160,7 @@ export async function imagesProxyRoute(app: FastifyInstance) { body: cloneFormDataWithOverrides(multipartForm, { model: selected.actualModel || requestedModel, }) as any, - }, accountProxy) + }) : withSiteRecordProxyRequestInit(selected.site, { method: 'POST', headers: { @@ -181,13 +171,13 @@ export async function imagesProxyRoute(app: FastifyInstance) { ...(jsonBody || {}), model: selected.actualModel || requestedModel, }), - }, accountProxy); + }); const upstream = await fetch(targetUrl, requestInit); const text = await upstream.text(); if (!upstream.ok) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, 0, '/v1/images/edits'); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits'); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ accountId: selected.account.id, @@ -221,11 +211,11 @@ export async function imagesProxyRoute(app: FastifyInstance) { }); tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, estimatedCost, '/v1/images/edits'); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost, '/v1/images/edits'); return reply.code(upstream.status).send(data); } catch (err: any) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, 0, '/v1/images/edits'); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits'); if (retryCount < MAX_RETRIES) { retryCount++; continue; @@ -259,6 +249,7 @@ async function logProxy( latencyMs: number, errorMessage: string | null, retryCount: number, + downstreamApiKeyId: number | null = null, estimatedCost = 0, downstreamPath = '/v1/images/generations', ) { @@ -272,6 +263,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/models.test.ts b/src/server/routes/proxy/models.test.ts index fad0be3..bdeea62 100644 --- a/src/server/routes/proxy/models.test.ts +++ b/src/server/routes/proxy/models.test.ts @@ -7,11 +7,13 @@ import { join } from 'node:path'; type DbModule = typeof import('../../db/index.js'); type ProxyRouterModule = typeof import('./router.js'); type TokenRouterModule = typeof import('../../services/tokenRouter.js'); +type TokensRoutesModule = typeof import('../api/tokens.js'); describe('/v1/models route', () => { let db: DbModule['db']; let schema: DbModule['schema']; let proxyRoutes: ProxyRouterModule['proxyRoutes']; + let tokensRoutes: TokensRoutesModule['tokensRoutes']; let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache']; let app: FastifyInstance; let dataDir = ''; @@ -24,13 +26,16 @@ describe('/v1/models route', () => { const dbModule = await import('../../db/index.js'); const proxyRouterModule = await import('./router.js'); const tokenRouterModule = await import('../../services/tokenRouter.js'); + const tokensRoutesModule = await import('../api/tokens.js'); db = dbModule.db; schema = dbModule.schema; proxyRoutes = proxyRouterModule.proxyRoutes; + tokensRoutes = tokensRoutesModule.tokensRoutes; invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache; app = Fastify(); + await app.register(tokensRoutes); await app.register(proxyRoutes); }); @@ -285,6 +290,106 @@ describe('/v1/models route', () => { expect(ids).not.toContain('claude-sonnet-4-5'); }); + it('returns only explicit-group public name while hiding source exact routes', async () => { + const site = await db.insert(schema.sites).values({ + name: 'explicit-group-site', + url: 'https://explicit-group.example.com', + platform: 'openai', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + accessToken: 'explicit-group-access-token', + status: 'active', + }).returning().get(); + + const token = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'default', + token: 'explicit-group-api-token', + enabled: true, + isDefault: true, + }).returning().get(); + + await db.insert(schema.modelAvailability).values([ + { + accountId: account.id, + modelName: 'claude-opus-4-5', + available: true, + }, + { + accountId: account.id, + modelName: 'claude-sonnet-4-5', + available: true, + }, + ]).run(); + + const sourceRouteA = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-opus-4-5', + enabled: true, + }).returning().get(); + const sourceRouteB = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'claude-sonnet-4-5', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values([ + { + routeId: sourceRouteA.id, + accountId: account.id, + tokenId: token.id, + sourceModel: 'claude-opus-4-5', + enabled: true, + }, + { + routeId: sourceRouteB.id, + accountId: account.id, + tokenId: token.id, + sourceModel: 'claude-sonnet-4-5', + enabled: true, + }, + ]).run(); + + const groupResponse = await app.inject({ + method: 'POST', + url: '/api/routes', + payload: { + routeMode: 'explicit_group', + displayName: 'claude-opus-4-6', + sourceRouteIds: [sourceRouteA.id, sourceRouteB.id], + }, + }); + expect(groupResponse.statusCode).toBe(200); + const groupId = (groupResponse.json() as { id: number }).id; + + await db.insert(schema.downstreamApiKeys).values({ + name: 'managed-explicit-group-key', + key: 'sk-managed-explicit-group', + enabled: true, + allowedRouteIds: JSON.stringify([groupId]), + }).run(); + + const response = await app.inject({ + method: 'GET', + url: '/v1/models', + headers: { + authorization: 'Bearer sk-managed-explicit-group', + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json() as { + object: 'list'; + data: Array<{ id: string }>; + }; + + const ids = body.data.map((item) => item.id); + expect(ids).toContain('claude-opus-4-6'); + expect(ids).not.toContain('claude-opus-4-5'); + expect(ids).not.toContain('claude-sonnet-4-5'); + }); + it('filters search pseudo models out of /v1/models', async () => { const site = await db.insert(schema.sites).values({ name: 'search-site', diff --git a/src/server/routes/proxy/models.ts b/src/server/routes/proxy/models.ts index 065ad9b..eba96ea 100644 --- a/src/server/routes/proxy/models.ts +++ b/src/server/routes/proxy/models.ts @@ -1,6 +1,4 @@ import { FastifyInstance } from 'fastify'; -import { db, schema } from '../../db/index.js'; -import { and, eq } from 'drizzle-orm'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; import { tokenRouter } from '../../services/tokenRouter.js'; @@ -17,28 +15,7 @@ export async function modelsProxyRoute(app: FastifyInstance) { const downstreamPolicy = getDownstreamRoutingPolicy(request); const readModels = async () => { - const rows = await db.select({ modelName: schema.modelAvailability.modelName }) - .from(schema.modelAvailability) - .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .where( - and( - eq(schema.modelAvailability.available, true), - eq(schema.accounts.status, 'active'), - eq(schema.sites.status, 'active'), - ), - ) - .all(); - const routeAliases = (await db.select({ displayName: schema.tokenRoutes.displayName }) - .from(schema.tokenRoutes) - .where(eq(schema.tokenRoutes.enabled, true)) - .all()) - .map((row) => (row.displayName || '').trim()) - .filter((name) => name.length > 0); - const deduped = Array.from(new Set([ - ...rows.map((r) => r.modelName), - ...routeAliases, - ])) + const deduped = Array.from(new Set(await tokenRouter.getAvailableModels())) .filter((modelName) => !isSearchPseudoModel(modelName)) .sort(); const allowed: string[] = []; diff --git a/src/server/routes/proxy/responses.codex-oauth.test.ts b/src/server/routes/proxy/responses.codex-oauth.test.ts new file mode 100644 index 0000000..cc7d66a --- /dev/null +++ b/src/server/routes/proxy/responses.codex-oauth.test.ts @@ -0,0 +1,470 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ + ...usage, + estimatedCostFromQuota: 0, + recoveredFromSelfLog: false, +})); +const refreshOauthAccessTokenSingleflightMock = vi.fn(); +const recordOauthQuotaResetHintMock = vi.fn(); +const insertedProxyLogs: Record[] = []; +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: (values: Record) => { + insertedProxyLogs.push(values); + return { + run: () => undefined, + }; + }, +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: ({ status }: { status?: number }) => status === 401, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: async () => 0, + buildProxyBillingDetails: async () => null, + fetchModelPricingCatalog: async () => null, +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), +})); + +vi.mock('../../services/oauth/refreshSingleflight.js', () => ({ + refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args), +})); + +vi.mock('../../services/oauth/quota.js', () => ({ + recordOauthQuotaResetHint: (...args: unknown[]) => recordOauthQuotaResetHintMock(...args), +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, + schema: { + proxyLogs: {}, + }, +})); + +describe('responses proxy codex oauth refresh', () => { + let app: FastifyInstance; + + const createSseResponse = (chunks: string[], status = 200) => { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }), { + status, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + }); + }; + + beforeAll(async () => { + const { responsesProxyRoute } = await import('./responses.js'); + app = Fastify(); + await app.register(responsesProxyRoute); + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + refreshOauthAccessTokenSingleflightMock.mockReset(); + recordOauthQuotaResetHintMock.mockReset(); + dbInsertMock.mockClear(); + insertedProxyLogs.length = 0; + + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'codex-site', url: 'https://chatgpt.com/backend-api/codex', platform: 'codex' }, + account: { + id: 33, + username: 'codex-user@example.com', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }, + tokenName: 'default', + tokenValue: 'expired-access-token', + actualModel: 'gpt-5.2-codex', + }); + selectNextChannelMock.mockReturnValue(null); + refreshOauthAccessTokenSingleflightMock.mockResolvedValue({ + accessToken: 'fresh-access-token', + accountId: 33, + accountKey: 'chatgpt-account-123', + }); + }); + + afterAll(async () => { + await app.close(); + }); + + it('refreshes codex oauth token and retries the same responses request on 401', async () => { + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'expired token', type: 'invalid_request_error' }, + }), { + status: 401, + headers: { 'content-type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ + id: 'resp_codex_refreshed', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok after codex token refresh', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + 'user-agent': 'CodexClient/1.0', + }, + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(33); + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [firstUrl, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [secondUrl, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + expect(firstUrl).toBe('https://chatgpt.com/backend-api/codex/responses'); + expect(secondUrl).toBe('https://chatgpt.com/backend-api/codex/responses'); + expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token'); + expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token'); + expect(secondOptions.headers.Originator || secondOptions.headers.originator).toBe('codex_cli_rs'); + expect(secondOptions.headers['Chatgpt-Account-Id'] || secondOptions.headers['chatgpt-account-id']).toBe('chatgpt-account-123'); + expect(secondOptions.headers.Version || secondOptions.headers.version).toBe('0.101.0'); + expect(String(secondOptions.headers.Session_id || secondOptions.headers.session_id || '')).toMatch(/^[0-9a-f-]{36}$/i); + expect(secondOptions.headers.Conversation_id || secondOptions.headers.conversation_id).toBeUndefined(); + expect(secondOptions.headers['User-Agent'] || secondOptions.headers['user-agent']).toBe('CodexClient/1.0'); + expect(secondOptions.headers.Accept || secondOptions.headers.accept).toBe('text/event-stream'); + expect(secondOptions.headers.Connection || secondOptions.headers.connection).toBe('Keep-Alive'); + expect(response.json()?.output_text).toContain('ok after codex token refresh'); + }); + + it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_codex_no_system', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok without system prompt', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.instructions).toBe(''); + expect(forwardedBody.prompt_cache_key).toBeUndefined(); + expect(forwardedBody.stream).toBe(true); + expect(forwardedBody.input).toEqual([ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello codex' }], + }, + ]); + }); + + it('preserves explicit prompt_cache_key for codex responses requests', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_codex_with_cache_key', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok with cache key', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + prompt_cache_key: 'codex-cache-123', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(options.headers.Session_id || options.headers.session_id).toBe('codex-cache-123'); + expect(options.headers.Conversation_id || options.headers.conversation_id).toBe('codex-cache-123'); + expect(forwardedBody.prompt_cache_key).toBe('codex-cache-123'); + }); + + it('strips generic downstream headers before forwarding codex responses upstream', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_codex_header_filter', + object: 'response', + model: 'gpt-5.2-codex', + status: 'completed', + output_text: 'ok with filtered headers', + usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + 'openai-beta': 'responses-2025-03-11', + 'x-openai-client-user-agent': '{"client":"openclaw"}', + origin: 'https://openclaw.example', + referer: 'https://openclaw.example/app', + 'user-agent': 'OpenClaw/1.0', + version: '0.202.0', + session_id: 'session-from-client', + }, + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + expect(options.headers.Version || options.headers.version).toBe('0.202.0'); + expect(options.headers.Session_id || options.headers.session_id).toBe('session-from-client'); + expect(options.headers['User-Agent'] || options.headers['user-agent']).toBe('OpenClaw/1.0'); + expect(options.headers['openai-beta']).toBeUndefined(); + expect(options.headers['x-openai-client-user-agent']).toBeUndefined(); + expect(options.headers.origin).toBeUndefined(); + expect(options.headers.referer).toBeUndefined(); + }); + + it('records codex usage_limit_reached reset hints on upstream 429 failures', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { + type: 'usage_limit_reached', + resets_at: 1773800400, + message: 'quota exceeded', + }, + }), { + status: 429, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(429); + expect(recordOauthQuotaResetHintMock).toHaveBeenCalledWith({ + accountId: 33, + statusCode: 429, + errorText: JSON.stringify({ + error: { + type: 'usage_limit_reached', + resets_at: 1773800400, + message: 'quota exceeded', + }, + }), + }); + }); + + it('forces codex upstream responses requests to stream and aggregates the SSE payload for non-stream downstream callers', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_stream","delta":"pong"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.stream).toBe(true); + expect(forwardedBody.instructions).toBe(''); + expect(forwardedBody.store).toBe(false); + + expect(response.json()).toMatchObject({ + id: 'resp_codex_stream', + model: 'gpt-5.4', + status: 'completed', + output_text: 'pong', + usage: { + input_tokens: 3, + output_tokens: 1, + total_tokens: 4, + }, + }); + }); + + it('preserves codex-required instructions and store fields across responses compatibility retries', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + error: { message: 'upstream_error', type: 'upstream_error' }, + }), { + status: 400, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.2-codex', + input: 'hello codex', + metadata: { trace: 'compatibility-retry' }, + }, + }); + + expect(response.statusCode).toBe(400); + expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(2); + + const [, firstOptions] = fetchMock.mock.calls[0] as [string, any]; + const [, secondOptions] = fetchMock.mock.calls[1] as [string, any]; + const firstBody = JSON.parse(firstOptions.body); + const secondBody = JSON.parse(secondOptions.body); + + expect(firstBody.instructions).toBe(''); + expect(firstBody.store).toBe(false); + expect(firstBody.stream).toBe(true); + expect(secondBody.instructions).toBe(''); + expect(secondBody.store).toBe(false); + expect(secondBody.stream).toBe(true); + }); + + it('does not record success when a streaming responses request ends with response.failed', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_failed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.failed\n', + 'data: {"type":"response.failed","response":{"id":"resp_codex_failed","model":"gpt-5.4","status":"failed","error":{"message":"tool execution failed"}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'gpt-5.4', + input: 'hello codex', + stream: true, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toContain('response.failed'); + expect(response.body).not.toContain('response.completed'); + expect(recordSuccessMock).not.toHaveBeenCalled(); + expect(recordFailureMock).toHaveBeenCalledTimes(1); + expect(insertedProxyLogs.at(-1)).toMatchObject({ + status: 'failed', + httpStatus: 200, + }); + expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('tool execution failed'); + }); +}); diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index c336416..f34b073 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -1,6 +1,5 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -17,17 +16,30 @@ import { } from './upstreamEndpoint.js'; import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; -import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js'; +import { executeEndpointFlow, withUpstreamPath, type BuiltEndpointRequest } from './endpointFlow.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { resolveProxyLogBilling } from './proxyBilling.js'; -import { getProxyResourceOwner } from '../../middleware/auth.js'; +import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js'; import { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js'; +import { dispatchRuntimeRequest } from './runtimeExecutor.js'; import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js'; import { ProxyInputFileResolutionError, hasNonImageFileInputInOpenAiBody, resolveResponsesBodyInputFiles, } from '../../services/proxyInputFileResolver.js'; +import { + buildOauthProviderHeaders, +} from '../../services/oauth/service.js'; +import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; +import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js'; +import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js'; +import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js'; +import { + createGeminiCliStreamReader, + unwrapGeminiCliPayload, +} from './geminiCliCompat.js'; +import { ensureResponsesWebsocketTransport } from './responsesWebsocket.js'; const MAX_RETRIES = 2; @@ -113,7 +125,15 @@ function carriesResponsesFileUrlInput(value: unknown): boolean { type UsageSummary = ReturnType; +function deriveCodexExplicitSessionId(body: Record): string | null { + const promptCacheKey = typeof body.prompt_cache_key === 'string' + ? body.prompt_cache_key.trim() + : ''; + return promptCacheKey || null; +} + export async function responsesProxyRoute(app: FastifyInstance) { + ensureResponsesWebsocketTransport(app); const handleResponsesRequest = async ( request: FastifyRequest, reply: FastifyReply, @@ -148,6 +168,9 @@ export async function responsesProxyRoute(app: FastifyInstance) { } if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -174,12 +197,17 @@ export async function responsesProxyRoute(app: FastifyInstance) { excludeChannelIds.push(selected.channel.id); const modelName = selected.actualModel || requestedModel; + const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); + const isCodexSite = String(selected.site.platform || '').trim().toLowerCase() === 'codex'; const owner = getProxyResourceOwner(request); let normalizedResponsesBody: Record = { ...requestEnvelope.parsed.normalizedBody, model: modelName, stream: isStream, }; + if (body.generate === false) { + normalizedResponsesBody.generate = false; + } if (owner) { try { normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner); @@ -226,18 +254,30 @@ export async function responsesProxyRoute(app: FastifyInstance) { if (endpointCandidates.length === 0) { endpointCandidates.push('responses', 'chat', 'messages'); } + const buildProviderHeaders = () => ( + buildOauthProviderHeaders({ + extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + downstreamHeaders: request.headers as Record, + }) + ); const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => { + const upstreamStream = isStream || (isCodexSite && endpoint === 'responses'); + const codexExplicitSessionId = deriveCodexExplicitSessionId(normalizedResponsesBody); const endpointRequest = buildUpstreamEndpointRequest({ endpoint, modelName, - stream: isStream, + stream: upstreamStream, tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + oauthProjectId: oauth?.projectId, sitePlatform: selected.site.platform, siteUrl: selected.site.url, openaiBody: openAiBody, downstreamFormat: 'responses', responsesOriginalBody: normalizedResponsesBody, downstreamHeaders: request.headers as Record, + providerHeaders: buildProviderHeaders(), + codexExplicitSessionId, }); const upstreamPath = ( isCompactRequest && endpoint === 'responses' @@ -249,20 +289,54 @@ export async function responsesProxyRoute(app: FastifyInstance) { path: upstreamPath, headers: endpointRequest.headers, body: endpointRequest.body as Record, + runtime: endpointRequest.runtime, }; }; - const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({ - isStream, - requiresNativeResponsesFileUrl, - dispatchRequest: (compatibilityRequest, targetUrl) => fetch( - targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`, - withSiteRecordProxyRequestInit(selected.site, { + const dispatchRequest = (compatibilityRequest: BuiltEndpointRequest, targetUrl?: string) => ( + dispatchRuntimeRequest({ + siteUrl: selected.site.url, + targetUrl, + request: compatibilityRequest, + buildInit: (_requestUrl, requestForFetch) => withSiteRecordProxyRequestInit(selected.site, { method: 'POST', - headers: compatibilityRequest.headers, - body: JSON.stringify(compatibilityRequest.body), - }, getProxyUrlFromExtraConfig(selected.account.extraConfig)), - ), + headers: requestForFetch.headers, + body: JSON.stringify(requestForFetch.body), + }), + }) + ); + const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({ + isStream: isStream || isCodexSite, + requiresNativeResponsesFileUrl, + dispatchRequest, }); + const tryRecover = async (ctx: Parameters>[0]) => { + if (ctx.response.status === 401 && oauth) { + try { + const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id); + selected.tokenValue = refreshed.accessToken; + selected.account = { + ...selected.account, + accessToken: refreshed.accessToken, + extraConfig: refreshed.extraConfig ?? selected.account.extraConfig, + }; + const refreshedRequest = buildEndpointRequest(ctx.request.endpoint); + const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`; + const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl); + if (refreshedResponse.ok) { + return { + upstream: refreshedResponse, + upstreamPath: refreshedRequest.path, + }; + } + ctx.request = refreshedRequest; + ctx.response = refreshedResponse; + ctx.rawErrText = await refreshedResponse.text().catch(() => 'unknown error'); + } catch { + return endpointStrategy.tryRecover(ctx); + } + } + return endpointStrategy.tryRecover(ctx); + }; const startTime = Date.now(); @@ -272,7 +346,8 @@ export async function responsesProxyRoute(app: FastifyInstance) { proxyUrl: resolveChannelProxyUrl(selected.site, selected.account.extraConfig), endpointCandidates, buildRequest: (endpoint) => buildEndpointRequest(endpoint), - tryRecover: endpointStrategy.tryRecover, + dispatchRequest, + tryRecover, shouldDowngrade: endpointStrategy.shouldDowngrade, onDowngrade: (ctx) => { logProxy( @@ -291,6 +366,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { null, null, clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); }, }); @@ -298,6 +374,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { if (!endpointResult.ok) { const status = endpointResult.status || 502; const errText = endpointResult.errText || 'unknown error'; + const rawErrText = endpointResult.rawErrText || errText; tokenRouter.recordFailure(selected.channel.id); logProxy( selected, @@ -315,7 +392,13 @@ export async function responsesProxyRoute(app: FastifyInstance) { null, null, clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); + await recordOauthQuotaResetHint({ + accountId: selected.account.id, + statusCode: status, + errorText: rawErrText, + }); if (isTokenExpiredError({ status, message: errText })) { await reportTokenExpired({ @@ -338,8 +421,8 @@ export async function responsesProxyRoute(app: FastifyInstance) { return reply.code(status).send({ error: { message: errText, type: 'upstream_error' } }); } - const upstream = endpointResult.upstream; - const successfulUpstreamPath = endpointResult.upstreamPath; + const upstream = endpointResult.upstream; + const successfulUpstreamPath = endpointResult.upstreamPath; if (isStream) { reply.hijack(); @@ -349,7 +432,10 @@ export async function responsesProxyRoute(app: FastifyInstance) { reply.raw.setHeader('Connection', 'keep-alive'); reply.raw.setHeader('X-Accel-Buffering', 'no'); - const reader = upstream.body?.getReader(); + const upstreamReader = upstream.body?.getReader(); + const reader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader + ? createGeminiCliStreamReader(upstreamReader) + : upstreamReader; let parsedUsage: UsageSummary = { promptTokens: 0, completionTokens: 0, @@ -364,6 +450,9 @@ export async function responsesProxyRoute(app: FastifyInstance) { const streamSession = openAiResponsesTransformer.proxyStream.createSession({ modelName, successfulUpstreamPath, + strictTerminalEvents: Object.entries(request.headers as Record) + .some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport' + && String(rawValue).trim() === '1'), getUsage: () => parsedUsage, onParsedPayload: (payload) => { if (payload && typeof payload === 'object') { @@ -375,9 +464,32 @@ export async function responsesProxyRoute(app: FastifyInstance) { reply.raw.write(chunk); }, }); - await streamSession.run(reader, reply.raw); + const streamResult = await streamSession.run(reader, reply.raw); const latency = Date.now() - startTime; + if (streamResult.status === 'failed') { + tokenRouter.recordFailure(selected.channel.id); + logProxy( + selected, + requestedModel, + 'failed', + 200, + latency, + streamResult.errorMessage, + retryCount, + downstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + 0, + null, + successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, + ); + return; + } + const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({ site: selected.site, account: selected.account, @@ -406,16 +518,30 @@ export async function responsesProxyRoute(app: FastifyInstance) { selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); return; } - const rawText = await upstream.text(); - let upstreamData: unknown = rawText; - try { - upstreamData = JSON.parse(rawText); - } catch { + const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase(); + let rawText = ''; + let upstreamData: unknown; + if (upstreamContentType.includes('text/event-stream') && successfulUpstreamPath.endsWith('/responses')) { + const collected = await collectResponsesFinalPayloadFromSse(upstream, modelName); + rawText = collected.rawText; + upstreamData = collected.payload; + } else { + rawText = await upstream.text(); upstreamData = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch { + upstreamData = rawText; + } + } + if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') { + upstreamData = unwrapGeminiCliPayload(upstreamData); } const latency = Date.now() - startTime; const parsedUsage = parseProxyUsage(upstreamData); @@ -459,6 +585,8 @@ export async function responsesProxyRoute(app: FastifyInstance) { selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath, resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, successfulUpstreamPath, + clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); return reply.send(downstreamData); } catch (err: any) { @@ -479,6 +607,7 @@ export async function responsesProxyRoute(app: FastifyInstance) { null, null, clientContext, + logDownstreamApiKeyId ? downstreamApiKeyId : null, ); if (retryCount < MAX_RETRIES) { retryCount += 1; @@ -497,6 +626,13 @@ export async function responsesProxyRoute(app: FastifyInstance) { app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) => handleResponsesRequest(request, reply, '/v1/responses')); + app.get('/v1/responses', async (_request: FastifyRequest, reply: FastifyReply) => + reply.code(426).send({ + error: { + message: 'WebSocket upgrade required for GET /v1/responses', + type: 'invalid_request_error', + }, + })); app.post('/v1/responses/compact', async (request: FastifyRequest, reply: FastifyReply) => handleResponsesRequest(request, reply, '/v1/responses/compact')); } @@ -517,6 +653,7 @@ async function logProxy( billingDetails: unknown = null, upstreamPath: string | null = null, clientContext: DownstreamClientContext | null = null, + downstreamApiKeyId: number | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); @@ -534,6 +671,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/responses.websocket.test.ts b/src/server/routes/proxy/responses.websocket.test.ts new file mode 100644 index 0000000..a88c896 --- /dev/null +++ b/src/server/routes/proxy/responses.websocket.test.ts @@ -0,0 +1,554 @@ +import Fastify, { type FastifyInstance } from 'fastify'; +import { AddressInfo } from 'node:net'; +import WebSocket from 'ws'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.fn(); +const selectChannelMock = vi.fn(); +const selectNextChannelMock = vi.fn(); +const previewSelectedChannelMock = vi.fn(); +const recordSuccessMock = vi.fn(); +const recordFailureMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); +const reportProxyAllFailedMock = vi.fn(); +const reportTokenExpiredMock = vi.fn(); +const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({ + ...usage, + estimatedCostFromQuota: 0, + recoveredFromSelfLog: false, +})); +const dbInsertMock = vi.fn((_arg?: any) => ({ + values: () => ({ + run: () => undefined, + }), +})); + +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => fetchMock(...args), +})); + +vi.mock('../../services/tokenRouter.js', () => ({ + tokenRouter: { + selectChannel: (...args: unknown[]) => selectChannelMock(...args), + selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args), + previewSelectedChannel: (...args: unknown[]) => previewSelectedChannelMock(...args), + recordSuccess: (...args: unknown[]) => recordSuccessMock(...args), + recordFailure: (...args: unknown[]) => recordFailureMock(...args), + }, +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + +vi.mock('../../services/alertService.js', () => ({ + reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args), + reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args), +})); + +vi.mock('../../services/alertRules.js', () => ({ + isTokenExpiredError: () => false, +})); + +vi.mock('../../services/modelPricingService.js', () => ({ + estimateProxyCost: async () => 0, + buildProxyBillingDetails: async () => null, + fetchModelPricingCatalog: async () => null, +})); + +vi.mock('../../services/proxyRetryPolicy.js', () => ({ + shouldRetryProxyRequest: () => false, +})); + +vi.mock('../../services/proxyUsageFallbackService.js', () => ({ + resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg), +})); + +vi.mock('../../services/oauth/quota.js', () => ({ + recordOauthQuotaResetHint: async () => undefined, +})); + +vi.mock('../../db/index.js', () => ({ + db: { + insert: (arg: any) => dbInsertMock(arg), + }, + hasProxyLogDownstreamApiKeyIdColumn: async () => false, + schema: { + proxyLogs: {}, + }, +})); + +function createSseResponse(chunks: string[], status = 200) { + const encoder = new TextEncoder(); + return new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + }, + }), { + status, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + }); +} + +function createSelectedChannel(options?: { + siteName?: string; + siteUrl?: string; + sitePlatform?: string; + username?: string; + extraConfig?: string; + tokenValue?: string; + actualModel?: string; +}) { + const sitePlatform = options?.sitePlatform ?? 'codex'; + const isCodex = sitePlatform === 'codex'; + return { + channel: { id: 11, routeId: 22 }, + site: { + name: options?.siteName ?? (isCodex ? 'codex-site' : 'openai-site'), + url: options?.siteUrl ?? (isCodex ? 'https://chatgpt.com/backend-api/codex' : 'https://api.openai.com'), + platform: sitePlatform, + }, + account: { + id: 33, + username: options?.username ?? (isCodex ? 'codex-user@example.com' : 'openai-user@example.com'), + extraConfig: options?.extraConfig ?? (isCodex + ? JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + }, + }) + : '{}'), + }, + tokenName: 'default', + tokenValue: options?.tokenValue ?? (isCodex ? 'oauth-access-token' : 'sk-openai-token'), + actualModel: options?.actualModel ?? (isCodex ? 'gpt-5.4' : 'gpt-4.1'), + }; +} + +function waitForSocketOpen(socket: WebSocket) { + return new Promise((resolve, reject) => { + socket.once('open', () => resolve()); + socket.once('error', reject); + }); +} + +function waitForSocketUpgrade(socket: WebSocket) { + return new Promise<{ headers: Record }>((resolve, reject) => { + socket.once('upgrade', (response) => resolve({ headers: response.headers as Record })); + socket.once('error', reject); + }); +} + +function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 1000) { + return new Promise((resolve, reject) => { + const messages: any[] = []; + const timeout = setTimeout(() => { + socket.off('message', onMessage); + socket.off('error', onError); + reject(new Error(`Timed out waiting for ${count} websocket messages`)); + }, timeoutMs); + const onMessage = (payload: WebSocket.RawData) => { + messages.push(JSON.parse(String(payload))); + if (messages.length >= count) { + clearTimeout(timeout); + socket.off('message', onMessage); + socket.off('error', onError); + resolve(messages); + } + }; + const onError = (error: Error) => { + clearTimeout(timeout); + socket.off('message', onMessage); + reject(error); + }; + socket.on('message', onMessage); + socket.once('error', onError); + }); +} + +describe('responses websocket transport', () => { + let app: FastifyInstance; + let baseUrl: string; + + beforeAll(async () => { + const { responsesProxyRoute } = await import('./responses.js'); + app = Fastify(); + await app.register(responsesProxyRoute); + await app.listen({ port: 0, host: '127.0.0.1' }); + const address = app.server.address() as AddressInfo; + baseUrl = `ws://127.0.0.1:${address.port}`; + }); + + beforeEach(() => { + fetchMock.mockReset(); + selectChannelMock.mockReset(); + selectNextChannelMock.mockReset(); + previewSelectedChannelMock.mockReset(); + recordSuccessMock.mockReset(); + recordFailureMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); + reportProxyAllFailedMock.mockReset(); + reportTokenExpiredMock.mockReset(); + resolveProxyUsageWithSelfLogFallbackMock.mockClear(); + dbInsertMock.mockClear(); + + const selectedChannel = createSelectedChannel(); + selectChannelMock.mockReturnValue(selectedChannel); + selectNextChannelMock.mockReturnValue(null); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + }); + + afterAll(async () => { + await app.close(); + }); + + it('accepts response.create over GET /v1/responses websocket and forwards streamed responses events', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_ws","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_ws","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"pong"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws","model":"gpt-5.4","status":"completed","output":[{"id":"msg_ws","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"pong"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 4); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello websocket' }], + }, + ], + })); + + const messages = await messagesPromise; + socket.close(); + + expect(messages.map((message) => message.type)).toEqual([ + 'response.created', + 'response.output_item.added', + 'response.output_text.delta', + 'response.completed', + ]); + expect(messages[3]?.response?.output?.[0]?.content?.[0]?.text).toBe('pong'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('echoes x-codex-turn-state on websocket upgrade responses', async () => { + const socket = new WebSocket(`${baseUrl}/v1/responses`, { + headers: { + 'x-codex-turn-state': 'turn-state-123', + }, + }); + + const [upgrade] = await Promise.all([ + waitForSocketUpgrade(socket), + waitForSocketOpen(socket), + ]); + socket.close(); + + expect(upgrade.headers['x-codex-turn-state']).toBe('turn-state-123'); + }); + + it('merges follow-up response.create payloads when the selected upstream does not support incremental mode', async () => { + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n', + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-4.1","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + + const firstResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + instructions: 'be helpful', + input: [ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + ], + })); + await firstResponsePromise; + + const secondResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: 'resp_ws_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, firstOptions] = fetchMock.mock.calls[0] as [string, RequestInit]; + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const firstBody = JSON.parse(String(firstOptions.body)); + const secondBody = JSON.parse(String(secondOptions.body)); + + expect(firstBody.input).toHaveLength(1); + expect(secondBody.previous_response_id).toBeUndefined(); + expect(secondBody.model).toBe('gpt-4.1'); + expect(secondBody.instructions).toBe('be helpful'); + expect(secondBody.input).toEqual([ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + { + id: 'fc_1', + type: 'function_call', + call_id: 'call_1', + status: 'completed', + }, + { + id: 'msg_1', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'call tool' }], + }, + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ]); + }); + + it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => { + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n', + 'event: response.output_item.done\n', + 'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-5.4","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + + const firstResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + instructions: 'be helpful', + input: [ + { + id: 'msg_user_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'call the tool' }], + }, + ], + })); + await firstResponsePromise; + + const secondResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: 'resp_ws_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.previous_response_id).toBe('resp_ws_1'); + expect(secondBody.model).toBe('gpt-5.4'); + expect(secondBody.instructions).toBe('be helpful'); + expect(secondBody.input).toEqual([ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ]); + }); + + it('handles generate=false locally only for non-websocket-capable upstreams', async () => { + const selectedChannel = createSelectedChannel({ + sitePlatform: 'openai', + actualModel: 'gpt-4.1', + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock.mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_ws_after_prewarm","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + + const prewarmMessagesPromise = waitForSocketMessages(socket, 2); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-4.1', + generate: false, + })); + const prewarmMessages = await prewarmMessagesPromise; + expect(prewarmMessages.map((message) => message.type)).toEqual(['response.created', 'response.completed']); + expect(fetchMock).toHaveBeenCalledTimes(0); + + const secondResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + previous_response_id: prewarmMessages[0]?.response?.id, + input: [ + { + id: 'msg_followup_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'continue' }], + }, + ], + })); + await secondResponsePromise; + socket.close(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]; + const forwardedBody = JSON.parse(String(options.body)); + expect(forwardedBody.generate).toBeUndefined(); + expect(forwardedBody.previous_response_id).toBeUndefined(); + expect(forwardedBody.model).toBe('gpt-4.1'); + expect(forwardedBody.input).toEqual([ + { + id: 'msg_followup_1', + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'continue' }], + }, + ]); + }); + + it('forwards generate=false upstream for websocket-capable upstreams instead of synthesizing prewarm events', async () => { + fetchMock.mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_capable_generate_false","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 1); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + generate: false, + })); + + const messages = await messagesPromise; + socket.close(); + + expect(messages.map((message) => message.type)).toEqual(['response.completed']); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, options] = fetchMock.mock.calls[0] as [string, RequestInit]; + const forwardedBody = JSON.parse(String(options.body)); + expect(forwardedBody.generate).toBe(false); + }); + + it('emits websocket error when the upstream stream closes before a terminal responses event', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_incomplete","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"partial"}\n\n', + ])); + + const socket = new WebSocket(`${baseUrl}/v1/responses`); + await waitForSocketOpen(socket); + const messagesPromise = waitForSocketMessages(socket, 3, 400); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + input: [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello websocket' }], + }, + ], + })); + + const messages = await messagesPromise; + socket.close(); + + expect(messages.map((message) => message.type)).toEqual([ + 'response.created', + 'response.output_text.delta', + 'error', + ]); + expect(messages[2]?.status).toBe(408); + expect(messages[2]?.error?.message).toContain('stream closed before response.completed'); + }); +}); diff --git a/src/server/routes/proxy/responsesSseFinal.ts b/src/server/routes/proxy/responsesSseFinal.ts new file mode 100644 index 0000000..0eb7a0c --- /dev/null +++ b/src/server/routes/proxy/responsesSseFinal.ts @@ -0,0 +1,97 @@ +import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; +import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object'; +} + +function parseResponsesSsePayload(data: string): Record | null { + try { + const parsed = JSON.parse(data); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function getResponsesFailureMessage(payload: Record): string { + if (isRecord(payload.error) && typeof payload.error.message === 'string' && payload.error.message.trim()) { + return payload.error.message.trim(); + } + if (typeof payload.message === 'string' && payload.message.trim()) { + return payload.message.trim(); + } + return 'upstream stream failed'; +} + +export async function collectResponsesFinalPayloadFromSse( + upstream: { text(): Promise }, + modelName: string, +): Promise<{ payload: Record; rawText: string }> { + const rawText = await upstream.text(); + const { events } = openAiResponsesTransformer.pullSseEvents(rawText); + const streamContext = openAiResponsesTransformer.createStreamContext(modelName); + const aggregateState = openAiResponsesTransformer.aggregator.createState(modelName); + let usage = { + promptTokens: 0, + completionTokens: 0, + totalTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null as boolean | null, + }; + let completedPayload: Record | null = null; + + const captureCompletedPayload = (lines: string[]) => { + if (completedPayload) return; + const parsed = openAiResponsesTransformer.pullSseEvents(lines.join('')); + for (const event of parsed.events) { + if (event.data === '[DONE]') continue; + const payload = parseResponsesSsePayload(event.data); + if (!payload) continue; + if (payload.type === 'response.failed') { + throw new Error(getResponsesFailureMessage(payload)); + } + if (payload.type === 'response.completed' && isRecord(payload.response)) { + completedPayload = payload.response; + return; + } + } + }; + + for (const event of events) { + if (event.data === '[DONE]') continue; + const payload = parseResponsesSsePayload(event.data); + if (!payload) continue; + + if (payload.type === 'response.failed') { + throw new Error(getResponsesFailureMessage(payload)); + } + + usage = mergeProxyUsage(usage, parseProxyUsage(payload)); + const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(payload, streamContext, modelName); + captureCompletedPayload(openAiResponsesTransformer.aggregator.serialize({ + state: aggregateState, + streamContext, + event: normalizedEvent, + usage, + })); + } + + if (!completedPayload) { + captureCompletedPayload(openAiResponsesTransformer.aggregator.complete( + aggregateState, + streamContext, + usage, + )); + } + + if (completedPayload) { + return { + payload: completedPayload, + rawText, + }; + } + + throw new Error('stream disconnected before response.completed'); +} diff --git a/src/server/routes/proxy/responsesWebsocket.ts b/src/server/routes/proxy/responsesWebsocket.ts new file mode 100644 index 0000000..c663f58 --- /dev/null +++ b/src/server/routes/proxy/responsesWebsocket.ts @@ -0,0 +1,401 @@ +import { randomUUID } from 'node:crypto'; +import type { FastifyInstance } from 'fastify'; +import type { IncomingMessage } from 'node:http'; +import { WebSocketServer, type RawData, type WebSocket } from 'ws'; +import { tokenRouter } from '../../services/tokenRouter.js'; +import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js'; + +const installedApps = new WeakSet(); +const WS_TURN_STATE_HEADER = 'x-codex-turn-state'; +const RESPONSES_WEBSOCKET_MODE_HEADER = 'x-metapi-responses-websocket-mode'; +const RESPONSES_WEBSOCKET_TRANSPORT_HEADER = 'x-metapi-responses-websocket-transport'; + +type NormalizedResponsesWebsocketRequest = + | { + ok: true; + request: Record; + nextRequestSnapshot: Record; + } + | { + ok: false; + status: number; + message: string; + }; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function headerValueToTrimmedString(value: unknown): string { + if (typeof value === 'string') return value.trim(); + if (Array.isArray(value)) { + for (const item of value) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + return ''; +} + +function parseJsonObject(raw: RawData): Record | null { + try { + const parsed = JSON.parse(String(raw)); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function cloneJsonObject(value: T): T { + return structuredClone(value); +} + +function toResponseInputArray(value: unknown): unknown[] { + return Array.isArray(value) ? cloneJsonObject(value) : []; +} + +function normalizeResponsesWebsocketRequest( + parsed: Record, + lastRequest: Record | null, + lastResponseOutput: unknown[], + supportsIncrementalInput: boolean, +): NormalizedResponsesWebsocketRequest { + const requestType = asTrimmedString(parsed.type); + if (requestType !== 'response.create' && requestType !== 'response.append') { + return { + ok: false, + status: 400, + message: `unsupported websocket request type: ${requestType || 'unknown'}`, + }; + } + + if (!lastRequest) { + if (requestType !== 'response.create') { + return { + ok: false, + status: 400, + message: 'websocket request received before response.create', + }; + } + const next = cloneJsonObject(parsed); + delete next.type; + if (!supportsIncrementalInput && parsed.generate === false) { + delete next.generate; + } + next.stream = true; + if (!Array.isArray(next.input)) next.input = []; + const modelName = asTrimmedString(next.model); + if (!modelName) { + return { + ok: false, + status: 400, + message: 'missing model in response.create request', + }; + } + return { + ok: true, + request: next, + nextRequestSnapshot: cloneJsonObject(next), + }; + } + + if (!Array.isArray(parsed.input)) { + return { + ok: false, + status: 400, + message: 'websocket request requires array field: input', + }; + } + + const next = cloneJsonObject(parsed); + delete next.type; + next.stream = true; + if (!('model' in next) && typeof lastRequest.model === 'string') { + next.model = lastRequest.model; + } + if (!('instructions' in next) && lastRequest.instructions !== undefined) { + next.instructions = cloneJsonObject(lastRequest.instructions); + } + + if (supportsIncrementalInput && requestType === 'response.create' && asTrimmedString(parsed.previous_response_id)) { + return { + ok: true, + request: next, + nextRequestSnapshot: cloneJsonObject(next), + }; + } + + const mergedInput = [ + ...toResponseInputArray(lastRequest.input), + ...cloneJsonObject(lastResponseOutput), + ...cloneJsonObject(parsed.input), + ]; + delete next.previous_response_id; + next.input = mergedInput; + + return { + ok: true, + request: next, + nextRequestSnapshot: cloneJsonObject(next), + }; +} + +function shouldHandleResponsesWebsocketPrewarmLocally( + parsed: Record, + lastRequest: Record | null, + supportsIncrementalInput: boolean, +): boolean { + if (supportsIncrementalInput || lastRequest) return false; + if (asTrimmedString(parsed.type) !== 'response.create') return false; + return parsed.generate === false; +} + +function writeResponsesWebsocketError( + socket: WebSocket, + status: number, + message: string, + errorPayload?: unknown, +) { + socket.send(JSON.stringify({ + type: 'error', + status, + error: isRecord(errorPayload) && isRecord(errorPayload.error) + ? errorPayload.error + : { + type: status >= 500 ? 'server_error' : 'invalid_request_error', + message, + }, + })); +} + +function synthesizePrewarmResponsePayloads(request: Record) { + const responseId = `resp_prewarm_${randomUUID()}`; + const modelName = asTrimmedString(request.model) || 'unknown'; + const createdAt = Math.floor(Date.now() / 1000); + return [ + { + type: 'response.created', + response: { + id: responseId, + object: 'response', + created_at: createdAt, + status: 'in_progress', + model: modelName, + output: [], + }, + }, + { + type: 'response.completed', + response: { + id: responseId, + object: 'response', + created_at: createdAt, + status: 'completed', + model: modelName, + output: [], + usage: { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + }, + }, + }, + ]; +} + +function collectResponsesOutput(payloads: unknown[]): unknown[] { + const outputByIndex = new Map(); + let completedOutput: unknown[] | null = null; + + for (const payload of payloads) { + if (!isRecord(payload)) continue; + const type = asTrimmedString(payload.type); + if ((type === 'response.output_item.added' || type === 'response.output_item.done') + && Number.isInteger(payload.output_index) + && payload.item !== undefined) { + outputByIndex.set(Number(payload.output_index), cloneJsonObject(payload.item)); + continue; + } + if (type === 'response.completed' && isRecord(payload.response) && Array.isArray(payload.response.output)) { + completedOutput = cloneJsonObject(payload.response.output); + } + } + + if (completedOutput) return completedOutput; + return [...outputByIndex.entries()] + .sort((left, right) => left[0] - right[0]) + .map(([, value]) => value); +} + +function buildInjectHeaders(request: IncomingMessage): Record { + const headers: Record = {}; + for (const [rawKey, rawValue] of Object.entries(request.headers)) { + const key = rawKey.toLowerCase(); + if (!rawValue) continue; + if ( + key === 'host' + || key === 'connection' + || key === 'upgrade' + || key === 'sec-websocket-key' + || key === 'sec-websocket-version' + || key === 'sec-websocket-extensions' + || key === 'sec-websocket-protocol' + ) { + continue; + } + headers[rawKey] = rawValue as string | string[]; + } + return headers; +} + +async function supportsResponsesWebsocketIncrementalInput( + parsed: Record, + lastRequest: Record | null, +): Promise { + const requestModel = asTrimmedString(parsed.model) || asTrimmedString(lastRequest?.model); + if (!requestModel) return false; + + try { + const selected = await tokenRouter.previewSelectedChannel(requestModel); + return asTrimmedString(selected?.site?.platform).toLowerCase() === 'codex'; + } catch { + return false; + } +} + +async function handleResponsesWebsocketConnection( + app: FastifyInstance, + socket: WebSocket, + request: IncomingMessage, +) { + let lastRequest: Record | null = null; + let lastResponseOutput: unknown[] = []; + + socket.on('message', async (raw) => { + const parsed = parseJsonObject(raw); + if (!parsed) { + writeResponsesWebsocketError(socket, 400, 'Invalid websocket JSON payload'); + return; + } + + const supportsIncrementalInput = await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest); + const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally( + parsed, + lastRequest, + supportsIncrementalInput, + ); + const normalized = normalizeResponsesWebsocketRequest( + parsed, + lastRequest, + lastResponseOutput, + supportsIncrementalInput, + ); + if (!normalized.ok) { + writeResponsesWebsocketError(socket, normalized.status, normalized.message); + return; + } + + lastRequest = normalized.nextRequestSnapshot; + if (shouldHandleLocalPrewarm) { + lastResponseOutput = []; + for (const payload of synthesizePrewarmResponsePayloads(normalized.request)) { + socket.send(JSON.stringify(payload)); + } + return; + } + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + ...buildInjectHeaders(request), + [RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1', + ...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}), + }, + payload: normalized.request, + }); + + if (response.statusCode < 200 || response.statusCode >= 300) { + let payload: unknown = null; + try { + payload = JSON.parse(response.body); + } catch { + payload = null; + } + writeResponsesWebsocketError( + socket, + response.statusCode, + response.statusMessage || 'Upstream error', + payload, + ); + return; + } + + const contentType = String(response.headers['content-type'] || '').toLowerCase(); + if (!contentType.includes('text/event-stream')) { + try { + const payload = JSON.parse(response.body); + socket.send(JSON.stringify(payload)); + lastResponseOutput = isRecord(payload?.response) && Array.isArray(payload.response.output) + ? cloneJsonObject(payload.response.output) + : []; + } catch { + writeResponsesWebsocketError(socket, 502, 'Unexpected non-JSON websocket proxy response'); + } + return; + } + + const pulled = openAiResponsesTransformer.pullSseEvents(response.body); + const forwardedPayloads: unknown[] = []; + let sawTerminalPayload = false; + for (const event of pulled.events) { + if (event.data === '[DONE]') continue; + try { + const payload = JSON.parse(event.data); + forwardedPayloads.push(payload); + const type = isRecord(payload) ? asTrimmedString(payload.type) : ''; + if (type === 'response.completed' || type === 'response.failed') { + sawTerminalPayload = true; + } + socket.send(JSON.stringify(payload)); + } catch { + // Ignore malformed SSE frames; the HTTP route already normalizes them. + } + } + lastResponseOutput = collectResponsesOutput(forwardedPayloads); + if (!sawTerminalPayload) { + writeResponsesWebsocketError(socket, 408, 'stream closed before response.completed'); + } + }); +} + +export function ensureResponsesWebsocketTransport(app: FastifyInstance) { + if (installedApps.has(app)) return; + installedApps.add(app); + + const websocketServer = new WebSocketServer({ noServer: true }); + websocketServer.on('headers', (headers, request) => { + const turnState = headerValueToTrimmedString(request.headers[WS_TURN_STATE_HEADER]); + if (!turnState) return; + headers.push(`${WS_TURN_STATE_HEADER}: ${turnState}`); + }); + + app.server.on('upgrade', (request, socket, head) => { + const url = new URL(request.url || '/', 'http://localhost'); + if (url.pathname !== '/v1/responses') return; + websocketServer.handleUpgrade(request, socket, head, (client) => { + void handleResponsesWebsocketConnection(app, client, request); + }); + }); + + app.addHook('onClose', async () => { + await new Promise((resolve) => { + websocketServer.close(() => resolve()); + }); + }); +} diff --git a/src/server/routes/proxy/runtimeExecutor.test.ts b/src/server/routes/proxy/runtimeExecutor.test.ts new file mode 100644 index 0000000..12cd02a --- /dev/null +++ b/src/server/routes/proxy/runtimeExecutor.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetch } from 'undici'; +import type { BuiltEndpointRequest } from './endpointFlow.js'; + +vi.mock('undici', () => ({ + fetch: vi.fn(), +})); + +const fetchMock = vi.mocked(fetch); + +describe('dispatchRuntimeRequest', () => { + beforeEach(() => { + fetchMock.mockReset(); + }); + + it('routes antigravity runtime requests through daily then sandbox base urls and rewrites the payload fingerprint', async () => { + const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js'); + const request: BuiltEndpointRequest = { + endpoint: 'chat', + path: '/v1internal:generateContent', + headers: { + Authorization: 'Bearer antigravity-token', + 'Content-Type': 'application/json', + 'User-Agent': 'google-api-nodejs-client/9.15.1', + }, + body: { + project: 'project-demo', + model: 'gemini-3-pro-preview', + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello runtime executor' }], + }, + ], + }, + }, + runtime: { + executor: 'antigravity', + modelName: 'gemini-3-pro-preview', + stream: false, + }, + }; + + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ + error: { message: 'try fallback base url' }, + }), { + status: 429, + headers: { 'content-type': 'application/json' }, + }) as unknown as Awaited>) + .mockResolvedValueOnce(new Response(JSON.stringify({ + response: { responseId: 'ok' }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) as unknown as Awaited>); + + const response = await dispatchRuntimeRequest({ + siteUrl: 'https://cloudcode-pa.googleapis.com', + request, + buildInit: (_url, nextRequest) => ({ + method: 'POST', + headers: nextRequest.headers, + body: JSON.stringify(nextRequest.body), + }), + }); + + expect(response.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent'); + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent'); + + const firstInit = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(firstInit.headers).toMatchObject({ + Authorization: 'Bearer antigravity-token', + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + }); + + const upstreamBody = JSON.parse(String(firstInit.body)); + expect(upstreamBody).toMatchObject({ + project: 'project-demo', + model: 'gemini-3-pro-preview', + userAgent: 'antigravity', + requestType: 'agent', + request: { + sessionId: expect.any(String), + contents: [ + { + role: 'user', + parts: [{ text: 'hello runtime executor' }], + }, + ], + }, + }); + expect(upstreamBody.requestId).toMatch(/^agent-[0-9a-f-]{36}$/i); + }); + + it('keeps gemini-cli countTokens payload lean while forcing a model-aware user agent', async () => { + const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js'); + const request: BuiltEndpointRequest = { + endpoint: 'chat', + path: '/v1internal:countTokens', + headers: { + Authorization: 'Bearer gemini-cli-token', + 'Content-Type': 'application/json', + 'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)', + 'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0', + }, + body: { + project: 'project-demo', + model: 'gemini-2.5-pro', + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'count these tokens' }], + }, + ], + }, + }, + runtime: { + executor: 'gemini-cli', + modelName: 'gemini-2.5-pro', + stream: false, + action: 'countTokens', + }, + }; + + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ + totalTokens: 12, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) as unknown as Awaited>); + + const response = await dispatchRuntimeRequest({ + siteUrl: 'https://cloudcode-pa.googleapis.com', + request, + buildInit: (_url, nextRequest) => ({ + method: 'POST', + headers: nextRequest.headers, + body: JSON.stringify(nextRequest.body), + }), + }); + + expect(response.ok).toBe(true); + const requestInit = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(requestInit.headers).toMatchObject({ + Authorization: 'Bearer gemini-cli-token', + 'Content-Type': 'application/json', + 'User-Agent': 'GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)', + 'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0', + }); + expect(JSON.parse(String(requestInit.body))).toEqual({ + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'count these tokens' }], + }, + ], + }, + }); + }); + + it('retries antigravity runtime requests on transport errors before falling back to the next base url', async () => { + const { dispatchRuntimeRequest } = await import('./runtimeExecutor.js'); + const request: BuiltEndpointRequest = { + endpoint: 'chat', + path: '/v1internal:generateContent', + headers: { + Authorization: 'Bearer antigravity-token', + 'Content-Type': 'application/json', + }, + body: { + project: 'project-demo', + model: 'gemini-3-pro-preview', + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello runtime executor' }], + }, + ], + }, + }, + runtime: { + executor: 'antigravity', + modelName: 'gemini-3-pro-preview', + stream: false, + }, + }; + + fetchMock + .mockRejectedValueOnce(new Error('socket hang up')) + .mockResolvedValueOnce(new Response(JSON.stringify({ + response: { responseId: 'ok' }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) as unknown as Awaited>); + + const response = await dispatchRuntimeRequest({ + siteUrl: 'https://cloudcode-pa.googleapis.com', + request, + buildInit: (_url, nextRequest) => ({ + method: 'POST', + headers: nextRequest.headers, + body: JSON.stringify(nextRequest.body), + }), + }); + + expect(response.ok).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent'); + expect(fetchMock.mock.calls[1]?.[0]).toBe('https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:generateContent'); + }); +}); diff --git a/src/server/routes/proxy/runtimeExecutor.ts b/src/server/routes/proxy/runtimeExecutor.ts new file mode 100644 index 0000000..22bd563 --- /dev/null +++ b/src/server/routes/proxy/runtimeExecutor.ts @@ -0,0 +1,346 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { fetch, type RequestInit as UndiciRequestInit } from 'undici'; +import type { BuiltEndpointRequest } from './endpointFlow.js'; +import { buildUpstreamUrl } from './upstreamUrl.js'; + +const ANTIGRAVITY_RUNTIME_BASE_URLS = [ + 'https://daily-cloudcode-pa.googleapis.com', + 'https://daily-cloudcode-pa.sandbox.googleapis.com', +] as const; + +type RuntimeDispatchInput = { + siteUrl: string; + request: BuiltEndpointRequest; + targetUrl?: string; + buildInit: (requestUrl: string, request: BuiltEndpointRequest) => Promise | UndiciRequestInit; +}; + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function withRequestBody( + request: BuiltEndpointRequest, + body: Record, + headers?: Record, +): BuiltEndpointRequest { + return { + ...request, + headers: headers ? { ...headers } : { ...request.headers }, + body, + }; +} + +async function performFetch( + input: RuntimeDispatchInput, + request: BuiltEndpointRequest, + requestUrl = input.targetUrl || buildUpstreamUrl(input.siteUrl, request.path), +): Promise>> { + const init = await input.buildInit(requestUrl, request); + return fetch(requestUrl, init); +} + +async function materializeErrorResponse( + response: Awaited>, +): Promise>> { + if (response.ok) return response; + const text = await response.text().catch(() => ''); + return new Response(text, { + status: response.status, + headers: response.headers, + }) as unknown as Awaited>; +} + +function parseGeminiCliPreviewFallbackOrder(modelName: string): string[] { + switch (modelName.trim()) { + case 'gemini-2.5-pro': + return []; + case 'gemini-2.5-flash': + return []; + case 'gemini-2.5-flash-lite': + return []; + default: + return []; + } +} + +function replaceGeminiCliModelInUserAgent(userAgent: string | undefined, modelName: string): string | undefined { + const raw = asTrimmedString(userAgent); + if (!raw) return undefined; + return raw.replace(/^GeminiCLI\/([^/]+)\/[^ ]+ /i, `GeminiCLI/$1/${modelName} `); +} + +function buildGeminiCliAttemptRequest(request: BuiltEndpointRequest, modelName: string): BuiltEndpointRequest { + const body = structuredClone(request.body); + const action = request.runtime?.action; + if (action === 'countTokens') { + delete body.model; + delete body.project; + } else { + body.model = modelName; + } + const headers = { ...request.headers }; + const nextUserAgent = replaceGeminiCliModelInUserAgent( + headers['User-Agent'] || headers['user-agent'], + modelName, + ); + if (nextUserAgent) { + headers['User-Agent'] = nextUserAgent; + delete headers['user-agent']; + } + return withRequestBody(request, body, headers); +} + +function antigravityRequestType(modelName: string): 'image_gen' | 'agent' { + return modelName.includes('image') ? 'image_gen' : 'agent'; +} + +function generateAntigravityProjectId(): string { + const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold']; + const nouns = ['signal', 'river', 'rocket', 'forest', 'bridge']; + const adjective = adjectives[Math.floor(Math.random() * adjectives.length)] || 'useful'; + const noun = nouns[Math.floor(Math.random() * nouns.length)] || 'signal'; + const suffix = Math.floor(100000 + Math.random() * 900000); + return `${adjective}-${noun}-${suffix}`; +} + +function extractFirstUserText(value: unknown): string { + if (!Array.isArray(value)) return ''; + for (const content of value) { + if (!content || typeof content !== 'object' || Array.isArray(content)) continue; + const record = content as Record; + if (asTrimmedString(record.role) !== 'user') continue; + const parts = Array.isArray(record.parts) ? record.parts : []; + for (const part of parts) { + if (!part || typeof part !== 'object' || Array.isArray(part)) continue; + const text = asTrimmedString((part as Record).text); + if (text) return text; + } + } + return ''; +} + +function generateStableAntigravitySessionId(payload: Record): string { + const firstUserText = extractFirstUserText( + payload.request && typeof payload.request === 'object' && !Array.isArray(payload.request) + ? (payload.request as Record).contents + : undefined, + ); + if (!firstUserText) { + return `-${BigInt(`0x${randomUUID().replace(/-/g, '').slice(0, 16)}`).toString()}`; + } + const digest = createHash('sha256').update(firstUserText).digest('hex').slice(0, 16); + const bigint = BigInt(`0x${digest}`) & BigInt('0x7fffffffffffffff'); + return `-${bigint.toString()}`; +} + +function renameParametersJsonSchema(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => renameParametersJsonSchema(item)); + } + if (!value || typeof value !== 'object') return value; + + const input = value as Record; + const output: Record = {}; + for (const [key, entry] of Object.entries(input)) { + const nextKey = key === 'parametersJsonSchema' ? 'parameters' : key; + output[nextKey] = renameParametersJsonSchema(entry); + } + return output; +} + +function deleteNestedMaxOutputTokens(payload: Record): void { + const request = payload.request; + if (!request || typeof request !== 'object' || Array.isArray(request)) return; + const generationConfig = (request as Record).generationConfig; + if (!generationConfig || typeof generationConfig !== 'object' || Array.isArray(generationConfig)) return; + delete (generationConfig as Record).maxOutputTokens; +} + +function buildAntigravityRuntimeBody( + originalBody: Record, + modelName: string, + action?: NonNullable['action'], +): Record { + const payload = renameParametersJsonSchema(structuredClone(originalBody)) as Record; + if (action === 'countTokens') { + return payload; + } + const requestType = antigravityRequestType(modelName); + const projectId = asTrimmedString(payload.project) || generateAntigravityProjectId(); + + payload.model = modelName; + payload.project = projectId; + payload.userAgent = 'antigravity'; + payload.requestType = requestType; + payload.requestId = requestType === 'image_gen' + ? `image_gen/${Date.now()}/${randomUUID()}/12` + : `agent-${randomUUID()}`; + + const request = payload.request; + if (request && typeof request === 'object' && !Array.isArray(request)) { + delete (request as Record).safetySettings; + if (requestType !== 'image_gen') { + (request as Record).sessionId = generateStableAntigravitySessionId(payload); + } + if (modelName.includes('claude')) { + const toolConfig = ( + (request as Record).toolConfig + && typeof (request as Record).toolConfig === 'object' + && !Array.isArray((request as Record).toolConfig) + ) + ? (request as Record).toolConfig as Record + : (((request as Record).toolConfig = {}) as Record); + toolConfig.functionCallingConfig = { mode: 'VALIDATED' }; + } else { + deleteNestedMaxOutputTokens(payload); + } + } + + return payload; +} + +function antigravityShouldRetryNoCapacity( + status: number, + responseText: string, +): boolean { + if (status !== 503) return false; + return responseText.toLowerCase().includes('no capacity available'); +} + +function antigravityNoCapacityRetryDelay(attempt: number): number { + const delay = Math.min((attempt + 1) * 250, 2000); + return delay; +} + +async function waitMs(ms: number): Promise { + if (ms <= 0) return; + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function dispatchGeminiCliRuntimeRequest( + input: RuntimeDispatchInput, +): Promise>> { + const baseModel = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model); + const models = [baseModel, ...parseGeminiCliPreviewFallbackOrder(baseModel)].filter(Boolean); + let lastResponse: Awaited> | null = null; + + for (const modelName of models.length > 0 ? models : [baseModel || 'unknown']) { + const attemptRequest = buildGeminiCliAttemptRequest(input.request, modelName); + const response = await performFetch(input, attemptRequest); + if (response.ok) return response; + + if (response.status === 429) { + lastResponse = await materializeErrorResponse(response); + continue; + } + + return materializeErrorResponse(response); + } + + return lastResponse || performFetch(input, input.request); +} + +async function dispatchCodexRuntimeRequest( + input: RuntimeDispatchInput, +): Promise>> { + return performFetch(input, input.request); +} + +async function dispatchClaudeRuntimeRequest( + input: RuntimeDispatchInput, +): Promise>> { + return performFetch(input, input.request); +} + +async function dispatchAntigravityRuntimeRequest( + input: RuntimeDispatchInput, +): Promise>> { + const modelName = asTrimmedString(input.request.runtime?.modelName) || asTrimmedString(input.request.body.model); + const runtimeBody = buildAntigravityRuntimeBody( + input.request.body, + modelName, + input.request.runtime?.action, + ); + const baseAttempts = 3; + let lastResponse: Awaited> | null = null; + + attemptLoop: + for (let attempt = 0; attempt < baseAttempts; attempt += 1) { + for (const baseUrl of ANTIGRAVITY_RUNTIME_BASE_URLS) { + const requestUrl = `${baseUrl}${input.request.path}`; + const minimalHeaders: Record = { + Authorization: input.request.headers.Authorization || input.request.headers.authorization || '', + 'Content-Type': 'application/json', + Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + }; + let response: Awaited>; + try { + response = await performFetch( + input, + withRequestBody(input.request, runtimeBody, minimalHeaders), + requestUrl, + ); + } catch (error) { + if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) { + continue; + } + if (attempt + 1 < baseAttempts) { + continue attemptLoop; + } + throw error; + } + if (response.ok) return response; + + const errorResponse = await materializeErrorResponse(response); + const errorText = await errorResponse.text().catch(() => ''); + lastResponse = new Response(errorText, { + status: errorResponse.status, + headers: errorResponse.headers, + }) as unknown as Awaited>; + + if (errorResponse.status === 429) { + continue; + } + + if (antigravityShouldRetryNoCapacity(errorResponse.status, errorText)) { + if (baseUrl !== ANTIGRAVITY_RUNTIME_BASE_URLS[ANTIGRAVITY_RUNTIME_BASE_URLS.length - 1]) { + continue; + } + if (attempt + 1 < baseAttempts) { + await waitMs(antigravityNoCapacityRetryDelay(attempt)); + continue attemptLoop; + } + } + + return lastResponse; + } + } + + return lastResponse || performFetch(input, withRequestBody(input.request, runtimeBody, { + Authorization: input.request.headers.Authorization || input.request.headers.authorization || '', + 'Content-Type': 'application/json', + Accept: input.request.runtime?.stream ? 'text/event-stream' : 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + })); +} + +export async function dispatchRuntimeRequest( + input: RuntimeDispatchInput, +): Promise>> { + const executor = input.request.runtime?.executor || 'default'; + if (executor === 'codex') { + return dispatchCodexRuntimeRequest(input); + } + if (executor === 'claude') { + return dispatchClaudeRuntimeRequest(input); + } + if (executor === 'gemini-cli') { + return dispatchGeminiCliRuntimeRequest(input); + } + if (executor === 'antigravity') { + return dispatchAntigravityRuntimeRequest(input); + } + return performFetch(input, input.request); +} diff --git a/src/server/routes/proxy/search.ts b/src/server/routes/proxy/search.ts index 5c161c2..c63aa7b 100644 --- a/src/server/routes/proxy/search.ts +++ b/src/server/routes/proxy/search.ts @@ -1,6 +1,6 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; -import { db, schema } from '../../db/index.js'; +import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js'; import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; @@ -11,6 +11,7 @@ import { withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { getProxyUrlFromExtraConfig } from '../../services/accountExtraConfig.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { getProxyAuthContext } from '../../middleware/auth.js'; import { buildUpstreamUrl } from './upstreamUrl.js'; const MAX_RETRIES = 2; @@ -53,6 +54,9 @@ export async function searchProxyRoute(app: FastifyInstance) { if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return; const downstreamPolicy = getDownstreamRoutingPolicy(request); + const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null; + const logDownstreamApiKeyId = downstreamApiKeyId !== null + && await hasProxyLogDownstreamApiKeyIdColumn(); const excludeChannelIds: number[] = []; let retryCount = 0; @@ -98,7 +102,7 @@ export async function searchProxyRoute(app: FastifyInstance) { const text = await upstream.text(); if (!upstream.ok) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount); + logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (isTokenExpiredError({ status: upstream.status, message: text })) { await reportTokenExpired({ accountId: selected.account.id, @@ -124,11 +128,11 @@ export async function searchProxyRoute(app: FastifyInstance) { const latency = Date.now() - startTime; tokenRouter.recordSuccess(selected.channel.id, latency, 0); recordDownstreamCostUsage(request, 0); - logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount); + logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); return reply.code(upstream.status).send(data); } catch (error: any) { tokenRouter.recordFailure(selected.channel.id); - logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount); + logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null); if (retryCount < MAX_RETRIES) { retryCount += 1; continue; @@ -153,6 +157,7 @@ async function logProxy( latencyMs: number, errorMessage: string | null, retryCount: number, + downstreamApiKeyId: number | null = null, ) { try { const createdAt = formatUtcSqlDateTime(new Date()); @@ -160,6 +165,7 @@ async function logProxy( routeId: selected.channel.routeId, channelId: selected.channel.id, accountId: selected.account.id, + ...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}), modelRequested, modelActual: selected.actualModel, status, diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index 00646c6..6b22a91 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -151,6 +151,16 @@ describe('resolveUpstreamEndpointCandidates', () => { 'claude', ); expect(claudeOrder).toEqual(['messages']); + + const codexOrder = await resolveUpstreamEndpointCandidates( + { + ...baseContext, + site: { ...baseContext.site, platform: 'codex', url: 'https://chatgpt.com/backend-api/codex' }, + }, + 'gpt-5.2-codex', + 'openai', + ); + expect(codexOrder).toEqual(['responses']); }); it('prefers document-capable endpoints when downstream content contains non-image files', async () => { @@ -319,6 +329,339 @@ describe('buildUpstreamEndpointRequest', () => { ]); }); + it('builds codex responses requests against backend-api path and preserves oauth provider headers', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.2-codex', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.2-codex', + messages: [{ role: 'user', content: 'hello codex' }], + temperature: 0.2, + top_p: 0.9, + user: 'drop-me', + service_tier: 'auto', + }, + downstreamFormat: 'openai', + providerHeaders: { + Originator: 'codex_cli_rs', + 'Chatgpt-Account-Id': 'chatgpt-account-123', + }, + codexSessionCacheKey: 'gpt-5.2-codex:proxy:test-key', + } as any); + + expect(request.path).toBe('/responses'); + expect(request.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(request.headers.Originator).toBe('codex_cli_rs'); + expect(request.headers['Chatgpt-Account-Id']).toBe('chatgpt-account-123'); + expect(request.headers.Version).toBe('0.101.0'); + expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(request.headers.Conversation_id).toBe(request.headers.Session_id); + expect(request.headers['User-Agent']).toBe('codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'); + expect(request.headers.Accept).toBe('text/event-stream'); + expect(request.headers.Connection).toBe('Keep-Alive'); + expect(request.body.instructions).toBe(''); + expect(request.body.prompt_cache_key).toBe(request.headers.Session_id); + expect(request.body.stream).toBe(true); + expect(request.body.store).toBe(false); + expect(request.body.parallel_tool_calls).toBe(true); + expect(request.body.include).toEqual(['reasoning.encrypted_content']); + expect(request.body.max_output_tokens).toBeUndefined(); + expect(request.body.temperature).toBeUndefined(); + expect(request.body.top_p).toBeUndefined(); + expect(request.body.user).toBeUndefined(); + expect(request.body.service_tier).toBeUndefined(); + }); + + it('reuses a stable codex session id when the same downstream continuity key is provided', () => { + const firstRequest = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello codex' }], + }, + downstreamFormat: 'openai', + providerHeaders: { + Originator: 'codex_cli_rs', + }, + codexSessionCacheKey: 'gpt-5.4:user-123', + } as any); + + const secondRequest = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: { + model: 'gpt-5.4', + messages: [{ role: 'user', content: 'hello again codex' }], + }, + downstreamFormat: 'openai', + providerHeaders: { + Originator: 'codex_cli_rs', + }, + codexSessionCacheKey: 'gpt-5.4:user-123', + } as any); + + expect(firstRequest.headers.Session_id).toBe(secondRequest.headers.Session_id); + expect(firstRequest.headers.Conversation_id).toBe(secondRequest.headers.Conversation_id); + expect(firstRequest.body.prompt_cache_key).toBe(secondRequest.body.prompt_cache_key); + }); + + it('does not synthesize prompt_cache_key or conversation_id for native codex responses requests without one', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.4', + input: 'hello codex', + }, + providerHeaders: { + Originator: 'codex_cli_rs', + }, + } as any); + + expect(request.headers.Session_id).toMatch(/^[0-9a-f-]{36}$/i); + expect(request.headers.Conversation_id).toBeUndefined(); + expect(request.body.prompt_cache_key).toBeUndefined(); + }); + + it('preserves explicit prompt_cache_key for native codex responses requests', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.4', + prompt_cache_key: 'codex-cache-123', + input: 'hello codex', + }, + providerHeaders: { + Originator: 'codex_cli_rs', + }, + codexExplicitSessionId: 'codex-cache-123', + } as any); + + expect(request.headers.Session_id).toBe('codex-cache-123'); + expect(request.headers.Conversation_id).toBe('codex-cache-123'); + expect(request.body.prompt_cache_key).toBe('codex-cache-123'); + }); + + it('builds gemini-cli native requests with project envelope and bearer headers', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gemini-2.5-pro', + stream: true, + tokenValue: 'oauth-access-token', + oauthProvider: 'gemini-cli', + oauthProjectId: 'project-demo', + sitePlatform: 'gemini-cli', + siteUrl: 'https://cloudcode-pa.googleapis.com', + openaiBody: { + model: 'gemini-2.5-pro', + messages: [ + { role: 'system', content: 'be concise' }, + { role: 'user', content: 'hello gemini cli' }, + ], + temperature: 0.4, + }, + downstreamFormat: 'openai', + providerHeaders: { + 'User-Agent': 'GeminiCLI/0.31.0/unknown (win32; x64)', + 'X-Goog-Api-Client': 'google-genai-sdk/1.41.0 gl-node/v22.19.0', + }, + }); + + expect(request.path).toBe('/v1internal:streamGenerateContent?alt=sse'); + expect(request.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(request.headers['User-Agent']).toBe('GeminiCLI/0.31.0/gemini-2.5-pro (win32; x64)'); + expect(request.headers['X-Goog-Api-Client']).toContain('google-genai-sdk/'); + expect(request.body.project).toBe('project-demo'); + expect(request.body.model).toBe('gemini-2.5-pro'); + expect(request.body.request).toMatchObject({ + generationConfig: { + temperature: 0.4, + }, + systemInstruction: { + role: 'user', + }, + contents: [ + { + role: 'user', + parts: [{ text: 'hello gemini cli' }], + }, + ], + }); + }); + + it('builds antigravity native requests with the same internal Gemini envelope', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'chat', + modelName: 'gemini-3-pro-preview', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'antigravity', + oauthProjectId: 'project-demo', + sitePlatform: 'antigravity', + siteUrl: 'https://cloudcode-pa.googleapis.com', + openaiBody: { + model: 'gemini-3-pro-preview', + messages: [ + { role: 'system', content: 'be concise' }, + { role: 'user', content: 'hello antigravity' }, + ], + }, + downstreamFormat: 'openai', + providerHeaders: { + 'User-Agent': 'google-api-nodejs-client/9.15.1', + 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1', + }, + }); + + expect(request.path).toBe('/v1internal:generateContent'); + expect(request.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(request.headers['User-Agent']).toBe('antigravity/1.19.6 darwin/arm64'); + expect(request.headers['X-Goog-Api-Client']).toBeUndefined(); + expect(request.headers['Client-Metadata']).toBeUndefined(); + expect(request.body).toEqual({ + project: 'project-demo', + model: 'gemini-3-pro-preview', + request: { + systemInstruction: { + role: 'user', + parts: [{ text: 'be concise' }], + }, + contents: [ + { + role: 'user', + parts: [{ text: 'hello antigravity' }], + }, + ], + }, + }); + }); + + it('uses claude-code runtime headers for claude oauth upstream requests', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'messages', + modelName: 'claude-opus-4-6', + stream: true, + tokenValue: 'oauth-access-token', + oauthProvider: 'claude', + sitePlatform: 'claude', + siteUrl: 'https://api.anthropic.com', + openaiBody: { + model: 'claude-opus-4-6', + messages: [{ role: 'user', content: 'hello claude oauth' }], + }, + downstreamFormat: 'openai', + }); + + expect(request.path).toBe('/v1/messages'); + expect(request.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(request.headers['x-api-key']).toBeUndefined(); + expect(request.headers['anthropic-version']).toBe('2023-06-01'); + expect(request.headers['Anthropic-Dangerous-Direct-Browser-Access']).toBe('true'); + expect(request.headers['X-App']).toBe('cli'); + expect(request.headers['X-Stainless-Retry-Count']).toBe('0'); + expect(request.headers['X-Stainless-Runtime-Version']).toBe('v24.3.0'); + expect(request.headers['X-Stainless-Package-Version']).toBe('0.74.0'); + expect(request.headers['X-Stainless-Runtime']).toBe('node'); + expect(request.headers['X-Stainless-Lang']).toBe('js'); + expect(request.headers['X-Stainless-Arch']).toBe('x64'); + expect(request.headers['X-Stainless-Os']).toBe('Windows'); + expect(request.headers['X-Stainless-Timeout']).toBe('600'); + expect(request.headers['User-Agent']).toBe('claude-cli/2.1.63 (external, cli)'); + expect(request.headers.Connection).toBe('keep-alive'); + expect(request.headers.Accept).toBe('text/event-stream'); + expect(request.headers['Accept-Encoding']).toBe('identity'); + }); + + it('uses claude-code beta headers and compressed non-stream responses for claude upstream requests', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'messages', + modelName: 'claude-opus-4-6', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'claude', + sitePlatform: 'claude', + siteUrl: 'https://api.anthropic.com', + openaiBody: { + model: 'claude-opus-4-6', + messages: [{ role: 'user', content: 'hello claude oauth' }], + }, + downstreamFormat: 'openai', + }); + + expect(request.headers['anthropic-beta']).toContain('claude-code-20250219'); + expect(request.headers['anthropic-beta']).toContain('oauth-2025-04-20'); + expect(request.headers['anthropic-beta']).toContain('context-management-2025-06-27'); + expect(request.headers.Accept).toBe('application/json'); + expect(request.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd'); + }); + + it('converts system roles to developer in native codex responses bodies', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'responses', + modelName: 'gpt-5.4', + stream: false, + tokenValue: 'oauth-access-token', + sitePlatform: 'codex', + siteUrl: 'https://chatgpt.com/backend-api/codex', + openaiBody: {}, + downstreamFormat: 'responses', + responsesOriginalBody: { + model: 'gpt-5.4', + input: [ + { + type: 'message', + role: 'system', + content: [{ type: 'input_text', text: 'be careful' }], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ], + }, + }); + + expect(request.body.input).toEqual([ + { + type: 'message', + role: 'developer', + content: [{ type: 'input_text', text: 'be careful' }], + }, + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hello' }], + }, + ]); + }); + it('normalizes downstream responses input string before forwarding upstream', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'responses', diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 78baaa1..8ffdc25 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -1,3 +1,4 @@ +import { createHash, randomUUID } from 'node:crypto'; import { fetchModelPricingCatalog } from '../../services/modelPricingService.js'; import type { DownstreamFormat } from '../../transformers/shared/normalized.js'; import { @@ -8,6 +9,10 @@ import { convertOpenAiBodyToAnthropicMessagesBody, sanitizeAnthropicMessagesBody, } from '../../transformers/anthropic/messages/conversion.js'; +import { + buildGeminiGenerateContentRequestFromOpenAi, + wrapGeminiCliRequest, +} from './geminiCliCompat.js'; export { buildMinimalJsonHeadersForCompatibility, isEndpointDispatchDeniedError, @@ -94,6 +99,12 @@ const BLOCKED_PASSTHROUGH_HEADERS = new Set([ 'sec-websocket-extensions', ]); +const CODEX_CLIENT_VERSION = '0.101.0'; +const CODEX_DEFAULT_USER_AGENT = 'codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464'; +const ANTIGRAVITY_RUNTIME_USER_AGENT = 'antigravity/1.19.6 darwin/arm64'; +const CLAUDE_DEFAULT_USER_AGENT = 'claude-cli/2.1.63 (external, cli)'; +const CLAUDE_DEFAULT_BETA_HEADER = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05'; + function shouldSkipPassthroughHeader(key: string): boolean { return HOP_BY_HOP_HEADERS.has(key) || BLOCKED_PASSTHROUGH_HEADERS.has(key); } @@ -164,6 +175,239 @@ function extractResponsesPassthroughHeaders( return forwarded; } +function getInputHeader( + headers: Record | Record | undefined, + key: string, +): string | null { + if (!headers) return null; + for (const [candidateKey, candidateValue] of Object.entries(headers)) { + if (candidateKey.toLowerCase() !== key.toLowerCase()) continue; + return headerValueToString(candidateValue); + } + return null; +} + +function parseGeminiCliUserAgentRuntime(userAgent: string | null): { + version: string; + platform: string; + arch: string; +} | null { + if (!userAgent) return null; + const match = /^GeminiCLI\/([^/]+)\/[^ ]+ \(([^;]+); ([^)]+)\)$/i.exec(userAgent.trim()); + if (!match) return null; + return { + version: match[1] || '0.31.0', + platform: match[2] || 'win32', + arch: match[3] || 'x64', + }; +} + +function buildGeminiCLIUserAgent(modelName: string, existingUserAgent?: string | null): string { + const parsed = parseGeminiCliUserAgentRuntime(existingUserAgent ?? null); + const version = parsed?.version || '0.31.0'; + const platform = parsed?.platform || 'win32'; + const arch = parsed?.arch || 'x64'; + const effectiveModel = asTrimmedString(modelName) || 'unknown'; + return `GeminiCLI/${version}/${effectiveModel} (${platform}; ${arch})`; +} + +function uuidFromSeed(seed: string): string { + const hash = createHash('sha1').update(seed).digest(); + const bytes = new Uint8Array(hash.subarray(0, 16)); + bytes[6] = (bytes[6]! & 0x0f) | 0x50; + bytes[8] = (bytes[8]! & 0x3f) | 0x80; + const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join(''); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join('-'); +} + +function mergeClaudeBetaHeader( + explicitValue: string | null, + extraBetas: string[] = [], +): string { + const source = explicitValue || CLAUDE_DEFAULT_BETA_HEADER; + const seen = new Set(); + const merged: string[] = []; + for (const entry of source.split(',')) { + const normalized = entry.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + merged.push(normalized); + } + if (!explicitValue) { + for (const entry of extraBetas) { + const normalized = entry.trim(); + if (!normalized || seen.has(normalized)) continue; + seen.add(normalized); + merged.push(normalized); + } + } + return merged.join(','); +} + +function extractClaudeBetasFromBody(body: Record): { + body: Record; + betas: string[]; +} { + const next = { ...body }; + const rawBetas = next.betas; + delete next.betas; + + if (typeof rawBetas === 'string') { + return { + body: next, + betas: rawBetas.split(',').map((entry) => entry.trim()).filter(Boolean), + }; + } + + if (Array.isArray(rawBetas)) { + return { + body: next, + betas: rawBetas + .map((entry) => asTrimmedString(entry)) + .filter(Boolean), + }; + } + + return { + body: next, + betas: [], + }; +} + +function buildCodexRuntimeHeaders(input: { + baseHeaders: Record; + providerHeaders?: Record; + explicitSessionId?: string | null; + continuityKey?: string | null; +}): Record { + const authorization = ( + getInputHeader(input.baseHeaders, 'authorization') + || getInputHeader(input.baseHeaders, 'Authorization') + || '' + ); + const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs'; + const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id'); + const version = getInputHeader(input.baseHeaders, 'version') || CODEX_CLIENT_VERSION; + const userAgent = getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT; + const explicitSessionId = asTrimmedString(input.explicitSessionId); + const continuityKey = asTrimmedString(input.continuityKey); + const sessionId = ( + getInputHeader(input.baseHeaders, 'session_id') + || getInputHeader(input.baseHeaders, 'session-id') + || explicitSessionId + || (continuityKey ? uuidFromSeed(`metapi:codex:${continuityKey}`) : null) + || randomUUID() + ); + const conversationId = ( + getInputHeader(input.baseHeaders, 'conversation_id') + || getInputHeader(input.baseHeaders, 'conversation-id') + || explicitSessionId + || (continuityKey ? sessionId : null) + ); + + return { + Authorization: authorization, + 'Content-Type': 'application/json', + ...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}), + Originator: originator, + Version: version, + Session_id: sessionId, + ...(conversationId ? { Conversation_id: conversationId } : {}), + 'User-Agent': userAgent, + Accept: 'text/event-stream', + Connection: 'Keep-Alive', + }; +} + +function buildGeminiCliRuntimeHeaders(input: { + baseHeaders: Record; + providerHeaders?: Record; + modelName: string; + stream: boolean; +}): Record { + const apiClient = ( + getInputHeader(input.providerHeaders, 'x-goog-api-client') + || getInputHeader(input.baseHeaders, 'x-goog-api-client') + ); + const userAgent = buildGeminiCLIUserAgent( + input.modelName, + getInputHeader(input.providerHeaders, 'user-agent') || getInputHeader(input.baseHeaders, 'user-agent'), + ); + + const headers: Record = { + Authorization: input.baseHeaders.Authorization, + 'Content-Type': 'application/json', + 'User-Agent': userAgent, + }; + if (apiClient) { + headers['X-Goog-Api-Client'] = apiClient; + } + if (input.stream) { + headers.Accept = 'text/event-stream'; + } + return headers; +} + +function buildAntigravityRuntimeHeaders(input: { + baseHeaders: Record; + stream: boolean; +}): Record { + const headers: Record = { + Authorization: input.baseHeaders.Authorization, + 'Content-Type': 'application/json', + Accept: input.stream ? 'text/event-stream' : 'application/json', + 'User-Agent': ANTIGRAVITY_RUNTIME_USER_AGENT, + }; + return headers; +} + +function buildClaudeRuntimeHeaders(input: { + baseHeaders: Record; + claudeHeaders: Record; + anthropicVersion: string; + stream: boolean; + isClaudeOauthUpstream: boolean; + tokenValue: string; + extraBetas?: string[]; +}): Record { + const anthropicBeta = mergeClaudeBetaHeader( + getInputHeader(input.claudeHeaders, 'anthropic-beta'), + input.extraBetas, + ); + const headers: Record = { + ...input.baseHeaders, + ...input.claudeHeaders, + 'anthropic-version': input.anthropicVersion, + ...(anthropicBeta ? { 'anthropic-beta': anthropicBeta } : {}), + 'Anthropic-Dangerous-Direct-Browser-Access': 'true', + 'X-App': 'cli', + 'X-Stainless-Retry-Count': getInputHeader(input.claudeHeaders, 'x-stainless-retry-count') || '0', + 'X-Stainless-Runtime-Version': getInputHeader(input.claudeHeaders, 'x-stainless-runtime-version') || 'v24.3.0', + 'X-Stainless-Package-Version': getInputHeader(input.claudeHeaders, 'x-stainless-package-version') || '0.74.0', + 'X-Stainless-Runtime': getInputHeader(input.claudeHeaders, 'x-stainless-runtime') || 'node', + 'X-Stainless-Lang': getInputHeader(input.claudeHeaders, 'x-stainless-lang') || 'js', + 'X-Stainless-Arch': getInputHeader(input.claudeHeaders, 'x-stainless-arch') || 'x64', + 'X-Stainless-Os': getInputHeader(input.claudeHeaders, 'x-stainless-os') || 'Windows', + 'X-Stainless-Timeout': getInputHeader(input.claudeHeaders, 'x-stainless-timeout') || '600', + 'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT, + Connection: 'keep-alive', + Accept: input.stream ? 'text/event-stream' : 'application/json', + 'Accept-Encoding': input.stream ? 'identity' : 'gzip, deflate, br, zstd', + }; + if (input.isClaudeOauthUpstream) { + headers.Authorization = `Bearer ${input.tokenValue}`; + } else { + headers['x-api-key'] = input.tokenValue; + } + return headers; +} + function ensureStreamAcceptHeader( headers: Record, stream: boolean, @@ -186,6 +430,89 @@ function toFiniteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } +function ensureCodexResponsesInstructions( + body: Record, + sitePlatform: string, +): Record { + if (sitePlatform !== 'codex') return body; + if (typeof body.instructions === 'string') return body; + return { + ...body, + instructions: '', + }; +} + +function ensureCodexResponsesStoreFalse( + body: Record, + sitePlatform: string, +): Record { + if (sitePlatform !== 'codex') return body; + if (body.store === false) return body; + return { + ...body, + store: false, + }; +} + +function convertCodexSystemRoleToDeveloper(input: unknown): unknown { + if (!Array.isArray(input)) return input; + return input.map((item) => { + if (!isRecord(item)) return item; + if (asTrimmedString(item.type).toLowerCase() !== 'message') return item; + if (asTrimmedString(item.role).toLowerCase() !== 'system') return item; + return { + ...item, + role: 'developer', + }; + }); +} + +function applyCodexResponsesCompatibility( + body: Record, + sitePlatform: string, + options?: { + preservePreviousResponseId?: boolean; + }, +): Record { + if (sitePlatform !== 'codex') return body; + + const next: Record = { + ...body, + stream: true, + store: false, + parallel_tool_calls: true, + include: ['reasoning.encrypted_content'], + input: convertCodexSystemRoleToDeveloper(body.input), + }; + + if (typeof next.instructions !== 'string') { + next.instructions = ''; + } + + for (const key of [ + 'max_output_tokens', + 'max_completion_tokens', + 'temperature', + 'top_p', + 'truncation', + 'user', + 'context_management', + 'prompt_cache_retention', + 'safety_identifier', + ]) { + delete next[key]; + } + if (!options?.preservePreviousResponseId) { + delete next.previous_response_id; + } + + if (asTrimmedString(next.service_tier).toLowerCase() !== 'priority') { + delete next.service_tier; + } + + return next; +} + function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] { const raw = asTrimmedString(value).toLowerCase(); @@ -237,11 +564,23 @@ function preferredEndpointOrder( ): UpstreamEndpoint[] { const platform = normalizePlatformName(sitePlatform); + if (platform === 'codex') { + return ['responses']; + } + if (platform === 'gemini') { // Gemini upstream is routed through OpenAI-compatible chat endpoint. return ['chat']; } + if (platform === 'gemini-cli') { + return ['chat']; + } + + if (platform === 'antigravity') { + return ['chat']; + } + if (platform === 'openai') { if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') { // Some OpenAI-compatible gateways expose Claude natively via /v1/messages. @@ -319,6 +658,7 @@ export async function resolveUpstreamEndpointCandidates( ? (() => { if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[]; if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[]; + if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') return ['chat'] as UpstreamEndpoint[]; if (preferMessagesForClaudeModel) return ['messages', 'responses', 'chat'] as UpstreamEndpoint[]; return ['responses', 'messages', 'chat'] as UpstreamEndpoint[]; })() @@ -338,6 +678,8 @@ export async function resolveUpstreamEndpointCandidates( && preferMessagesForClaudeModel && sitePlatform !== 'openai' && sitePlatform !== 'gemini' + && sitePlatform !== 'antigravity' + && sitePlatform !== 'gemini-cli' ); try { @@ -422,6 +764,8 @@ export function buildUpstreamEndpointRequest(input: { modelName: string; stream: boolean; tokenValue: string; + oauthProvider?: string; + oauthProjectId?: string; sitePlatform?: string; siteUrl?: string; openaiBody: Record; @@ -430,14 +774,28 @@ export function buildUpstreamEndpointRequest(input: { forceNormalizeClaudeBody?: boolean; responsesOriginalBody?: Record; downstreamHeaders?: Record; + providerHeaders?: Record; + codexSessionCacheKey?: string | null; + codexExplicitSessionId?: string | null; }): { path: string; headers: Record; body: Record; + runtime?: { + executor: 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude'; + modelName?: string; + stream?: boolean; + oauthProjectId?: string | null; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }; } { const sitePlatform = normalizePlatformName(input.sitePlatform); const isClaudeUpstream = sitePlatform === 'claude'; const isGeminiUpstream = sitePlatform === 'gemini'; + const isGeminiCliUpstream = sitePlatform === 'gemini-cli'; + const isAntigravityUpstream = sitePlatform === 'antigravity'; + const isInternalGeminiUpstream = isGeminiCliUpstream || isAntigravityUpstream; + const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude'; const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => { const normalizedSiteUrl = asTrimmedString(input.siteUrl).toLowerCase(); @@ -463,6 +821,16 @@ export function buildUpstreamEndpointRequest(input: { return '/v1/chat/completions'; } + if (sitePlatform === 'codex') { + return '/responses'; + } + + if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') { + return input.stream + ? '/v1internal:streamGenerateContent?alt=sse' + : '/v1internal:generateContent'; + } + if (sitePlatform === 'claude') { return '/v1/messages'; } @@ -476,6 +844,7 @@ export function buildUpstreamEndpointRequest(input: { const commonHeaders: Record = { ...passthroughHeaders, 'Content-Type': 'application/json', + ...(input.providerHeaders || {}), }; if (!isClaudeUpstream) { commonHeaders.Authorization = `Bearer ${input.tokenValue}`; @@ -483,7 +852,7 @@ export function buildUpstreamEndpointRequest(input: { const stripGeminiUnsupportedFields = (body: Record) => { const next = { ...body }; - if (isGeminiUpstream) { + if (isGeminiUpstream || isInternalGeminiUpstream) { for (const key of [ 'frequency_penalty', 'presence_penalty', @@ -499,6 +868,61 @@ export function buildUpstreamEndpointRequest(input: { }; const openaiBody = stripGeminiUnsupportedFields(input.openaiBody); + const runtime = { + executor: ( + sitePlatform === 'codex' + ? 'codex' + : sitePlatform === 'gemini-cli' + ? 'gemini-cli' + : sitePlatform === 'antigravity' + ? 'antigravity' + : sitePlatform === 'claude' + ? 'claude' + : 'default' + ) as 'default' | 'codex' | 'gemini-cli' | 'antigravity' | 'claude', + modelName: input.modelName, + stream: input.stream, + oauthProjectId: asTrimmedString(input.oauthProjectId) || null, + }; + + if (isInternalGeminiUpstream) { + const projectId = asTrimmedString(input.oauthProjectId); + if (isGeminiCliUpstream && !projectId) { + throw new Error('gemini-cli oauth project id missing'); + } + const instructions = ( + input.downstreamFormat === 'responses' + && typeof input.responsesOriginalBody?.instructions === 'string' + ) + ? input.responsesOriginalBody.instructions + : undefined; + const geminiRequest = buildGeminiGenerateContentRequestFromOpenAi({ + body: openaiBody, + modelName: input.modelName, + instructions, + }); + const headers = isGeminiCliUpstream + ? buildGeminiCliRuntimeHeaders({ + baseHeaders: commonHeaders, + providerHeaders: input.providerHeaders, + modelName: input.modelName, + stream: input.stream, + }) + : buildAntigravityRuntimeHeaders({ + baseHeaders: commonHeaders, + stream: input.stream, + }); + return { + path: resolveEndpointPath(input.endpoint), + headers, + body: wrapGeminiCliRequest({ + modelName: input.modelName, + projectId: projectId || '', + request: geminiRequest, + }) as Record, + runtime, + }; + } if (input.endpoint === 'messages') { const claudeHeaders = input.downstreamFormat === 'claude' @@ -537,21 +961,26 @@ export function buildUpstreamEndpointRequest(input: { convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream), ); - const headers = ensureStreamAcceptHeader({ - ...commonHeaders, - ...claudeHeaders, - 'x-api-key': input.tokenValue, - 'anthropic-version': anthropicVersion, - }, input.stream); + const headers = buildClaudeRuntimeHeaders({ + baseHeaders: commonHeaders, + claudeHeaders, + anthropicVersion, + stream: input.stream, + isClaudeOauthUpstream, + tokenValue: input.tokenValue, + }); return { path: resolveEndpointPath('messages'), headers, body: sanitizedBody, + runtime, }; } if (input.endpoint === 'responses') { + const websocketMode = Object.entries(input.downstreamHeaders || {}).find(([rawKey]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-mode'); + const preserveWebsocketIncrementalMode = asTrimmedString(websocketMode?.[1]).toLowerCase() === 'incremental'; const responsesHeaders = input.downstreamFormat === 'responses' ? extractResponsesPassthroughHeaders(input.downstreamHeaders) : {}; @@ -564,17 +993,56 @@ export function buildUpstreamEndpointRequest(input: { } : convertOpenAiBodyToResponsesBodyViaTransformer(openaiBody, input.modelName, input.stream) ); - const body = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream); + const sanitizedResponsesBody = sanitizeResponsesBodyForProxyViaTransformer(rawBody, input.modelName, input.stream); + if (preserveWebsocketIncrementalMode && rawBody.generate === false) { + sanitizedResponsesBody.generate = false; + } + const body = ensureCodexResponsesStoreFalse( + ensureCodexResponsesInstructions( + applyCodexResponsesCompatibility( + sanitizedResponsesBody, + sitePlatform, + { preservePreviousResponseId: preserveWebsocketIncrementalMode }, + ), + sitePlatform, + ), + sitePlatform, + ); - const headers = ensureStreamAcceptHeader({ - ...commonHeaders, - ...responsesHeaders, - }, input.stream); + const headers = sitePlatform === 'codex' + ? buildCodexRuntimeHeaders({ + baseHeaders: { + ...commonHeaders, + ...responsesHeaders, + }, + providerHeaders: input.providerHeaders, + explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null, + continuityKey: asTrimmedString(input.codexSessionCacheKey) || null, + }) + : ensureStreamAcceptHeader({ + ...commonHeaders, + ...responsesHeaders, + }, input.stream); + const codexSessionId = sitePlatform === 'codex' + ? (getInputHeader(headers, 'session_id') || getInputHeader(headers, 'session-id')) + : null; + const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex' + && !!codexSessionId + && !asTrimmedString((body as Record).prompt_cache_key) + && !asTrimmedString(input.codexExplicitSessionId) + && !!asTrimmedString(input.codexSessionCacheKey); + const runtimeBody = shouldInjectDerivedPromptCacheKey + ? { + ...body, + prompt_cache_key: codexSessionId, + } + : body; return { path: resolveEndpointPath('responses'), headers, - body, + body: runtimeBody, + runtime, }; } @@ -587,6 +1055,63 @@ export function buildUpstreamEndpointRequest(input: { model: input.modelName, stream: input.stream, }, + runtime, + }; +} + +export function buildClaudeCountTokensUpstreamRequest(input: { + modelName: string; + tokenValue: string; + oauthProvider?: string; + sitePlatform?: string; + claudeBody: Record; + downstreamHeaders?: Record; +}): { + path: string; + headers: Record; + body: Record; + runtime: { + executor: 'claude'; + modelName: string; + stream: false; + action: 'countTokens'; + }; +} { + const sitePlatform = normalizePlatformName(input.sitePlatform); + const isClaudeOauthUpstream = sitePlatform === 'claude' && input.oauthProvider === 'claude'; + const claudeHeaders = extractClaudePassthroughHeaders(input.downstreamHeaders); + const anthropicVersion = claudeHeaders['anthropic-version'] || '2023-06-01'; + const { body: bodyWithoutBetas, betas } = extractClaudeBetasFromBody({ + ...input.claudeBody, + model: input.modelName, + }); + const sanitizedBody = sanitizeAnthropicMessagesBody(bodyWithoutBetas); + delete sanitizedBody.max_tokens; + delete sanitizedBody.maxTokens; + delete sanitizedBody.stream; + + const headers = buildClaudeRuntimeHeaders({ + baseHeaders: { + 'Content-Type': 'application/json', + }, + claudeHeaders, + anthropicVersion, + stream: false, + isClaudeOauthUpstream, + tokenValue: input.tokenValue, + extraBetas: betas, + }); + + return { + path: '/v1/messages/count_tokens?beta=true', + headers, + body: sanitizedBody, + runtime: { + executor: 'claude', + modelName: input.modelName, + stream: false, + action: 'countTokens', + }, }; } diff --git a/src/server/services/accountExtraConfig.test.ts b/src/server/services/accountExtraConfig.test.ts index 2143dca..9868242 100644 --- a/src/server/services/accountExtraConfig.test.ts +++ b/src/server/services/accountExtraConfig.test.ts @@ -8,6 +8,8 @@ import { mergeAccountExtraConfig, normalizeCredentialMode, resolvePlatformUserId, + requiresManagedAccountTokens, + supportsDirectAccountRoutingConnection, } from './accountExtraConfig.js'; describe('accountExtraConfig', () => { @@ -82,4 +84,40 @@ describe('accountExtraConfig', () => { expect(getProxyUrlFromExtraConfig(undefined)).toBeNull(); expect(getProxyUrlFromExtraConfig('invalid-json')).toBeNull(); }); + + it('treats auto-mode api token connections as direct-account routable', () => { + expect(supportsDirectAccountRoutingConnection({ + accessToken: '', + apiToken: 'sk-demo', + extraConfig: null, + })).toBe(true); + expect(requiresManagedAccountTokens({ + accessToken: '', + apiToken: 'sk-demo', + extraConfig: null, + })).toBe(false); + }); + + it('treats oauth and session connections as non-managed-token direct routes only when intended', () => { + expect(supportsDirectAccountRoutingConnection({ + accessToken: 'oauth-access-token', + apiToken: null, + extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }), + })).toBe(true); + expect(requiresManagedAccountTokens({ + accessToken: 'oauth-access-token', + apiToken: null, + extraConfig: JSON.stringify({ credentialMode: 'session', oauth: { provider: 'codex' } }), + })).toBe(false); + expect(supportsDirectAccountRoutingConnection({ + accessToken: 'session-token', + apiToken: 'sk-default', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + })).toBe(false); + expect(requiresManagedAccountTokens({ + accessToken: 'session-token', + apiToken: 'sk-default', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + })).toBe(true); + }); }); diff --git a/src/server/services/accountExtraConfig.ts b/src/server/services/accountExtraConfig.ts index 1950761..3dadf59 100644 --- a/src/server/services/accountExtraConfig.ts +++ b/src/server/services/accountExtraConfig.ts @@ -20,6 +20,10 @@ const VALID_CREDENTIAL_MODES = new Set([ type AccountExtraConfig = { platformUserId?: unknown; credentialMode?: unknown; + oauth?: { + provider?: unknown; + [key: string]: unknown; + }; autoRelogin?: AutoReloginConfig; sub2apiAuth?: Sub2ApiAuthConfig; [key: string]: unknown; @@ -82,6 +86,49 @@ export function getCredentialModeFromExtraConfig(extraConfig?: string | null): A return normalizeCredentialMode(parsed.credentialMode); } +export function getOauthProviderFromExtraConfig(extraConfig?: string | null): string | undefined { + const parsed = parseExtraConfig(extraConfig); + return normalizeNonEmptyString(parsed.oauth?.provider); +} + +export function hasOauthProvider(extraConfig?: string | null): boolean { + return !!getOauthProviderFromExtraConfig(extraConfig); +} + +type DirectAccountRoutingInput = { + accessToken?: string | null; + apiToken?: string | null; + extraConfig?: string | null; +}; + +function hasCredentialValue(value: string | null | undefined): boolean { + return typeof value === 'string' && value.trim().length > 0; +} + +export function supportsDirectAccountRoutingConnection(account: DirectAccountRoutingInput): boolean { + const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig); + if (hasOauthProvider(account.extraConfig)) { + return hasCredentialValue(account.accessToken) || hasCredentialValue(account.apiToken); + } + if (credentialMode === 'apikey') { + return hasCredentialValue(account.apiToken); + } + if (credentialMode === 'session') { + return false; + } + if (hasCredentialValue(account.accessToken)) return false; + return hasCredentialValue(account.apiToken); +} + +export function requiresManagedAccountTokens(account: DirectAccountRoutingInput): boolean { + const credentialMode = getCredentialModeFromExtraConfig(account.extraConfig); + if (hasOauthProvider(account.extraConfig)) return false; + if (credentialMode === 'apikey') return false; + if (credentialMode === 'session') return true; + if (hasCredentialValue(account.apiToken) && !hasCredentialValue(account.accessToken)) return false; + return true; +} + export type ManagedSub2ApiAuth = { refreshToken: string; tokenExpiresAt?: number; diff --git a/src/server/services/accountTokenService.ts b/src/server/services/accountTokenService.ts index 56954fa..da03492 100644 --- a/src/server/services/accountTokenService.ts +++ b/src/server/services/accountTokenService.ts @@ -9,6 +9,14 @@ type UpstreamApiToken = { tokenGroup?: string | null; }; +type AccountTokenRow = typeof schema.accountTokens.$inferSelect; + +export const ACCOUNT_TOKEN_VALUE_STATUS_READY = 'ready' as const; +export const ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING = 'masked_pending' as const; +export type AccountTokenValueStatus = + | typeof ACCOUNT_TOKEN_VALUE_STATUS_READY + | typeof ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING; + export function normalizeTokenForDisplay(token?: string | null, platform?: string | null): string { if (!token) return ''; const value = token.trim(); @@ -52,6 +60,45 @@ export function isMaskedTokenValue(token: string | null | undefined): boolean { return value.includes('*') || value.includes('•'); } +function normalizeTokenValueStatus(value: string | null | undefined): AccountTokenValueStatus { + const normalized = (value || '').trim().toLowerCase(); + return normalized === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; +} + +export function resolveAccountTokenValueStatus( + value: Pick | string | null | undefined, +): AccountTokenValueStatus { + if (typeof value === 'string' || value == null) { + return normalizeTokenValueStatus(value); + } + + const explicit = normalizeTokenValueStatus(value.valueStatus); + if (explicit === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + return ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING; + } + return isMaskedTokenValue(value.token) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; +} + +export function isReadyAccountToken(token: Pick | null | undefined): boolean { + if (!token) return false; + return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_READY + && !isMaskedTokenValue(token.token); +} + +export function isMaskedPendingAccountToken(token: Pick | null | undefined): boolean { + if (!token) return false; + return resolveAccountTokenValueStatus(token) === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING; +} + +export function isUsableAccountToken(token: AccountTokenRow | null | undefined): boolean { + if (!token) return false; + return token.enabled === true && isReadyAccountToken(token); +} + function normalizeTokenGroup(value: string | null | undefined, tokenName?: string | null): string | null { const explicit = (value || '').trim(); if (explicit.length > 0) return explicit; @@ -66,6 +113,15 @@ function normalizeTokenGroup(value: string | null | undefined, tokenName?: strin return name; } +function sameTokenGroup( + leftGroup: string | null | undefined, + leftName: string | null | undefined, + rightGroup: string | null | undefined, + rightName: string | null | undefined, +): boolean { + return normalizeTokenGroup(leftGroup, leftName) === normalizeTokenGroup(rightGroup, rightName); +} + async function updateAccountApiToken(accountId: number, tokenValue: string | null) { await db.update(schema.accounts) .set({ apiToken: tokenValue || null, updatedAt: new Date().toISOString() }) @@ -85,9 +141,10 @@ export async function getPreferredAccountToken(accountId: number) { .where(and(eq(schema.accountTokens.accountId, accountId), eq(schema.accountTokens.enabled, true))) .all(); - if (tokens.length === 0) return null; + const usableTokens = tokens.filter(isUsableAccountToken); + if (usableTokens.length === 0) return null; - const preferred = tokens.find((t) => t.isDefault) || tokens[0]; + const preferred = usableTokens.find((t) => t.isDefault) || usableTokens[0]; return preferred; } @@ -98,6 +155,7 @@ export async function ensureDefaultTokenForAccount( ): Promise { const normalizedToken = normalizeTokenValue(tokenValue); if (!normalizedToken) return null; + if (isMaskedTokenValue(normalizedToken)) return null; const tokenGroup = normalizeTokenGroup(options?.tokenGroup, options?.name) || 'default'; const now = new Date().toISOString(); @@ -114,6 +172,7 @@ export async function ensureDefaultTokenForAccount( name: normalizeTokenName(options?.name, tokens.length + 1), token: normalizedToken, tokenGroup, + valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY, source: options?.source || 'manual', enabled: options?.enabled ?? true, isDefault: true, @@ -131,6 +190,7 @@ export async function ensureDefaultTokenForAccount( .set({ name: options?.name ? normalizeTokenName(options.name) : target.name, tokenGroup, + valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY, source: options?.source || target.source || 'manual', enabled: options?.enabled ?? target.enabled, isDefault: true, @@ -151,7 +211,7 @@ export async function ensureDefaultTokenForAccount( export async function setDefaultToken(tokenId: number): Promise { const target = await db.select().from(schema.accountTokens).where(eq(schema.accountTokens.id, tokenId)).get(); - if (!target) return false; + if (!target || !isUsableAccountToken(target)) return false; const now = new Date().toISOString(); await db.update(schema.accountTokens) @@ -174,7 +234,7 @@ export async function repairDefaultToken(accountId: number) { .where(eq(schema.accountTokens.accountId, accountId)) .all(); - const enabled = tokens.filter((t) => t.enabled); + const enabled = tokens.filter(isUsableAccountToken); if (enabled.length === 0) { await updateAccountApiToken(accountId, null); return null; @@ -206,27 +266,30 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: let created = 0; let updated = 0; - let maskedSkipped = 0; + let maskedPending = 0; + const pendingTokenIds: number[] = []; let index = existing.length + 1; for (const upstream of upstreamTokens) { const tokenValue = normalizeTokenValue(upstream.key); if (!tokenValue) continue; - if (isMaskedTokenValue(tokenValue)) { - maskedSkipped++; - continue; - } - const tokenName = normalizeTokenName(upstream.name, index); const enabled = upstream.enabled ?? true; const tokenGroup = normalizeTokenGroup(upstream.tokenGroup, tokenName); + const nextValueStatus = isMaskedTokenValue(tokenValue) + ? ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING + : ACCOUNT_TOKEN_VALUE_STATUS_READY; - const byToken = existing.find((row) => row.token === tokenValue); + const byToken = existing.find((row) => ( + row.token === tokenValue + && resolveAccountTokenValueStatus(row) === ACCOUNT_TOKEN_VALUE_STATUS_READY + )); if (byToken) { await db.update(schema.accountTokens) .set({ name: tokenName, tokenGroup, + valueStatus: ACCOUNT_TOKEN_VALUE_STATUS_READY, source: 'sync', enabled, updatedAt: now, @@ -235,6 +298,7 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: .run(); byToken.name = tokenName; byToken.tokenGroup = tokenGroup; + byToken.valueStatus = ACCOUNT_TOKEN_VALUE_STATUS_READY; byToken.enabled = enabled; byToken.source = 'sync'; byToken.updatedAt = now; @@ -242,14 +306,52 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: continue; } + const matchingPlaceholder = existing.find((row) => ( + isMaskedPendingAccountToken(row) + && row.name === tokenName + && sameTokenGroup(row.tokenGroup, row.name, tokenGroup, tokenName) + )); + + if (matchingPlaceholder) { + const nextEnabled = nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false; + await db.update(schema.accountTokens) + .set({ + name: tokenName, + token: tokenValue, + tokenGroup, + valueStatus: nextValueStatus, + source: 'sync', + enabled: nextEnabled, + isDefault: false, + updatedAt: now, + }) + .where(eq(schema.accountTokens.id, matchingPlaceholder.id)) + .run(); + matchingPlaceholder.name = tokenName; + matchingPlaceholder.token = tokenValue; + matchingPlaceholder.tokenGroup = tokenGroup; + matchingPlaceholder.valueStatus = nextValueStatus; + matchingPlaceholder.source = 'sync'; + matchingPlaceholder.enabled = nextEnabled; + matchingPlaceholder.isDefault = false; + matchingPlaceholder.updatedAt = now; + updated++; + if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + maskedPending++; + pendingTokenIds.push(matchingPlaceholder.id); + } + continue; + } + const inserted = await db.insert(schema.accountTokens) .values({ accountId, name: tokenName, token: tokenValue, tokenGroup, + valueStatus: nextValueStatus, source: 'sync', - enabled, + enabled: nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_READY ? enabled : false, isDefault: false, createdAt: now, updatedAt: now, @@ -263,6 +365,10 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: existing.push(createdRow); created++; index++; + if (nextValueStatus === ACCOUNT_TOKEN_VALUE_STATUS_MASKED_PENDING) { + maskedPending++; + pendingTokenIds.push(createdRow.id); + } } const repaired = await repairDefaultToken(accountId); @@ -270,7 +376,8 @@ export async function syncTokensFromUpstream(accountId: number, upstreamTokens: return { created, updated, - maskedSkipped, + maskedPending, + pendingTokenIds, total: existing.length, defaultTokenId: repaired?.id || null, }; @@ -292,6 +399,7 @@ export async function listTokensWithRelations(accountId?: number) { const { token, ...tokenMeta } = row.account_tokens; return { ...tokenMeta, + valueStatus: resolveAccountTokenValueStatus(row.account_tokens), tokenMasked: maskToken(token, row.sites.platform), account: { id: row.accounts.id, diff --git a/src/server/services/backupService.test.ts b/src/server/services/backupService.test.ts index fc0f07f..7b5d926 100644 --- a/src/server/services/backupService.test.ts +++ b/src/server/services/backupService.test.ts @@ -28,6 +28,7 @@ describe('backupService', () => { beforeEach(async () => { await db.delete(schema.routeChannels).run(); + await db.delete(schema.routeGroupSources).run(); await db.delete(schema.tokenRoutes).run(); await db.delete(schema.tokenModelAvailability).run(); await db.delete(schema.modelAvailability).run(); @@ -68,6 +69,9 @@ describe('backupService', () => { username: 'roundtrip-user', accessToken: 'session-token', apiToken: 'api-token', + oauthProvider: 'codex', + oauthAccountKey: 'roundtrip-account-key', + oauthProjectId: 'roundtrip-project-id', balance: 12.3, balanceUsed: 4.5, quota: 99.9, @@ -93,16 +97,34 @@ describe('backupService', () => { updatedAt: now, }).returning().get(); + const sourceRoute = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-source-*', + displayName: 'gpt-source', + routeMode: 'pattern', + enabled: true, + createdAt: now, + updatedAt: now, + }).returning().get(); + const route = await db.insert(schema.tokenRoutes).values({ modelPattern: 'gpt-*', displayName: 'gpt-route', displayIcon: 'icon-gpt', modelMapping: JSON.stringify({ to: 'gpt-4o-mini' }), + routeMode: 'explicit_group', + decisionSnapshot: JSON.stringify({ channelIds: [1, 2] }), + decisionRefreshedAt: now, + routingStrategy: 'round_robin', enabled: true, createdAt: now, updatedAt: now, }).returning().get(); + await db.insert(schema.routeGroupSources).values({ + groupRouteId: route.id, + sourceRouteId: sourceRoute.id, + }).run(); + await db.insert(schema.routeChannels).values({ routeId: route.id, accountId: account.id, @@ -141,9 +163,18 @@ describe('backupService', () => { expect(restoredAccount?.isPinned).toBe(true); expect(restoredAccount?.sortOrder).toBe(7); + expect(restoredAccount?.oauthProvider).toBe('codex'); + expect(restoredAccount?.oauthAccountKey).toBe('roundtrip-account-key'); + expect(restoredAccount?.oauthProjectId).toBe('roundtrip-project-id'); expect(restoredRoute?.displayName).toBe('gpt-route'); expect(restoredRoute?.displayIcon).toBe('icon-gpt'); + expect(restoredRoute?.routeMode).toBe('explicit_group'); + expect(restoredRoute?.decisionSnapshot).toBe('{"channelIds":[1,2]}'); + expect(restoredRoute?.decisionRefreshedAt).toBe(now); + expect(restoredRoute?.routingStrategy).toBe('round_robin'); + const restoredGroupSource = await db.select().from(schema.routeGroupSources).where(eq(schema.routeGroupSources.groupRouteId, route.id)).get(); + expect(restoredGroupSource?.sourceRouteId).toBe(sourceRoute.id); expect(restoredChannel?.sourceModel).toBe('gpt-4o'); }); @@ -203,4 +234,80 @@ describe('backupService', () => { expect(accounts[0].username).toBe('legacy-user'); expect(settings.some((row) => row.key === 'legacy_preferences_ref_v2')).toBe(true); }); + + it('backfills oauth columns from extraConfig when importing older backups', async () => { + const payload = { + timestamp: Date.now(), + accounts: { + sites: [ + { + id: 1, + name: 'codex-site', + url: 'https://codex.example.com', + platform: 'chatgpt-account', + proxyUrl: null, + status: 'active', + isPinned: false, + sortOrder: 0, + apiKey: null, + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + externalCheckinUrl: null, + useSystemProxy: false, + globalWeight: 1, + customHeaders: null, + }, + ], + accounts: [ + { + id: 10, + siteId: 1, + username: 'oauth-user', + accessToken: 'oauth-access-token', + apiToken: null, + balance: 0, + balanceUsed: 0, + quota: 0, + unitCost: null, + valueScore: 0, + status: 'active', + isPinned: false, + sortOrder: 0, + checkinEnabled: true, + lastCheckinAt: null, + lastBalanceRefresh: null, + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'gemini-cli', + accountId: 'oauth-user@example.com', + accountKey: 'oauth-user@example.com', + projectId: 'oauth-project-id', + refreshToken: 'oauth-refresh-token', + }, + }), + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, + }, + ], + accountTokens: [], + tokenRoutes: [], + routeChannels: [], + }, + } as Record; + + const result = await backupService.importBackup(payload); + + expect(result.allImported).toBe(true); + expect(result.sections.accounts).toBe(true); + + const restoredAccount = await db.select().from(schema.accounts).where(eq(schema.accounts.id, 10)).get(); + + expect(restoredAccount?.oauthProvider).toBe('gemini-cli'); + expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com'); + expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id'); + }); }); diff --git a/src/server/services/backupService.ts b/src/server/services/backupService.ts index af7e538..9e36a21 100644 --- a/src/server/services/backupService.ts +++ b/src/server/services/backupService.ts @@ -2,6 +2,7 @@ import { asc } from 'drizzle-orm'; import cron from 'node-cron'; import { db, schema } from '../db/index.js'; import { upsertSetting } from '../db/upsertSetting.js'; +import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; const BACKUP_VERSION = '2.0'; @@ -12,6 +13,7 @@ type AccountRow = typeof schema.accounts.$inferSelect; type AccountTokenRow = typeof schema.accountTokens.$inferSelect; type TokenRouteRow = typeof schema.tokenRoutes.$inferSelect; type RouteChannelRow = typeof schema.routeChannels.$inferSelect; +type RouteGroupSourceRow = typeof schema.routeGroupSources.$inferSelect; interface AccountsBackupSection { sites: SiteRow[]; @@ -19,6 +21,7 @@ interface AccountsBackupSection { accountTokens: AccountTokenRow[]; tokenRoutes: TokenRouteRow[]; routeChannels: RouteChannelRow[]; + routeGroupSources: RouteGroupSourceRow[]; } interface PreferencesBackupSection { @@ -104,6 +107,18 @@ function normalizeLegacyQuota(raw: unknown): number { return value; } +function resolveImportedOauthColumns(row: Pick) { + const oauth = getOauthInfoFromExtraConfig(row.extraConfig); + const oauthProvider = row.oauthProvider || oauth?.provider || null; + const oauthAccountKey = row.oauthAccountKey || oauth?.accountKey || oauth?.accountId || null; + const oauthProjectId = row.oauthProjectId || oauth?.projectId || null; + return { + oauthProvider, + oauthAccountKey, + oauthProjectId, + }; +} + function normalizeLegacyPlatform(raw: string): string { const value = raw.trim().toLowerCase(); if (!value) return 'new-api'; @@ -215,6 +230,9 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS username, accessToken: accountAccessToken, apiToken, + oauthProvider: null, + oauthAccountKey: null, + oauthProjectId: null, balance: importedBalance, balanceUsed: importedUsed, quota: importedQuota > 0 ? importedQuota : importedBalance, @@ -238,6 +256,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS name: 'default', token: apiToken, tokenGroup: 'default', + valueStatus: 'ready', source: 'legacy', enabled: true, isDefault: true, @@ -253,6 +272,7 @@ function buildAccountsSectionFromRefBackup(data: RawBackupData): AccountsBackupS accountTokens, tokenRoutes, routeChannels, + routeGroupSources: [], }; } @@ -331,6 +351,7 @@ async function exportAccountsSection(): Promise { const accountTokens = await db.select().from(schema.accountTokens).orderBy(asc(schema.accountTokens.id)).all(); const tokenRoutes = await db.select().from(schema.tokenRoutes).orderBy(asc(schema.tokenRoutes.id)).all(); const routeChannels = await db.select().from(schema.routeChannels).orderBy(asc(schema.routeChannels.id)).all(); + const routeGroupSources = await db.select().from(schema.routeGroupSources).orderBy(asc(schema.routeGroupSources.id)).all(); return { sites, @@ -338,6 +359,7 @@ async function exportAccountsSection(): Promise { accountTokens, tokenRoutes, routeChannels, + routeGroupSources, }; } @@ -388,6 +410,9 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null { const accountTokens = Array.isArray(input.accountTokens) ? input.accountTokens as AccountTokenRow[] : null; const tokenRoutes = Array.isArray(input.tokenRoutes) ? input.tokenRoutes as TokenRouteRow[] : null; const routeChannels = Array.isArray(input.routeChannels) ? input.routeChannels as RouteChannelRow[] : null; + const routeGroupSources = Array.isArray(input.routeGroupSources) + ? input.routeGroupSources as RouteGroupSourceRow[] + : []; if (!sites || !accounts || !accountTokens || !tokenRoutes || !routeChannels) return null; @@ -397,6 +422,7 @@ function coerceAccountsSection(input: unknown): AccountsBackupSection | null { accountTokens, tokenRoutes, routeChannels, + routeGroupSources, }; } @@ -460,6 +486,7 @@ function detectPreferencesSection(data: RawBackupData): PreferencesBackupSection async function importAccountsSection(section: AccountsBackupSection): Promise { await db.transaction(async (tx) => { await tx.delete(schema.routeChannels).run(); + await tx.delete(schema.routeGroupSources).run(); await tx.delete(schema.tokenRoutes).run(); await tx.delete(schema.tokenModelAvailability).run(); await tx.delete(schema.modelAvailability).run(); @@ -490,12 +517,16 @@ async function importAccountsSection(section: AccountsBackupSection): Promise { checkinLogs: [], modelAvailability: [], tokenModelAvailability: [], - tokenRoutes: [], + tokenRoutes: [{ + id: 10, + modelPattern: 'claude-opus-4-6', + displayName: 'claude-opus-4-6', + displayIcon: 'icon-claude', + modelMapping: null, + routeMode: 'explicit_group', + decisionSnapshot: '{"channels":[1]}', + decisionRefreshedAt: '2026-03-14T01:30:00.000Z', + routingStrategy: 'round_robin', + enabled: true, + createdAt: '2026-03-14T00:00:00.000Z', + updatedAt: '2026-03-14T01:00:00.000Z', + }], routeChannels: [], proxyLogs: [], proxyVideoTasks: [{ @@ -260,6 +273,11 @@ describe('databaseMigrationService', () => { updatedAt: '2026-03-14T01:00:00.000Z', deletedAt: null, }], + routeGroupSources: [{ + id: 9, + groupRouteId: 12, + sourceRouteId: 13, + }], downstreamApiKeys: [], events: [], }, @@ -271,5 +289,10 @@ describe('databaseMigrationService', () => { expect(statements.some((statement) => statement.table === 'site_disabled_models')).toBe(true); expect(statements.some((statement) => statement.table === 'proxy_video_tasks')).toBe(true); expect(statements.some((statement) => statement.table === 'proxy_files')).toBe(true); + expect(statements.some((statement) => statement.table === 'route_group_sources')).toBe(true); + const tokenRouteStatement = statements.find((statement) => statement.table === 'token_routes'); + const routeModeIndex = tokenRouteStatement?.columns.indexOf('route_mode') ?? -1; + expect(routeModeIndex).toBeGreaterThanOrEqual(0); + expect(tokenRouteStatement?.values[routeModeIndex]).toBe('explicit_group'); }); }); diff --git a/src/server/services/databaseMigrationService.ts b/src/server/services/databaseMigrationService.ts index 154ed83..0559cc5 100644 --- a/src/server/services/databaseMigrationService.ts +++ b/src/server/services/databaseMigrationService.ts @@ -36,6 +36,7 @@ type BackupSnapshot = { tokenModelAvailability: Array>; tokenRoutes: Array>; routeChannels: Array>; + routeGroupSources: Array>; proxyLogs: Array>; proxyVideoTasks: Array>; proxyFiles: Array>; @@ -60,6 +61,7 @@ export interface DatabaseMigrationSummary { accountTokens: number; tokenRoutes: number; routeChannels: number; + routeGroupSources: number; checkinLogs: number; modelAvailability: number; tokenModelAvailability: number; @@ -218,6 +220,7 @@ async function toBackupSnapshot(): Promise { tokenModelAvailability: await db.select().from(schema.tokenModelAvailability).all() as Array>, tokenRoutes: await db.select().from(schema.tokenRoutes).all() as Array>, routeChannels: await db.select().from(schema.routeChannels).all() as Array>, + routeGroupSources: await db.select().from(schema.routeGroupSources).all() as Array>, proxyLogs: await db.select().from(schema.proxyLogs).all() as Array>, proxyVideoTasks: await db.select().from(schema.proxyVideoTasks).all() as Array>, proxyFiles: await db.select().from(schema.proxyFiles).all() as Array>, @@ -245,6 +248,7 @@ async function ensureTargetState(client: SqlClient, overwrite: boolean): Promise async function clearTargetData(client: SqlClient): Promise { const tables = [ 'route_channels', + 'route_group_sources', 'token_model_availability', 'model_availability', 'checkin_logs', @@ -336,13 +340,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.accountTokens) { statements.push({ table: 'account_tokens', - columns: ['id', 'account_id', 'name', 'token', 'token_group', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'], + columns: ['id', 'account_id', 'name', 'token', 'token_group', 'value_status', 'source', 'enabled', 'is_default', 'created_at', 'updated_at'], values: [ asNumber(row.id, 0), asNumber(row.accountId, 0), asNullableString(row.name), asNullableString(row.token), asNullableString(row.tokenGroup), + asNullableString((row as { valueStatus?: unknown }).valueStatus) ?? 'ready', asNullableString(row.source) ?? 'manual', asBoolean(row.enabled, true), asBoolean(row.isDefault, false), @@ -400,13 +405,14 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { for (const row of snapshot.accounts.tokenRoutes) { statements.push({ table: 'token_routes', - columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'], + columns: ['id', 'model_pattern', 'display_name', 'display_icon', 'model_mapping', 'route_mode', 'decision_snapshot', 'decision_refreshed_at', 'routing_strategy', 'enabled', 'created_at', 'updated_at'], values: [ asNumber(row.id, 0), asNullableString(row.modelPattern), asNullableString(row.displayName), asNullableString(row.displayIcon), asNullableString(row.modelMapping), + asNullableString(row.routeMode) ?? 'pattern', asNullableString(row.decisionSnapshot), asNullableString(row.decisionRefreshedAt), asNullableString(row.routingStrategy), @@ -445,15 +451,28 @@ function buildStatements(snapshot: BackupSnapshot): InsertStatement[] { }); } + for (const row of (snapshot.accounts.routeGroupSources || [])) { + statements.push({ + table: 'route_group_sources', + columns: ['id', 'group_route_id', 'source_route_id'], + values: [ + asNumber(row.id, 0), + asNumber(row.groupRouteId, 0), + asNumber(row.sourceRouteId, 0), + ], + }); + } + for (const row of snapshot.accounts.proxyLogs) { statements.push({ table: 'proxy_logs', - columns: ['id', 'route_id', 'channel_id', 'account_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'], + columns: ['id', 'route_id', 'channel_id', 'account_id', 'downstream_api_key_id', 'model_requested', 'model_actual', 'status', 'http_status', 'latency_ms', 'prompt_tokens', 'completion_tokens', 'total_tokens', 'estimated_cost', 'billing_details', 'error_message', 'retry_count', 'created_at'], values: [ asNumber(row.id, 0), asNumber(row.routeId, null), asNumber(row.channelId, null), asNumber(row.accountId, null), + asNumber((row as any).downstreamApiKeyId ?? (row as any).downstream_api_key_id, null), asNullableString(row.modelRequested), asNullableString(row.modelActual), asNullableString(row.status), @@ -606,6 +625,7 @@ async function syncPostgresSequences(client: SqlClient): Promise { 'token_model_availability', 'token_routes', 'route_channels', + 'route_group_sources', 'proxy_logs', 'proxy_video_tasks', 'proxy_files', @@ -613,9 +633,9 @@ async function syncPostgresSequences(client: SqlClient): Promise { 'events', ]; for (const table of tables) { - await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`); + await client.execute(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), COALESCE((SELECT MAX(id) FROM "${table}"), 1), TRUE)`); + } } -} export async function bootstrapRuntimeDatabaseSchema(input: Pick): Promise { const client = await createClient({ @@ -678,6 +698,7 @@ export async function migrateCurrentDatabase(input: DatabaseMigrationInput): Pro downstreamApiKeys: snapshot.accounts.downstreamApiKeys.length, events: snapshot.accounts.events.length, settings: snapshot.preferences.settings.length, + routeGroupSources: snapshot.accounts.routeGroupSources.length, }, }; } diff --git a/src/server/services/downstreamApiKeyService.ts b/src/server/services/downstreamApiKeyService.ts index ebba7d4..7cfc24f 100644 --- a/src/server/services/downstreamApiKeyService.ts +++ b/src/server/services/downstreamApiKeyService.ts @@ -12,6 +12,8 @@ export type DownstreamApiKeyPolicyView = { key: string; keyMasked: string; description: string | null; + groupName: string | null; + tags: string[]; enabled: boolean; expiresAt: string | null; maxCost: number | null; @@ -97,6 +99,34 @@ function parseJson(value: string | null | undefined): unknown { } } +export function normalizeGroupNameInput(input: unknown): string | null { + if (typeof input !== 'string') return null; + const value = input.trim(); + if (!value) return null; + return value.slice(0, 64); +} + +export function normalizeTagsInput(input: unknown): string[] { + const rawValues = Array.isArray(input) + ? input + : (typeof input === 'string' ? input.split(/[\r\n,,]+/g) : []); + + const tags: string[] = []; + const seen = new Set(); + for (const raw of rawValues) { + const value = String(raw || '').trim(); + if (!value) continue; + const normalized = value.slice(0, 32); + const dedupeKey = normalized.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + tags.push(normalized); + if (tags.length >= 20) break; + } + + return tags; +} + export function normalizeSupportedModelsInput(input: unknown): string[] { if (Array.isArray(input)) { return input @@ -226,6 +256,8 @@ export function toDownstreamApiKeyPolicyView(row: DownstreamApiKeyRow): Downstre key: row.key, keyMasked: maskSecret(row.key), description: row.description || null, + groupName: normalizeGroupNameInput(row.groupName), + tags: normalizeTagsInput(parseJson(row.tags)), enabled: !!row.enabled, expiresAt: row.expiresAt || null, maxCost: row.maxCost ?? null, @@ -385,6 +417,8 @@ export function normalizeDownstreamApiKeyPayload(input: { name?: unknown; key?: unknown; description?: unknown; + groupName?: unknown; + tags?: unknown; enabled?: unknown; expiresAt?: unknown; maxCost?: unknown; @@ -398,6 +432,8 @@ export function normalizeDownstreamApiKeyPayload(input: { const description = typeof input.description === 'string' ? input.description.trim() : ''; + const groupName = normalizeGroupNameInput(input.groupName); + const tags = normalizeTagsInput(input.tags); const enabled = input.enabled === undefined ? true : !!input.enabled; const expiresAtRaw = typeof input.expiresAt === 'string' @@ -422,6 +458,8 @@ export function normalizeDownstreamApiKeyPayload(input: { name, key, description: description || null, + groupName, + tags, enabled, expiresAt, maxCost, diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 1c41ae9..715c2ca 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm'; const getApiTokenMock = vi.fn(); const getModelsMock = vi.fn(); +const undiciFetchMock = vi.fn(); vi.mock('./platforms/index.js', () => ({ getAdapter: () => ({ @@ -14,6 +15,10 @@ vi.mock('./platforms/index.js', () => ({ }), })); +vi.mock('undici', () => ({ + fetch: (...args: unknown[]) => undiciFetchMock(...args), +})); + type DbModule = typeof import('../db/index.js'); type ModelServiceModule = typeof import('./modelService.js'); @@ -39,6 +44,7 @@ describe('refreshModelsForAccount credential discovery', () => { beforeEach(async () => { getApiTokenMock.mockReset(); getModelsMock.mockReset(); + undiciFetchMock.mockReset(); await db.delete(schema.routeChannels).run(); await db.delete(schema.tokenRoutes).run(); @@ -100,6 +106,42 @@ describe('refreshModelsForAccount credential discovery', () => { expect(tokenRows).toHaveLength(0); }); + it('deduplicates discovered model names before writing availability rows', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockResolvedValue(['? ', '?', 'GPT-4.1', 'gpt-4.1']); + + const site = await db.insert(schema.sites).values({ + name: 'site-dedupe', + url: 'https://site-dedupe.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'dedupe-user', + accessToken: 'session-token', + apiToken: null, + status: 'active', + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + modelCount: 2, + modelsPreview: ['?', 'GPT-4.1'], + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + + expect(rows.map((row) => row.modelName).sort()).toEqual(['?', 'GPT-4.1']); + }); + it('marks runtime health unhealthy when model discovery fails', async () => { getApiTokenMock.mockResolvedValue(null); getModelsMock.mockRejectedValue(new Error('HTTP 401: invalid token')); @@ -216,4 +258,314 @@ describe('refreshModelsForAccount credential discovery', () => { reason: 'adapter_or_status', }); }); + + it('does not scan masked_pending placeholders as token credentials', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( + token === 'sk-mask***tail' ? ['gpt-5.2-codex'] : [] + )); + + const site = await db.insert(schema.sites).values({ + name: 'site-placeholder', + url: 'https://site-placeholder.example.com', + platform: 'new-api', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'placeholder-user', + accessToken: '', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ credentialMode: 'session' }), + }).returning().get(); + + const placeholder = await db.insert(schema.accountTokens).values({ + accountId: account.id, + name: 'masked-token', + token: 'sk-mask***tail', + source: 'sync', + enabled: true, + isDefault: false, + valueStatus: 'masked_pending' as any, + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'failed', + tokenScanned: 0, + }); + + const placeholderModels = await db.select().from(schema.tokenModelAvailability) + .where(eq(schema.tokenModelAvailability.tokenId, placeholder.id)) + .all(); + expect(placeholderModels).toEqual([]); + expect(getModelsMock).not.toHaveBeenCalledWith(site.url, 'sk-mask***tail', account.username); + }); + + it('discovers codex models from upstream cloud endpoint without adapter model fetch', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels')); + undiciFetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + models: [ + { id: 'gpt-5.4' }, + { id: 'gpt-5.3-codex' }, + { id: 'gpt-5.2-codex' }, + { id: 'gpt-5.2' }, + { id: 'gpt-5.1-codex-max' }, + { id: 'gpt-5.1-codex' }, + { id: 'gpt-5.1' }, + { id: 'gpt-5-codex' }, + { id: 'gpt-5' }, + { id: 'gpt-5.1-codex-mini' }, + { id: 'gpt-5-codex-mini' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + tokenScanned: 0, + discoveredByCredential: true, + modelCount: 11, + }); + expect(result.modelsPreview).toEqual([ + 'gpt-5.4', + 'gpt-5.3-codex', + 'gpt-5.2-codex', + 'gpt-5.2', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex', + 'gpt-5.1', + 'gpt-5-codex', + 'gpt-5', + 'gpt-5.1-codex-mini', + ]); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledTimes(1); + expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://chatgpt.com/backend-api/codex/models?client_version=1.0.0'); + expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer oauth-access-token', + 'Chatgpt-Account-Id': 'chatgpt-account-123', + Originator: 'codex_cli_rs', + }), + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + const modelNames = rows.map((row) => row.modelName); + expect(modelNames.sort()).toEqual([ + 'gpt-5', + 'gpt-5-codex', + 'gpt-5-codex-mini', + 'gpt-5.1', + 'gpt-5.1-codex', + 'gpt-5.1-codex-max', + 'gpt-5.1-codex-mini', + 'gpt-5.2', + 'gpt-5.2-codex', + 'gpt-5.3-codex', + 'gpt-5.4', + ]); + }); + + it('marks codex oauth account abnormal when upstream cloud discovery fails', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('codex plan discovery should not call adapter.getModels')); + undiciFetchMock.mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: 'forbidden' }), + text: async () => 'forbidden', + }); + + const site = await db.insert(schema.sites).values({ + name: 'codex-team-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'team-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-team', + email: 'team-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + checkedAt: '2026-03-16T12:00:00.000Z', + }).run(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'failed', + errorCode: 'unauthorized', + tokenScanned: 0, + discoveredByCredential: false, + modelCount: 0, + }); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledTimes(1); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(rows).toEqual([]); + + const latest = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, account.id)) + .get(); + const parsed = JSON.parse(latest!.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + provider: 'codex', + modelDiscoveryStatus: 'abnormal', + }); + expect(parsed.oauth.lastModelSyncError).toContain('HTTP 403'); + expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/); + expect(parsed.runtimeHealth?.state).toBe('unhealthy'); + }); + + it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels')); + undiciFetchMock + .mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({ error: 'unavailable' }), + text: async () => 'unavailable', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: { + 'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' }, + 'claude-sonnet-4-5-20250929': { displayName: 'Claude Sonnet 4.5' }, + }, + }), + text: async () => JSON.stringify({ ok: true }), + }); + + const site = await db.insert(schema.sites).values({ + name: 'antigravity-site', + url: 'https://cloudcode-pa.googleapis.com', + platform: 'antigravity', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'antigravity-user@example.com', + accessToken: 'antigravity-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'antigravity', + email: 'antigravity-user@example.com', + projectId: 'project-demo', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + modelCount: 2, + modelsPreview: ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'], + }); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledTimes(2); + expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'); + expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'); + expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer antigravity-access-token', + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'antigravity/1.19.6 darwin/arm64', + }), + }); + const discoveryHeaders = undiciFetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(discoveryHeaders).not.toHaveProperty('X-Goog-Api-Client'); + expect(discoveryHeaders).not.toHaveProperty('Client-Metadata'); + expect(JSON.parse(String(undiciFetchMock.mock.calls[0]?.[1]?.body || '{}'))).toEqual({ + project: 'project-demo', + }); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(rows.map((row) => row.modelName).sort()).toEqual([ + 'claude-sonnet-4-5-20250929', + 'gemini-3-pro-preview', + ]); + }); }); diff --git a/src/server/services/modelService.test.ts b/src/server/services/modelService.test.ts index 61d00c0..00b6e7d 100644 --- a/src/server/services/modelService.test.ts +++ b/src/server/services/modelService.test.ts @@ -85,6 +85,59 @@ describe('rebuildTokenRoutesFromAvailability', () => { expect(channels[0]?.manualOverride).toBe(false); }); + it('creates an exact route with an account-direct channel for oauth model availability', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'team', + }, + }), + }).returning().get(); + + await db.insert(schema.modelAvailability).values({ + accountId: account.id, + modelName: 'gpt-5.2-codex', + available: true, + latencyMs: 320, + checkedAt: '2026-03-17T00:00:00.000Z', + }).run(); + + const rebuild = await rebuildTokenRoutesFromAvailability(); + + expect(rebuild.models).toBe(1); + + const route = await db.select().from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.modelPattern, 'gpt-5.2-codex')) + .get(); + expect(route).toBeDefined(); + + const channels = await db.select().from(schema.routeChannels) + .where(and( + eq(schema.routeChannels.routeId, route!.id), + eq(schema.routeChannels.accountId, account.id), + )) + .all(); + + expect(channels).toHaveLength(1); + expect(channels[0]?.tokenId ?? null).toBeNull(); + expect(channels[0]?.manualOverride).toBe(false); + }); + it('removes stale exact routes and keeps wildcard routes on rebuild', async () => { const site = await db.insert(schema.sites).values({ name: 'site-1', diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index df70983..fe5a3bc 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -1,18 +1,122 @@ import { and, eq } from 'drizzle-orm'; +import { fetch } from 'undici'; import { db, schema } from '../db/index.js'; import { getAdapter } from './platforms/index.js'; -import { ensureDefaultTokenForAccount, getPreferredAccountToken } from './accountTokenService.js'; -import { getCredentialModeFromExtraConfig, getProxyUrlFromExtraConfig, resolvePlatformUserId } from './accountExtraConfig.js'; +import { + ACCOUNT_TOKEN_VALUE_STATUS_READY, + ensureDefaultTokenForAccount, + getPreferredAccountToken, + isMaskedTokenValue, + isUsableAccountToken, +} from './accountTokenService.js'; +import { + getCredentialModeFromExtraConfig, + getProxyUrlFromExtraConfig, + mergeAccountExtraConfig, + resolvePlatformUserId, + supportsDirectAccountRoutingConnection, +} from './accountExtraConfig.js'; import { invalidateTokenRouterCache } from './tokenRouter.js'; import { setAccountRuntimeHealth } from './accountHealthService.js'; import { clearAllRouteDecisionSnapshots } from './routeDecisionSnapshotStore.js'; -import { withAccountProxyOverride } from './siteProxy.js'; +import { withAccountProxyOverride, withSiteRecordProxyRequestInit } from './siteProxy.js'; +import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js'; +import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; +import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js'; +import { + ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_MODELS_USER_AGENT, + ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_UPSTREAM_BASE_URL, +} from './oauth/antigravityProvider.js'; +import { + GEMINI_CLI_GOOGLE_API_CLIENT, + GEMINI_CLI_REQUIRED_SERVICE, + GEMINI_CLI_USER_AGENT, +} from './oauth/geminiCliProvider.js'; const API_TOKEN_DISCOVERY_TIMEOUT_MS = 8_000; const MODEL_DISCOVERY_TIMEOUT_MS = 12_000; const MODEL_REFRESH_BATCH_SIZE = 3; +const CODEX_MODELS_CLIENT_VERSION = '1.0.0'; +const CLAUDE_STATIC_MODELS = [ + 'claude-haiku-4-5-20251001', + 'claude-sonnet-4-5-20250929', + 'claude-sonnet-4-6', + 'claude-opus-4-6', + 'claude-opus-4-5-20251101', + 'claude-opus-4-1-20250805', + 'claude-opus-4-20250514', + 'claude-sonnet-4-20250514', + 'claude-3-7-sonnet-20250219', + 'claude-3-5-haiku-20241022', +]; +const GEMINI_CLI_STATIC_MODELS = [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.5-flash-lite', + 'gemini-3-pro-preview', + 'gemini-3.1-pro-preview', + 'gemini-3-flash-preview', + 'gemini-3.1-flash-lite-preview', +]; type ModelRefreshErrorCode = 'timeout' | 'unauthorized' | 'empty_models' | 'unknown'; +type ModelRefreshSkipCode = 'site_disabled' | 'adapter_or_status'; + +export type ModelRefreshAccountNotFoundResult = { + accountId: number; + refreshed: false; + status: 'failed'; + errorCode: 'account_not_found'; + errorMessage: '账号不存在'; + modelCount: 0; + modelsPreview: string[]; + reason: 'account_not_found'; +}; + +export type ModelRefreshSkippedResult = { + accountId: number; + refreshed: false; + status: 'skipped'; + errorCode: ModelRefreshSkipCode; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + reason: ModelRefreshSkipCode; +}; + +export type ModelRefreshFailureResult = { + accountId: number; + refreshed: true; + status: 'failed'; + errorCode: ModelRefreshErrorCode; + errorMessage: string; + modelCount: 0; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}; + +export type ModelRefreshSuccessResult = { + accountId: number; + refreshed: true; + status: 'success'; + errorCode: null; + errorMessage: ''; + modelCount: number; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}; + +export type ModelRefreshResult = + | ModelRefreshAccountNotFoundResult + | ModelRefreshSkippedResult + | ModelRefreshFailureResult + | ModelRefreshSuccessResult; function classifyModelDiscoveryError(message: string): ModelRefreshErrorCode { const lowered = message.toLowerCase(); @@ -41,14 +145,253 @@ function isApiKeyConnection(account: typeof schema.accounts.$inferSelect): boole } function normalizeModels(models: string[]): string[] { - return Array.from(new Set(models.filter((model) => typeof model === 'string' && model.trim().length > 0))); + const normalizedModels: string[] = []; + const seen = new Set(); + + for (const rawModel of models) { + if (typeof rawModel !== 'string') continue; + const modelName = rawModel.trim(); + if (!modelName) continue; + + // Keep app/database behavior stable across SQLite/MySQL by deduping with a + // case-insensitive key after trimming whitespace. + const dedupeKey = modelName.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + normalizedModels.push(modelName); + } + + return normalizedModels; +} + +function buildCodexModelsEndpoint(baseUrl: string): string { + const normalized = (baseUrl || '').replace(/\/+$/, ''); + return `${normalized}/models?client_version=${encodeURIComponent(CODEX_MODELS_CLIENT_VERSION)}`; +} + +function extractCodexModelIds(payload: unknown): string[] { + const collection = (() => { + if (Array.isArray(payload)) return payload; + if (!payload || typeof payload !== 'object') return []; + const record = payload as Record; + if (Array.isArray(record.models)) return record.models; + if (Array.isArray(record.data)) return record.data; + if (Array.isArray(record.items)) return record.items; + return []; + })(); + + return collection.flatMap((item) => { + if (typeof item === 'string') return [item]; + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const record = item as Record; + const id = typeof record.id === 'string' + ? record.id + : (typeof record.slug === 'string' ? record.slug : (typeof record.model === 'string' ? record.model : '')); + return id ? [id] : []; + }); +} + +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function extractAntigravityModelIds(payload: unknown): string[] { + if (!payload || typeof payload !== 'object') return []; + const record = payload as { models?: unknown }; + if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) { + return Object.keys(record.models).map((name) => name.trim()).filter(Boolean); + } + if (!Array.isArray(record.models)) return []; + return record.models.flatMap((item) => { + if (typeof item === 'string') { + const trimmed = item.trim(); + return trimmed ? [trimmed] : []; + } + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const value = item as { id?: unknown; name?: unknown }; + const id = typeof value.id === 'string' + ? value.id.trim() + : (typeof value.name === 'string' ? value.name.trim() : ''); + return id ? [id] : []; + }); +} + +function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] { + const seen = new Set(); + return [ + siteUrl, + ANTIGRAVITY_UPSTREAM_BASE_URL, + ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL, + ].flatMap((candidate) => { + const normalized = normalizeBaseUrl(candidate); + if (!normalized || seen.has(normalized)) return []; + seen.add(normalized); + return [normalized]; + }); +} + +async function updateOauthModelDiscoveryState(input: { + account: typeof schema.accounts.$inferSelect; + checkedAt: string; + status: 'healthy' | 'abnormal'; + lastModelSyncError?: string; + lastDiscoveredModels?: string[]; +}) { + const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig); + if (!oauth) return input.account.extraConfig || null; + const extraConfig = mergeAccountExtraConfig(input.account.extraConfig, { + oauth: buildOauthInfo(input.account.extraConfig, { + provider: oauth.provider, + modelDiscoveryStatus: input.status, + lastModelSyncAt: input.checkedAt, + lastModelSyncError: input.lastModelSyncError, + lastDiscoveredModels: input.lastDiscoveredModels ?? [], + }), + }); + await db.update(schema.accounts).set({ + extraConfig, + updatedAt: input.checkedAt, + }).where(eq(schema.accounts.id, input.account.id)).run(); + return extraConfig; +} + +async function discoverCodexModelsFromCloud(input: { + site: typeof schema.sites.$inferSelect; + account: typeof schema.accounts.$inferSelect; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('codex oauth access token missing'); + } + const oauth = getCodexOauthInfoFromExtraConfig(input.account.extraConfig); + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + Originator: 'codex_cli_rs', + }; + if (oauth?.accountId) { + headers['Chatgpt-Account-Id'] = oauth.accountId; + } + + const response = await fetch( + buildCodexModelsEndpoint(input.site.url), + withSiteRecordProxyRequestInit(input.site, { method: 'GET', headers }), + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || 'codex model discovery failed'}`); + } + return normalizeModels(extractCodexModelIds(await response.json())); +} + +async function validateClaudeOauthConnection(input: { + site: typeof schema.sites.$inferSelect; + account: typeof schema.accounts.$inferSelect; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('claude oauth access token missing'); + } + const response = await fetch( + `${input.site.url.replace(/\/+$/, '')}/v1/models`, + withSiteRecordProxyRequestInit(input.site, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, + }, + }), + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || 'claude oauth validation failed'}`); + } +} + +async function validateGeminiCliOauthConnection(input: { + account: typeof schema.accounts.$inferSelect; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('gemini cli oauth access token missing'); + } + const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig); + const projectId = (oauth?.projectId || '').trim(); + if (!projectId) { + throw new Error('gemini cli oauth project id missing'); + } + const response = await fetch( + `https://serviceusage.googleapis.com/v1/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + }, + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(`HTTP ${response.status}: ${text || 'gemini cli oauth validation failed'}`); + } + const payload = await response.json() as { state?: unknown }; + if (String(payload.state || '').trim().toUpperCase() !== 'ENABLED') { + throw new Error(`Cloud AI API not enabled for project ${projectId}`); + } +} + +async function discoverAntigravityModelsFromCloud(input: { + site: typeof schema.sites.$inferSelect; + account: typeof schema.accounts.$inferSelect; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('antigravity oauth access token missing'); + } + + const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig); + const requestBody = oauth?.projectId ? { project: oauth.projectId } : {}; + let lastError = ''; + + for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) { + const response = await fetch( + `${baseUrl}/v1internal:fetchAvailableModels`, + withSiteRecordProxyRequestInit(input.site, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': ANTIGRAVITY_MODELS_USER_AGENT, + }, + body: JSON.stringify(requestBody), + }), + ); + if (!response.ok) { + lastError = await response.text().catch(() => '') || `HTTP ${response.status}`; + continue; + } + + const payload = await response.json(); + const models = normalizeModels(extractAntigravityModelIds(payload)); + if (models.length > 0) { + return models; + } + lastError = '未获取到可用模型'; + } + + throw new Error(lastError || '未获取到可用模型'); } function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); if (!normalized) return false; if (normalized.toLowerCase().startsWith('re:')) return false; - return !/[\*\?\[]/.test(normalized); + return !/[\*\?]/.test(normalized); } async function withTimeout(fn: () => Promise, timeoutMs: number, timeoutMessage: string): Promise { @@ -65,27 +408,93 @@ async function withTimeout(fn: () => Promise, timeoutMs: number, timeoutMe } } -export async function refreshModelsForAccount(accountId: number) { +function buildAccountNotFoundRefreshResult(accountId: number): ModelRefreshAccountNotFoundResult { + return { + accountId, + refreshed: false, + status: 'failed', + errorCode: 'account_not_found', + errorMessage: '账号不存在', + modelCount: 0, + modelsPreview: [], + reason: 'account_not_found', + }; +} + +function buildSkippedRefreshResult( + accountId: number, + code: ModelRefreshSkipCode, + errorMessage: string, +): ModelRefreshSkippedResult { + return { + accountId, + refreshed: false, + status: 'skipped', + errorCode: code, + errorMessage, + modelCount: 0, + modelsPreview: [], + reason: code, + }; +} + +function buildFailedRefreshResult(input: { + accountId: number; + errorCode: ModelRefreshErrorCode; + errorMessage: string; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}): ModelRefreshFailureResult { + return { + accountId: input.accountId, + refreshed: true, + status: 'failed', + errorCode: input.errorCode, + errorMessage: input.errorMessage, + modelCount: 0, + modelsPreview: [], + tokenScanned: input.tokenScanned, + discoveredByCredential: input.discoveredByCredential, + discoveredApiToken: input.discoveredApiToken, + }; +} + +function buildSuccessfulRefreshResult(input: { + accountId: number; + modelCount: number; + modelsPreview: string[]; + tokenScanned: number; + discoveredByCredential: boolean; + discoveredApiToken: boolean; +}): ModelRefreshSuccessResult { + return { + accountId: input.accountId, + refreshed: true, + status: 'success', + errorCode: null, + errorMessage: '', + modelCount: input.modelCount, + modelsPreview: input.modelsPreview, + tokenScanned: input.tokenScanned, + discoveredByCredential: input.discoveredByCredential, + discoveredApiToken: input.discoveredApiToken, + }; +} + +export async function refreshModelsForAccount(accountId: number): Promise { const row = await db.select().from(schema.accounts) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .where(eq(schema.accounts.id, accountId)) .get(); if (!row) { - return { - accountId, - refreshed: false, - status: 'failed', - errorCode: 'account_not_found', - errorMessage: '账号不存在', - modelCount: 0, - modelsPreview: [], - reason: 'account_not_found', - }; + return buildAccountNotFoundRefreshResult(accountId); } const account = row.accounts; const site = row.sites; + const oauth = getOauthInfoFromExtraConfig(account.extraConfig); const adapter = getAdapter(site.platform); const accountTokens = await db.select() @@ -104,29 +513,287 @@ export async function refreshModelsForAccount(accountId: number) { } if (isSiteDisabled(site.status)) { - return { - accountId, - refreshed: false, - status: 'skipped', - errorCode: 'site_disabled', - errorMessage: '站点已禁用', - modelCount: 0, - modelsPreview: [], - reason: 'site_disabled', - }; + return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用'); } - if (!adapter || account.status !== 'active') { - return { - accountId, - refreshed: false, - status: 'skipped', - errorCode: 'adapter_or_status', - errorMessage: '平台不可用或账号未激活', - modelCount: 0, - modelsPreview: [], - reason: 'adapter_or_status', - }; + if (account.status !== 'active') { + return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活'); + } + + if (oauth?.provider === 'codex') { + const checkedAt = new Date().toISOString(); + const startedAt = Date.now(); + try { + const codexModels = await withTimeout( + () => discoverCodexModelsFromCloud({ site, account }), + MODEL_DISCOVERY_TIMEOUT_MS, + `codex model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + if (codexModels.length === 0) { + throw new Error('未获取到可用模型'); + } + + await db.insert(schema.modelAvailability).values( + codexModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'healthy', + lastDiscoveredModels: codexModels, + }); + await setAccountRuntimeHealth(accountId, { + state: 'healthy', + reason: 'Codex 云端模型探测成功', + source: 'model-discovery', + checkedAt, + }); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: codexModels.length, + modelsPreview: codexModels.slice(0, 10), + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + }); + } catch (err) { + const rawMessage = (err as { message?: string })?.message || 'codex model discovery failed'; + const errorCode = classifyModelDiscoveryError(rawMessage); + const errorMessage = `Codex 模型获取失败(${rawMessage})`; + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'abnormal', + lastModelSyncError: errorMessage, + lastDiscoveredModels: [], + }); + await setAccountRuntimeHealth(account.id, { + state: 'unhealthy', + reason: errorMessage, + source: 'model-discovery', + checkedAt, + }); + return buildFailedRefreshResult({ + accountId, + errorCode, + errorMessage, + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }); + } + } + + if (oauth?.provider === 'claude') { + const checkedAt = new Date().toISOString(); + const startedAt = Date.now(); + try { + await withTimeout( + () => validateClaudeOauthConnection({ site, account }), + MODEL_DISCOVERY_TIMEOUT_MS, + `claude oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + await db.insert(schema.modelAvailability).values( + CLAUDE_STATIC_MODELS.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'healthy', + lastDiscoveredModels: CLAUDE_STATIC_MODELS, + }); + await setAccountRuntimeHealth(accountId, { + state: 'healthy', + reason: 'Claude OAuth 健康探测成功', + source: 'model-discovery', + checkedAt, + }); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: CLAUDE_STATIC_MODELS.length, + modelsPreview: CLAUDE_STATIC_MODELS.slice(0, 10), + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + }); + } catch (err) { + const rawMessage = (err as { message?: string })?.message || 'claude oauth validation failed'; + const errorCode = classifyModelDiscoveryError(rawMessage); + const errorMessage = `Claude 模型获取失败(${rawMessage})`; + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'abnormal', + lastModelSyncError: errorMessage, + lastDiscoveredModels: [], + }); + await setAccountRuntimeHealth(account.id, { + state: 'unhealthy', + reason: errorMessage, + source: 'model-discovery', + checkedAt, + }); + return buildFailedRefreshResult({ + accountId, + errorCode, + errorMessage, + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }); + } + } + + if (oauth?.provider === 'gemini-cli') { + const checkedAt = new Date().toISOString(); + const startedAt = Date.now(); + try { + await withTimeout( + () => validateGeminiCliOauthConnection({ account }), + MODEL_DISCOVERY_TIMEOUT_MS, + `gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + await db.insert(schema.modelAvailability).values( + GEMINI_CLI_STATIC_MODELS.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'healthy', + lastDiscoveredModels: GEMINI_CLI_STATIC_MODELS, + }); + await setAccountRuntimeHealth(accountId, { + state: 'healthy', + reason: 'Gemini CLI OAuth 健康探测成功', + source: 'model-discovery', + checkedAt, + }); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: GEMINI_CLI_STATIC_MODELS.length, + modelsPreview: GEMINI_CLI_STATIC_MODELS.slice(0, 10), + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + }); + } catch (err) { + const rawMessage = (err as { message?: string })?.message || 'gemini cli oauth validation failed'; + const errorCode = classifyModelDiscoveryError(rawMessage); + const errorMessage = `Gemini CLI 模型获取失败(${rawMessage})`; + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'abnormal', + lastModelSyncError: errorMessage, + lastDiscoveredModels: [], + }); + await setAccountRuntimeHealth(account.id, { + state: 'unhealthy', + reason: errorMessage, + source: 'model-discovery', + checkedAt, + }); + return buildFailedRefreshResult({ + accountId, + errorCode, + errorMessage, + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }); + } + } + + if (oauth?.provider === 'antigravity') { + const checkedAt = new Date().toISOString(); + const startedAt = Date.now(); + try { + const antigravityModels = await withTimeout( + () => discoverAntigravityModelsFromCloud({ site, account }), + MODEL_DISCOVERY_TIMEOUT_MS, + `antigravity model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + if (antigravityModels.length === 0) { + throw new Error('未获取到可用模型'); + } + + await db.insert(schema.modelAvailability).values( + antigravityModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'healthy', + lastDiscoveredModels: antigravityModels, + }); + await setAccountRuntimeHealth(accountId, { + state: 'healthy', + reason: 'Antigravity OAuth 健康探测成功', + source: 'model-discovery', + checkedAt, + }); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: antigravityModels.length, + modelsPreview: antigravityModels.slice(0, 10), + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + }); + } catch (err) { + const rawMessage = (err as { message?: string })?.message || 'antigravity model discovery failed'; + const errorCode = classifyModelDiscoveryError(rawMessage); + const errorMessage = `Antigravity 模型获取失败(${rawMessage})`; + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'abnormal', + lastModelSyncError: errorMessage, + lastDiscoveredModels: [], + }); + await setAccountRuntimeHealth(account.id, { + state: 'unhealthy', + reason: errorMessage, + source: 'model-discovery', + checkedAt, + }); + return buildFailedRefreshResult({ + accountId, + errorCode, + errorMessage, + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }); + } + } + + if (!adapter) { + return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活'); } const platformUserId = resolvePlatformUserId(account.extraConfig, account.username); @@ -141,20 +808,27 @@ export async function refreshModelsForAccount(accountId: number) { API_TOKEN_DISCOVERY_TIMEOUT_MS, `api token discovery timeout (${Math.round(API_TOKEN_DISCOVERY_TIMEOUT_MS / 1000)}s)`, ); - if (discoveredApiToken) { + if (discoveredApiToken && !isMaskedTokenValue(discoveredApiToken)) { ensureDefaultTokenForAccount(account.id, discoveredApiToken, { name: 'default', source: 'sync' }); await db.update(schema.accounts).set({ apiToken: discoveredApiToken, updatedAt: new Date().toISOString(), }).where(eq(schema.accounts.id, account.id)).run(); + } else { + discoveredApiToken = null; } } catch { } } let enabledTokens = await db.select() .from(schema.accountTokens) - .where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true))) + .where(and( + eq(schema.accountTokens.accountId, account.id), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + )) .all(); + enabledTokens = enabledTokens.filter(isUsableAccountToken); // Last fallback: if still no managed token but account has a legacy apiToken, mirror it into token table. if (!isApiKeyConnection(account) && enabledTokens.length === 0) { @@ -163,8 +837,13 @@ export async function refreshModelsForAccount(accountId: number) { ensureDefaultTokenForAccount(account.id, fallback, { name: 'default', source: 'legacy' }); enabledTokens = await db.select() .from(schema.accountTokens) - .where(and(eq(schema.accountTokens.accountId, account.id), eq(schema.accountTokens.enabled, true))) + .where(and( + eq(schema.accountTokens.accountId, account.id), + eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), + )) .all(); + enabledTokens = enabledTokens.filter(isUsableAccountToken); } } @@ -195,6 +874,7 @@ export async function refreshModelsForAccount(accountId: number) { const discoverModelsWithCredential = async (credentialRaw: string | null | undefined) => { const credential = (credentialRaw || '').trim(); if (!credential) return; + if (isMaskedTokenValue(credential)) return; if (attemptedCredentials.has(credential)) return; attemptedCredentials.add(credential); @@ -271,18 +951,14 @@ export async function refreshModelsForAccount(accountId: number) { source: 'model-discovery', checkedAt: new Date().toISOString(), }); - return { + return buildFailedRefreshResult({ accountId, - refreshed: true, - status: 'failed', errorCode, errorMessage, - modelCount: 0, - modelsPreview: [], tokenScanned: scannedTokenCount, discoveredByCredential, discoveredApiToken: !!discoveredApiToken, - }; + }); } const checkedAt = new Date().toISOString(); @@ -304,26 +980,22 @@ export async function refreshModelsForAccount(accountId: number) { }); const modelsPreview = Array.from(accountModels).slice(0, 10); - return { + return buildSuccessfulRefreshResult({ accountId, - refreshed: true, - status: 'success', - errorCode: null, - errorMessage: '', modelCount: accountModels.size, modelsPreview, tokenScanned: scannedTokenCount, discoveredByCredential, discoveredApiToken: !!discoveredApiToken, - }; + }); } -async function refreshModelsForAllActiveAccounts() { +async function refreshModelsForAllActiveAccounts(): Promise { const accounts = await db.select({ id: schema.accounts.id }).from(schema.accounts) .where(eq(schema.accounts.status, 'active')) .all(); - const results: any[] = []; + const results: ModelRefreshResult[] = []; for (let offset = 0; offset < accounts.length; offset += MODEL_REFRESH_BATCH_SIZE) { const batch = accounts.slice(offset, offset + MODEL_REFRESH_BATCH_SIZE); const batchResults = await Promise.all(batch.map(async (account) => refreshModelsForAccount(account.id))); @@ -341,11 +1013,13 @@ export async function rebuildTokenRoutesFromAvailability() { and( eq(schema.tokenModelAvailability.available, true), eq(schema.accountTokens.enabled, true), + eq(schema.accountTokens.valueStatus, ACCOUNT_TOKEN_VALUE_STATUS_READY), eq(schema.accounts.status, 'active'), eq(schema.sites.status, 'active'), ), ) .all(); + const usableTokenRows = tokenRows.filter((row) => isUsableAccountToken(row.account_tokens)); const accountRows = await db.select().from(schema.modelAvailability) .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) @@ -382,13 +1056,12 @@ export async function rebuildTokenRoutesFromAvailability() { modelCandidates.get(modelName)!.set(candidateKey, { accountId, tokenId }); }; - for (const row of tokenRows) { + for (const row of usableTokenRows) { addModelCandidate(row.token_model_availability.modelName, row.accounts.id, row.account_tokens.id, row.accounts.siteId); } for (const row of accountRows) { - if (!isApiKeyConnection(row.accounts)) continue; - if (!(row.accounts.apiToken || '').trim()) continue; + if (!supportsDirectAccountRoutingConnection(row.accounts)) continue; addModelCandidate(row.model_availability.modelName, row.accounts.id, null, row.accounts.siteId); } @@ -401,7 +1074,7 @@ export async function rebuildTokenRoutesFromAvailability() { let removedRoutes = 0; for (const [modelName, candidateMap] of modelCandidates.entries()) { - let route = routes.find((r) => r.modelPattern === modelName); + let route = routes.find((r) => (r.routeMode || 'pattern') !== 'explicit_group' && r.modelPattern === modelName); if (!route) { const inserted = await db.insert(schema.tokenRoutes).values({ modelPattern: modelName, @@ -470,6 +1143,9 @@ export async function rebuildTokenRoutesFromAvailability() { const latestModelNames = new Set(Array.from(modelCandidates.keys())); for (const route of routes) { + if ((route.routeMode || 'pattern') === 'explicit_group') { + continue; + } const modelPattern = (route.modelPattern || '').trim(); if (!modelPattern || !isExactModelPattern(modelPattern) || latestModelNames.has(modelPattern)) { continue; diff --git a/src/server/services/oauth/antigravityProvider.ts b/src/server/services/oauth/antigravityProvider.ts new file mode 100644 index 0000000..5553a31 --- /dev/null +++ b/src/server/services/oauth/antigravityProvider.ts @@ -0,0 +1,291 @@ +import { fetch } from 'undici'; +import type { OAuthProviderDefinition } from './providers.js'; + +export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity'; +export const ANTIGRAVITY_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +export const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +export const ANTIGRAVITY_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json'; +export const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; +export const ANTIGRAVITY_CLIENT_SECRET = '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'; +export const ANTIGRAVITY_LOOPBACK_CALLBACK_PORT = 51121; +export const ANTIGRAVITY_LOOPBACK_CALLBACK_PATH = '/oauth-callback'; +export const ANTIGRAVITY_LOOPBACK_REDIRECT_URI = `http://localhost:${ANTIGRAVITY_LOOPBACK_CALLBACK_PORT}${ANTIGRAVITY_LOOPBACK_CALLBACK_PATH}`; +export const ANTIGRAVITY_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com'; +export const ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudcode-pa.googleapis.com'; +export const ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudcode-pa.sandbox.googleapis.com'; +export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1'; +export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1'; +export const ANTIGRAVITY_MODELS_USER_AGENT = 'antigravity/1.19.6 darwin/arm64'; +export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'; +export const ANTIGRAVITY_INTERNAL_API_VERSION = 'v1internal'; +export const ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS = 2_000; +export const ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS = 5; + +const ANTIGRAVITY_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs', +]; + +type AntigravityOAuthTokenPayload = { + access_token?: unknown; + refresh_token?: unknown; + token_type?: unknown; + expires_in?: unknown; + scope?: unknown; + expiry?: unknown; +}; + +type AntigravityLoadCodeAssistPayload = { + cloudaicompanionProject?: unknown; + allowedTiers?: unknown; +}; + +type AntigravityOnboardUserPayload = { + done?: unknown; + response?: unknown; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefined { + if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) { + return Date.now() + Math.trunc(payload.expires_in) * 1000; + } + if (typeof payload.expires_in === 'string') { + const parsed = Number.parseInt(payload.expires_in.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) { + return Date.now() + parsed * 1000; + } + } + if (typeof payload.expiry === 'string') { + const parsed = Date.parse(payload.expiry); + if (!Number.isNaN(parsed)) return parsed; + } + return undefined; +} + +function buildAntigravityMetadata() { + return { + ideType: 'ANTIGRAVITY', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }; +} + +function extractAntigravityProjectId(value: unknown): string | undefined { + if (typeof value === 'string') { + return asTrimmedString(value); + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + return asTrimmedString((value as { id?: unknown }).id); + } + return undefined; +} + +function extractAntigravityDefaultTierId(payload: AntigravityLoadCodeAssistPayload): string { + const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : []; + for (const rawTier of allowedTiers) { + if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue; + const tier = rawTier as { id?: unknown; isDefault?: unknown }; + if (tier.isDefault === true) { + const tierId = asTrimmedString(tier.id); + if (tierId) return tierId; + } + } + return 'legacy-tier'; +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function callAntigravityInternalApi( + accessToken: string, + method: 'loadCodeAssist' | 'onboardUser', + body: Record, +): Promise { + const response = await fetch( + `${ANTIGRAVITY_UPSTREAM_BASE_URL}/${ANTIGRAVITY_INTERNAL_API_VERSION}:${method}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': ANTIGRAVITY_USER_AGENT, + 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, + 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, + }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) return undefined; + return response.json() as Promise; +} + +async function postAntigravityToken(body: URLSearchParams) { + const response = await fetch(ANTIGRAVITY_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: body.toString(), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `antigravity token exchange failed with status ${response.status}`); + } + const payload = await response.json() as AntigravityOAuthTokenPayload; + const accessToken = asTrimmedString(payload.access_token); + if (!accessToken) { + throw new Error('antigravity token exchange response missing access token'); + } + return { + accessToken, + refreshToken: asTrimmedString(payload.refresh_token), + tokenExpiresAt: parseExpiresAt(payload), + providerData: { + tokenType: asTrimmedString(payload.token_type), + scope: asTrimmedString(payload.scope), + }, + }; +} + +async function fetchAntigravityUserEmail(accessToken: string): Promise { + const response = await fetch(ANTIGRAVITY_USERINFO_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + if (!response.ok) return undefined; + const payload = await response.json() as { email?: unknown }; + return asTrimmedString(payload.email); +} + +async function fetchAntigravityProjectId(accessToken: string): Promise { + const metadata = buildAntigravityMetadata(); + const payload = await callAntigravityInternalApi( + accessToken, + 'loadCodeAssist', + { metadata }, + ); + if (!payload) return undefined; + + const discoveredFromLoad = extractAntigravityProjectId(payload.cloudaicompanionProject); + if (discoveredFromLoad) { + return discoveredFromLoad; + } + + const tierId = extractAntigravityDefaultTierId(payload); + for (let attempt = 0; attempt < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS; attempt += 1) { + const onboardPayload = await callAntigravityInternalApi( + accessToken, + 'onboardUser', + { + tierId, + metadata, + }, + ); + if (!onboardPayload) return undefined; + if (onboardPayload.done === true) { + const response = ( + onboardPayload.response + && typeof onboardPayload.response === 'object' + && !Array.isArray(onboardPayload.response) + ) + ? onboardPayload.response as { cloudaicompanionProject?: unknown } + : undefined; + return extractAntigravityProjectId(response?.cloudaicompanionProject); + } + if ((attempt + 1) < ANTIGRAVITY_ONBOARD_MAX_ATTEMPTS) { + await sleep(ANTIGRAVITY_ONBOARD_POLL_INTERVAL_MS); + } + } + + return undefined; +} + +export const antigravityOauthProvider: OAuthProviderDefinition = { + metadata: { + provider: ANTIGRAVITY_OAUTH_PROVIDER, + label: 'Antigravity', + platform: 'antigravity', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: false, + }, + site: { + name: 'Google Antigravity OAuth', + url: ANTIGRAVITY_UPSTREAM_BASE_URL, + platform: 'antigravity', + }, + loopback: { + host: '127.0.0.1', + port: ANTIGRAVITY_LOOPBACK_CALLBACK_PORT, + path: ANTIGRAVITY_LOOPBACK_CALLBACK_PATH, + redirectUri: ANTIGRAVITY_LOOPBACK_REDIRECT_URI, + }, + buildAuthorizationUrl: async ({ state, redirectUri }) => { + const params = new URLSearchParams({ + client_id: ANTIGRAVITY_CLIENT_ID, + redirect_uri: redirectUri, + response_type: 'code', + access_type: 'offline', + prompt: 'consent', + scope: ANTIGRAVITY_SCOPES.join(' '), + state, + }); + return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`; + }, + exchangeAuthorizationCode: async ({ code, redirectUri }) => { + const token = await postAntigravityToken(new URLSearchParams({ + code, + client_id: ANTIGRAVITY_CLIENT_ID, + client_secret: ANTIGRAVITY_CLIENT_SECRET, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + })); + const email = await fetchAntigravityUserEmail(token.accessToken); + const projectId = await fetchAntigravityProjectId(token.accessToken); + return { + ...token, + email, + accountKey: email, + accountId: email, + projectId, + }; + }, + refreshAccessToken: async ({ refreshToken, oauth }) => { + const token = await postAntigravityToken(new URLSearchParams({ + client_id: ANTIGRAVITY_CLIENT_ID, + client_secret: ANTIGRAVITY_CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: refreshToken, + })); + const email = await fetchAntigravityUserEmail(token.accessToken); + return { + ...token, + email, + accountKey: email, + accountId: email, + projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken), + }; + }, + buildProxyHeaders: () => ({ + 'User-Agent': ANTIGRAVITY_USER_AGENT, + 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, + 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, + }), +}; diff --git a/src/server/services/oauth/claudeProvider.ts b/src/server/services/oauth/claudeProvider.ts new file mode 100644 index 0000000..61e7028 --- /dev/null +++ b/src/server/services/oauth/claudeProvider.ts @@ -0,0 +1,152 @@ +import { fetch } from 'undici'; +import { config } from '../../config.js'; +import { createPkceChallenge } from './sessionStore.js'; +import type { OAuthProviderDefinition } from './providers.js'; + +export const CLAUDE_OAUTH_PROVIDER = 'claude'; +export const CLAUDE_AUTH_URL = 'https://claude.ai/oauth/authorize'; +export const CLAUDE_TOKEN_URL = 'https://api.anthropic.com/v1/oauth/token'; +export const CLAUDE_CLIENT_ID = config.claudeClientId; +export const CLAUDE_LOOPBACK_CALLBACK_PORT = 54545; +export const CLAUDE_LOOPBACK_CALLBACK_PATH = '/callback'; +export const CLAUDE_LOOPBACK_REDIRECT_URI = `http://localhost:${CLAUDE_LOOPBACK_CALLBACK_PORT}${CLAUDE_LOOPBACK_CALLBACK_PATH}`; +export const CLAUDE_UPSTREAM_BASE_URL = 'https://api.anthropic.com'; +export const CLAUDE_DEFAULT_ANTHROPIC_VERSION = '2023-06-01'; + +function requireClaudeClientId(): string { + if (!CLAUDE_CLIENT_ID) { + throw new Error('CLAUDE_CLIENT_ID is not configured'); + } + return CLAUDE_CLIENT_ID; +} + +type ClaudeTokenResponse = { + access_token?: unknown; + refresh_token?: unknown; + token_type?: unknown; + expires_in?: unknown; + organization?: { + uuid?: unknown; + name?: unknown; + }; + account?: { + uuid?: unknown; + email_address?: unknown; + }; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseExpiresAt(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Date.now() + Math.trunc(value) * 1000; + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) { + return Date.now() + parsed * 1000; + } + } + return undefined; +} + +function parseClaudeTokenPayload(payload: unknown) { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('claude token exchange returned invalid payload'); + } + const body = payload as ClaudeTokenResponse; + const accessToken = asTrimmedString(body.access_token); + if (!accessToken) { + throw new Error('claude token exchange response missing access token'); + } + return { + accessToken, + refreshToken: asTrimmedString(body.refresh_token), + tokenExpiresAt: parseExpiresAt(body.expires_in), + email: asTrimmedString(body.account?.email_address), + accountId: asTrimmedString(body.account?.uuid), + accountKey: asTrimmedString(body.account?.uuid) || asTrimmedString(body.account?.email_address), + providerData: { + organizationId: asTrimmedString(body.organization?.uuid), + organizationName: asTrimmedString(body.organization?.name), + }, + }; +} + +async function postClaudeToken(body: Record) { + const response = await fetch(CLAUDE_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `claude token exchange failed with status ${response.status}`); + } + return parseClaudeTokenPayload(await response.json()); +} + +export const claudeOauthProvider: OAuthProviderDefinition = { + metadata: { + provider: CLAUDE_OAUTH_PROVIDER, + label: 'Claude', + platform: 'claude', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + site: { + name: 'Anthropic Claude OAuth', + url: CLAUDE_UPSTREAM_BASE_URL, + platform: 'claude', + }, + loopback: { + host: '127.0.0.1', + port: CLAUDE_LOOPBACK_CALLBACK_PORT, + path: CLAUDE_LOOPBACK_CALLBACK_PATH, + redirectUri: CLAUDE_LOOPBACK_REDIRECT_URI, + }, + buildAuthorizationUrl: async ({ state, redirectUri, codeVerifier }) => { + const params = new URLSearchParams({ + code: 'true', + client_id: requireClaudeClientId(), + response_type: 'code', + redirect_uri: redirectUri, + scope: 'org:create_api_key user:profile user:inference', + code_challenge: await createPkceChallenge(codeVerifier), + code_challenge_method: 'S256', + state, + }); + return `${CLAUDE_AUTH_URL}?${params.toString()}`; + }, + exchangeAuthorizationCode: async ({ code, state, redirectUri, codeVerifier }) => { + return postClaudeToken({ + code, + state, + grant_type: 'authorization_code', + client_id: requireClaudeClientId(), + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }); + }, + refreshAccessToken: async ({ refreshToken }) => { + return postClaudeToken({ + client_id: requireClaudeClientId(), + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + }, + buildProxyHeaders: () => ({ + 'anthropic-version': CLAUDE_DEFAULT_ANTHROPIC_VERSION, + }), +}; diff --git a/src/server/services/oauth/codexAccount.ts b/src/server/services/oauth/codexAccount.ts new file mode 100644 index 0000000..63cbd08 --- /dev/null +++ b/src/server/services/oauth/codexAccount.ts @@ -0,0 +1,30 @@ +import { schema } from '../../db/index.js'; +import { + buildOauthInfo, + getOauthInfoFromExtraConfig, + isOauthProvider, + type OauthInfo, +} from './oauthAccount.js'; + +export type CodexOauthInfo = OauthInfo & { + provider: 'codex'; +}; + +export function getCodexOauthInfoFromExtraConfig(extraConfig?: string | null): CodexOauthInfo | null { + const oauth = getOauthInfoFromExtraConfig(extraConfig); + if (!oauth || oauth.provider !== 'codex') return null; + return oauth as CodexOauthInfo; +} + +export function isCodexPlatform( + account: Pick | string | null | undefined, +): boolean { + return isOauthProvider(account, 'codex'); +} + +export function buildCodexOauthInfo( + extraConfig?: string | null, + patch: Partial = {}, +): CodexOauthInfo { + return buildOauthInfo(extraConfig, { provider: 'codex', ...patch }) as CodexOauthInfo; +} diff --git a/src/server/services/oauth/codexProvider.ts b/src/server/services/oauth/codexProvider.ts new file mode 100644 index 0000000..8895abd --- /dev/null +++ b/src/server/services/oauth/codexProvider.ts @@ -0,0 +1,233 @@ +import { fetch } from 'undici'; +import { config } from '../../config.js'; +import { createPkceChallenge } from './sessionStore.js'; +import type { OAuthProviderDefinition } from './providers.js'; + +export const CODEX_OAUTH_PROVIDER = 'codex'; +export const CODEX_AUTH_URL = 'https://auth.openai.com/oauth/authorize'; +export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token'; +export const CODEX_CLIENT_ID = config.codexClientId; +export const CODEX_CALLBACK_PATH = '/api/oauth/callback/codex'; +export const CODEX_LOOPBACK_CALLBACK_PATH = '/auth/callback'; +export const CODEX_LOOPBACK_CALLBACK_PORT = 1455; +export const CODEX_LOOPBACK_REDIRECT_URI = `http://localhost:${CODEX_LOOPBACK_CALLBACK_PORT}${CODEX_LOOPBACK_CALLBACK_PATH}`; +export const CODEX_UPSTREAM_BASE_URL = 'https://chatgpt.com/backend-api/codex'; + +function requireCodexClientId(): string { + if (!CODEX_CLIENT_ID) { + throw new Error('CODEX_CLIENT_ID is not configured'); + } + return CODEX_CLIENT_ID; +} + +type CodexJwtClaims = { + email?: unknown; + 'https://api.openai.com/auth'?: { + chatgpt_account_id?: unknown; + chatgpt_plan_type?: unknown; + }; +}; + +export type CodexTokenExchangeResult = { + accessToken: string; + refreshToken: string; + idToken: string; + accountId?: string; + email?: string; + planType?: string; + tokenExpiresAt: number; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseJwtClaims(token: string): CodexJwtClaims | null { + const parts = token.split('.'); + if (parts.length !== 3) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + return payload as CodexJwtClaims; + } catch { + return null; + } +} + +export async function buildCodexAuthorizationUrl(input: { + state: string; + redirectUri: string; + codeVerifier: string; +}): Promise { + const params = new URLSearchParams({ + client_id: requireCodexClientId(), + response_type: 'code', + redirect_uri: input.redirectUri, + scope: 'openid email profile offline_access', + state: input.state, + code_challenge: await createPkceChallenge(input.codeVerifier), + code_challenge_method: 'S256', + prompt: 'login', + id_token_add_organizations: 'true', + codex_cli_simplified_flow: 'true', + }); + return `${CODEX_AUTH_URL}?${params.toString()}`; +} + +function parseTokenResponsePayload(payload: unknown): CodexTokenExchangeResult { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('codex token exchange returned invalid payload'); + } + const body = payload as Record; + const accessToken = asTrimmedString(body.access_token); + const refreshToken = asTrimmedString(body.refresh_token); + const idToken = asTrimmedString(body.id_token); + const expiresIn = typeof body.expires_in === 'number' && Number.isFinite(body.expires_in) + ? Math.trunc(body.expires_in) + : (typeof body.expires_in === 'string' ? Number.parseInt(body.expires_in.trim(), 10) : NaN); + if (!accessToken || !refreshToken || !idToken || !Number.isFinite(expiresIn) || expiresIn <= 0) { + throw new Error('codex token exchange response missing required fields'); + } + const claims = parseJwtClaims(idToken); + return { + accessToken, + refreshToken, + idToken, + accountId: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_account_id), + email: asTrimmedString(claims?.email), + planType: asTrimmedString(claims?.['https://api.openai.com/auth']?.chatgpt_plan_type), + tokenExpiresAt: Date.now() + expiresIn * 1000, + }; +} + +async function exchangeCodexToken(form: URLSearchParams): Promise { + const response = await fetch(CODEX_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: form.toString(), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `codex token exchange failed with status ${response.status}`); + } + return parseTokenResponsePayload(await response.json()); +} + +export async function exchangeCodexAuthorizationCode(input: { + code: string; + codeVerifier: string; + redirectUri: string; +}): Promise { + return exchangeCodexToken(new URLSearchParams({ + grant_type: 'authorization_code', + client_id: requireCodexClientId(), + code: input.code, + redirect_uri: input.redirectUri, + code_verifier: input.codeVerifier, + })); +} + +export async function refreshCodexTokens(refreshToken: string): Promise { + return exchangeCodexToken(new URLSearchParams({ + client_id: requireCodexClientId(), + grant_type: 'refresh_token', + refresh_token: refreshToken, + scope: 'openid profile email', + })); +} + +function getHeaderValue(headers: Record | undefined, key: string): string | undefined { + if (!headers) return undefined; + const loweredKey = key.toLowerCase(); + for (const [rawKey, rawValue] of Object.entries(headers)) { + if (rawKey.toLowerCase() !== loweredKey) continue; + if (typeof rawValue === 'string') { + const trimmed = rawValue.trim(); + if (trimmed) return trimmed; + } + if (Array.isArray(rawValue)) { + for (const item of rawValue) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed) return trimmed; + } + } + } + return undefined; +} + +export const codexOauthProvider: OAuthProviderDefinition = { + metadata: { + provider: CODEX_OAUTH_PROVIDER, + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + site: { + name: 'ChatGPT Codex OAuth', + url: CODEX_UPSTREAM_BASE_URL, + platform: 'codex', + }, + loopback: { + host: '127.0.0.1', + port: CODEX_LOOPBACK_CALLBACK_PORT, + path: CODEX_LOOPBACK_CALLBACK_PATH, + redirectUri: CODEX_LOOPBACK_REDIRECT_URI, + }, + buildAuthorizationUrl: ({ state, redirectUri, codeVerifier }) => buildCodexAuthorizationUrl({ + state, + redirectUri, + codeVerifier, + }), + exchangeAuthorizationCode: async ({ code, redirectUri, codeVerifier }) => { + const exchange = await exchangeCodexAuthorizationCode({ + code, + redirectUri, + codeVerifier, + }); + return { + accessToken: exchange.accessToken, + refreshToken: exchange.refreshToken, + tokenExpiresAt: exchange.tokenExpiresAt, + email: exchange.email, + accountId: exchange.accountId, + accountKey: exchange.accountId, + planType: exchange.planType, + idToken: exchange.idToken, + }; + }, + refreshAccessToken: async ({ refreshToken }) => { + const refreshed = await refreshCodexTokens(refreshToken); + return { + accessToken: refreshed.accessToken, + refreshToken: refreshed.refreshToken, + tokenExpiresAt: refreshed.tokenExpiresAt, + email: refreshed.email, + accountId: refreshed.accountId, + accountKey: refreshed.accountId, + planType: refreshed.planType, + idToken: refreshed.idToken, + }; + }, + buildProxyHeaders: ({ oauth, downstreamHeaders }) => { + const accountId = getHeaderValue(downstreamHeaders, 'chatgpt-account-id') || oauth.accountId || oauth.accountKey; + const originator = getHeaderValue(downstreamHeaders, 'originator') || 'codex_cli_rs'; + const headers: Record = { + Originator: originator, + }; + if (accountId) { + headers['Chatgpt-Account-Id'] = accountId; + } + return headers; + }, +}; diff --git a/src/server/services/oauth/geminiCliProvider.ts b/src/server/services/oauth/geminiCliProvider.ts new file mode 100644 index 0000000..a56d82c --- /dev/null +++ b/src/server/services/oauth/geminiCliProvider.ts @@ -0,0 +1,470 @@ +import { fetch } from 'undici'; +import { config } from '../../config.js'; +import type { OAuthProviderDefinition } from './providers.js'; + +export const GEMINI_CLI_OAUTH_PROVIDER = 'gemini-cli'; +export const GEMINI_CLI_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +export const GEMINI_CLI_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +export const GEMINI_CLI_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json'; +export const GEMINI_CLI_PROJECTS_URL = 'https://cloudresourcemanager.googleapis.com/v1/projects'; +export const GEMINI_CLI_SERVICE_USAGE_URL = 'https://serviceusage.googleapis.com/v1'; +export const GEMINI_CLI_CLIENT_ID = config.geminiCliClientId; +export const GEMINI_CLI_CLIENT_SECRET = config.geminiCliClientSecret; +export const GEMINI_CLI_LOOPBACK_CALLBACK_PORT = 8085; +export const GEMINI_CLI_LOOPBACK_CALLBACK_PATH = '/oauth2callback'; +export const GEMINI_CLI_LOOPBACK_REDIRECT_URI = `http://localhost:${GEMINI_CLI_LOOPBACK_CALLBACK_PORT}${GEMINI_CLI_LOOPBACK_CALLBACK_PATH}`; +export const GEMINI_CLI_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com'; +export const GEMINI_CLI_GOOGLE_API_CLIENT = 'google-genai-sdk/1.41.0 gl-node/v22.19.0'; +export const GEMINI_CLI_USER_AGENT = 'GeminiCLI/0.31.0/unknown (win32; x64)'; +export const GEMINI_CLI_REQUIRED_SERVICE = 'cloudaicompanion.googleapis.com'; +export const GEMINI_CLI_INTERNAL_API_VERSION = 'v1internal'; +export const GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS = 2_000; +export const GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS = 15; +export const GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS = 5_000; +export const GEMINI_CLI_ONBOARD_MAX_ATTEMPTS = 6; + +function requireGeminiCliOAuthConfig() { + if (!GEMINI_CLI_CLIENT_ID) { + throw new Error('GEMINI_CLI_CLIENT_ID is not configured'); + } + if (!GEMINI_CLI_CLIENT_SECRET) { + throw new Error('GEMINI_CLI_CLIENT_SECRET is not configured'); + } + return { + clientId: GEMINI_CLI_CLIENT_ID, + clientSecret: GEMINI_CLI_CLIENT_SECRET, + }; +} + +const GEMINI_CLI_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', +]; + +type GeminiOAuthTokenPayload = { + access_token?: unknown; + refresh_token?: unknown; + token_type?: unknown; + expires_in?: unknown; + scope?: unknown; + expiry?: unknown; +}; + +type GeminiLoadCodeAssistPayload = { + cloudaicompanionProject?: unknown; + allowedTiers?: unknown; +}; + +type GeminiOnboardUserPayload = { + done?: unknown; + response?: unknown; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseExpiresAt(payload: GeminiOAuthTokenPayload): number | undefined { + if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) { + return Date.now() + Math.trunc(payload.expires_in) * 1000; + } + if (typeof payload.expires_in === 'string') { + const parsed = Number.parseInt(payload.expires_in.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) { + return Date.now() + parsed * 1000; + } + } + if (typeof payload.expiry === 'string') { + const parsed = Date.parse(payload.expiry); + if (!Number.isNaN(parsed)) return parsed; + } + return undefined; +} + +function buildGeminiCliMetadata() { + return { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }; +} + +function extractGeminiProjectId(value: unknown): string | undefined { + if (typeof value === 'string') { + return asTrimmedString(value); + } + if (value && typeof value === 'object' && !Array.isArray(value)) { + return asTrimmedString((value as { id?: unknown }).id); + } + return undefined; +} + +function extractGeminiDefaultTierId(payload: GeminiLoadCodeAssistPayload): string { + const allowedTiers = Array.isArray(payload.allowedTiers) ? payload.allowedTiers : []; + for (const rawTier of allowedTiers) { + if (!rawTier || typeof rawTier !== 'object' || Array.isArray(rawTier)) continue; + const tier = rawTier as { id?: unknown; isDefault?: unknown }; + if (tier.isDefault === true) { + const tierId = asTrimmedString(tier.id); + if (tierId) return tierId; + } + } + return 'legacy-tier'; +} + +function isGeminiFreeUserProject(input: { + requestedProjectId?: string; + tierId: string; +}) { + const projectId = (input.requestedProjectId || '').trim(); + const tierId = input.tierId.trim(); + return projectId.startsWith('gen-lang-client-') + || tierId.toUpperCase() === 'FREE' + || tierId.toUpperCase() === 'LEGACY'; +} + +function isSameGeminiProjectId(a: string, b: string): boolean { + return a.trim().toLowerCase() === b.trim().toLowerCase(); +} + +function extractGeminiServiceErrorMessage(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return ''; + try { + const parsed = JSON.parse(trimmed) as { + error?: { message?: unknown }; + }; + return asTrimmedString(parsed.error?.message) || trimmed; + } catch { + return trimmed; + } +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function postGeminiToken(body: URLSearchParams) { + const response = await fetch(GEMINI_CLI_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: body.toString(), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `gemini token exchange failed with status ${response.status}`); + } + const payload = await response.json() as GeminiOAuthTokenPayload; + const accessToken = asTrimmedString(payload.access_token); + if (!accessToken) { + throw new Error('gemini token exchange response missing access token'); + } + return { + accessToken, + refreshToken: asTrimmedString(payload.refresh_token), + tokenExpiresAt: parseExpiresAt(payload), + providerData: { + tokenType: asTrimmedString(payload.token_type), + scope: asTrimmedString(payload.scope), + }, + }; +} + +async function fetchGeminiUserEmail(accessToken: string): Promise { + const response = await fetch(GEMINI_CLI_USERINFO_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + if (!response.ok) return undefined; + const payload = await response.json() as { email?: unknown }; + return asTrimmedString(payload.email); +} + +async function callGeminiCliInternalApi( + accessToken: string, + method: 'loadCodeAssist' | 'onboardUser', + body: Record, +): Promise { + const response = await fetch( + `${GEMINI_CLI_UPSTREAM_BASE_URL}/${GEMINI_CLI_INTERNAL_API_VERSION}:${method}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + body: JSON.stringify(body), + }, + ); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `api request failed with status ${response.status}`); + } + return response.json() as Promise; +} + +async function fetchGcpProjects(accessToken: string): Promise { + const response = await fetch(GEMINI_CLI_PROJECTS_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `project list request failed with status ${response.status}`); + } + const payload = await response.json() as { projects?: Array<{ projectId?: unknown }> }; + return (payload.projects || []) + .map((project) => asTrimmedString(project.projectId)) + .filter((projectId): projectId is string => !!projectId); +} + +async function checkCloudAIAPIEnabled(accessToken: string, projectId: string): Promise { + const checkResponse = await fetch( + `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + }, + ); + if (checkResponse.ok) { + const payload = await checkResponse.json() as { state?: unknown }; + const state = asTrimmedString(payload.state); + if ((state || '').toUpperCase() === 'ENABLED') { + return; + } + } + + const response = await fetch( + `${GEMINI_CLI_SERVICE_USAGE_URL}/projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(GEMINI_CLI_REQUIRED_SERVICE)}:enable`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }, + body: '{}', + }, + ); + const text = await response.text().catch(() => ''); + if (response.ok) { + return; + } + if (response.status === 400 && extractGeminiServiceErrorMessage(text).toLowerCase().includes('already enabled')) { + return; + } + throw new Error(`project activation required: ${extractGeminiServiceErrorMessage(text) || `HTTP ${response.status}`}`); +} + +async function performGeminiCliSetup( + accessToken: string, + requestedProjectId: string, +): Promise { + const trimmedRequest = requestedProjectId.trim(); + const explicitProject = !!trimmedRequest; + const metadata = buildGeminiCliMetadata(); + const loadResponse = await callGeminiCliInternalApi( + accessToken, + 'loadCodeAssist', + explicitProject + ? { + metadata, + cloudaicompanionProject: trimmedRequest, + } + : { metadata }, + ); + + const tierId = extractGeminiDefaultTierId(loadResponse); + let projectId = trimmedRequest || extractGeminiProjectId(loadResponse.cloudaicompanionProject) || ''; + + if (!projectId) { + for (let attempt = 0; attempt < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS; attempt += 1) { + const onboardResponse = await callGeminiCliInternalApi( + accessToken, + 'onboardUser', + { + tierId, + metadata, + }, + ); + if (onboardResponse.done === true) { + const response = ( + onboardResponse.response + && typeof onboardResponse.response === 'object' + && !Array.isArray(onboardResponse.response) + ) + ? onboardResponse.response as { cloudaicompanionProject?: unknown } + : undefined; + projectId = extractGeminiProjectId(response?.cloudaicompanionProject) || ''; + break; + } + if ((attempt + 1) < GEMINI_CLI_AUTO_ONBOARD_MAX_ATTEMPTS) { + await sleep(GEMINI_CLI_AUTO_ONBOARD_POLL_INTERVAL_MS); + } + } + } + + if (!projectId) { + throw new Error('gemini cli: project selection required'); + } + + let finalProjectId = projectId; + for (let attempt = 0; attempt < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS; attempt += 1) { + const onboardResponse = await callGeminiCliInternalApi( + accessToken, + 'onboardUser', + { + tierId, + metadata, + cloudaicompanionProject: projectId, + }, + ); + if (onboardResponse.done === true) { + const response = ( + onboardResponse.response + && typeof onboardResponse.response === 'object' + && !Array.isArray(onboardResponse.response) + ) + ? onboardResponse.response as { cloudaicompanionProject?: unknown } + : undefined; + const responseProjectId = extractGeminiProjectId(response?.cloudaicompanionProject) || ''; + if (responseProjectId) { + if (explicitProject && !isSameGeminiProjectId(responseProjectId, projectId)) { + finalProjectId = isGeminiFreeUserProject({ requestedProjectId: projectId, tierId }) + ? responseProjectId + : projectId; + } else { + finalProjectId = responseProjectId; + } + } + return finalProjectId || projectId; + } + if ((attempt + 1) < GEMINI_CLI_ONBOARD_MAX_ATTEMPTS) { + await sleep(GEMINI_CLI_ONBOARD_POLL_INTERVAL_MS); + } + } + + if (finalProjectId) { + return finalProjectId; + } + throw new Error('gemini cli: onboarding timed out'); +} + +async function ensureGeminiProjectAndOnboard( + accessToken: string, + requestedProjectId?: string, +): Promise { + const explicitProject = asTrimmedString(requestedProjectId); + if (explicitProject) { + return performGeminiCliSetup(accessToken, explicitProject); + } + + const projects = await fetchGcpProjects(accessToken); + const firstProject = projects[0]; + if (!firstProject) { + throw new Error('no Google Cloud projects available for this account'); + } + return performGeminiCliSetup(accessToken, firstProject); +} + +export const geminiCliOauthProvider: OAuthProviderDefinition = { + metadata: { + provider: GEMINI_CLI_OAUTH_PROVIDER, + label: 'Gemini CLI', + platform: 'gemini-cli', + enabled: true, + loginType: 'oauth', + requiresProjectId: true, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + site: { + name: 'Google Gemini CLI OAuth', + url: GEMINI_CLI_UPSTREAM_BASE_URL, + platform: 'gemini-cli', + }, + loopback: { + host: '127.0.0.1', + port: GEMINI_CLI_LOOPBACK_CALLBACK_PORT, + path: GEMINI_CLI_LOOPBACK_CALLBACK_PATH, + redirectUri: GEMINI_CLI_LOOPBACK_REDIRECT_URI, + }, + buildAuthorizationUrl: async ({ state, redirectUri }) => { + const oauthConfig = requireGeminiCliOAuthConfig(); + const params = new URLSearchParams({ + client_id: oauthConfig.clientId, + redirect_uri: redirectUri, + response_type: 'code', + access_type: 'offline', + prompt: 'consent', + scope: GEMINI_CLI_SCOPES.join(' '), + state, + }); + return `${GEMINI_CLI_AUTH_URL}?${params.toString()}`; + }, + exchangeAuthorizationCode: async ({ code, redirectUri, projectId }) => { + const oauthConfig = requireGeminiCliOAuthConfig(); + const token = await postGeminiToken(new URLSearchParams({ + code, + client_id: oauthConfig.clientId, + client_secret: oauthConfig.clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + })); + const resolvedProjectId = await ensureGeminiProjectAndOnboard(token.accessToken, projectId); + await checkCloudAIAPIEnabled(token.accessToken, resolvedProjectId); + const email = await fetchGeminiUserEmail(token.accessToken); + return { + ...token, + email, + accountKey: email, + accountId: email, + projectId: resolvedProjectId, + }; + }, + refreshAccessToken: async ({ refreshToken, oauth }) => { + const oauthConfig = requireGeminiCliOAuthConfig(); + const token = await postGeminiToken(new URLSearchParams({ + client_id: oauthConfig.clientId, + client_secret: oauthConfig.clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + })); + const nextProjectId = oauth?.projectId + ? oauth.projectId + : await ensureGeminiProjectAndOnboard(token.accessToken); + await checkCloudAIAPIEnabled(token.accessToken, nextProjectId); + const email = await fetchGeminiUserEmail(token.accessToken); + return { + ...token, + email, + accountKey: email, + accountId: email, + projectId: nextProjectId, + }; + }, + buildProxyHeaders: () => ({ + 'User-Agent': GEMINI_CLI_USER_AGENT, + 'X-Goog-Api-Client': GEMINI_CLI_GOOGLE_API_CLIENT, + }), +}; diff --git a/src/server/services/oauth/localCallbackServer.test.ts b/src/server/services/oauth/localCallbackServer.test.ts new file mode 100644 index 0000000..ed7c5f6 --- /dev/null +++ b/src/server/services/oauth/localCallbackServer.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + startOAuthLoopbackCallbackServer, + stopOAuthLoopbackCallbackServers, +} from './localCallbackServer.js'; + +describe('oauth loopback callback server', () => { + afterEach(async () => { + await stopOAuthLoopbackCallbackServers(); + }); + + it('accepts codex oauth callback requests and closes the popup on success', async () => { + const callbackHandler = vi.fn(async () => ({ accountId: 12, siteId: 34 })); + const started = await startOAuthLoopbackCallbackServer('codex', { + callbackHandler, + }); + + const response = await fetch(`${started.origin}/auth/callback?state=test-state&code=test-code`); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(callbackHandler).toHaveBeenCalledWith({ + provider: 'codex', + state: 'test-state', + code: 'test-code', + error: undefined, + }); + expect(body).toContain('window.close()'); + }); + + it('renders a stable error page when oauth completion fails', async () => { + const callbackHandler = vi.fn(async () => { + throw new Error('oauth failed'); + }); + const started = await startOAuthLoopbackCallbackServer('claude', { + callbackHandler, + }); + + const response = await fetch(`${started.origin}/callback?state=test-state&code=test-code`); + const body = await response.text(); + + expect(response.status).toBe(500); + expect(body).toContain('OAuth authorization failed'); + expect(body).not.toContain('oauth failed'); + }); +}); diff --git a/src/server/services/oauth/localCallbackServer.ts b/src/server/services/oauth/localCallbackServer.ts new file mode 100644 index 0000000..8e96f09 --- /dev/null +++ b/src/server/services/oauth/localCallbackServer.ts @@ -0,0 +1,245 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { type AddressInfo } from 'node:net'; +import { getOAuthProviderDefinition, listOAuthProviderDefinitions } from './providers.js'; +import { handleOauthCallback } from './service.js'; + +type CallbackHandler = typeof handleOauthCallback; + +type StartOAuthLoopbackCallbackServerOptions = { + host?: string; + port?: number; + callbackHandler?: CallbackHandler; +}; + +export type OAuthLoopbackCallbackServerState = { + provider: string; + attempted: boolean; + ready: boolean; + host?: string; + port: number; + path: string; + origin: string; + redirectUri: string; + error?: string; +}; + +const servers = new Map(); +const states = new Map(); +const startPromises = new Map>(); + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function renderCompletionPage(message: string): string { + const safeMessage = escapeHtml(message); + return ` + + + + OAuth Callback + + + + ${safeMessage} + +`; +} + +function respondHtml(response: ServerResponse, statusCode: number, message: string) { + response.writeHead(statusCode, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + }); + response.end(renderCompletionPage(message)); +} + +function normalizeOrigin(host: string | undefined, port: number): string { + if (!host || host === '::' || host === '0.0.0.0') { + return `http://localhost:${port}`; + } + if (host.includes(':') && !host.startsWith('[')) { + return `http://[${host}]:${port}`; + } + return `http://${host}:${port}`; +} + +function createDefaultState(provider: string): OAuthLoopbackCallbackServerState { + const definition = getOAuthProviderDefinition(provider); + if (!definition) { + throw new Error(`unsupported oauth provider: ${provider}`); + } + return { + provider, + attempted: false, + ready: false, + host: definition.loopback.host, + port: definition.loopback.port, + path: definition.loopback.path, + origin: normalizeOrigin(definition.loopback.host, definition.loopback.port), + redirectUri: definition.loopback.redirectUri, + }; +} + +async function handleCallbackRequest( + provider: string, + request: IncomingMessage, + response: ServerResponse, + callbackHandler: CallbackHandler, +) { + const definition = getOAuthProviderDefinition(provider); + if (!definition) { + response.writeHead(404); + response.end('Not found'); + return; + } + + if (request.method !== 'GET') { + response.writeHead(405, { Allow: 'GET' }); + response.end('Method not allowed'); + return; + } + + const requestUrl = new URL(request.url || '/', 'http://localhost'); + if (requestUrl.pathname !== definition.loopback.path) { + response.writeHead(404); + response.end('Not found'); + return; + } + + try { + await callbackHandler({ + provider, + state: requestUrl.searchParams.get('state') || '', + code: requestUrl.searchParams.get('code') || undefined, + error: requestUrl.searchParams.get('error') || undefined, + }); + respondHtml(response, 200, 'OAuth authorization succeeded. You can close this window.'); + } catch { + respondHtml(response, 500, 'OAuth authorization failed. Return to metapi and review the server logs.'); + } +} + +export function getOAuthLoopbackCallbackServerState(provider: string): OAuthLoopbackCallbackServerState { + return { ...(states.get(provider) || createDefaultState(provider)) }; +} + +export function getOAuthLoopbackCallbackServerStates(): OAuthLoopbackCallbackServerState[] { + return listOAuthProviderDefinitions().map((provider) => getOAuthLoopbackCallbackServerState(provider.metadata.provider)); +} + +export async function startOAuthLoopbackCallbackServer( + provider: string, + options: StartOAuthLoopbackCallbackServerOptions = {}, +): Promise { + const definition = getOAuthProviderDefinition(provider); + if (!definition) { + throw new Error(`unsupported oauth provider: ${provider}`); + } + if (servers.has(provider)) { + return getOAuthLoopbackCallbackServerState(provider); + } + const existingStart = startPromises.get(provider); + if (existingStart) { + return existingStart; + } + + const callbackHandler = options.callbackHandler || handleOauthCallback; + const host = options.host || definition.loopback.host; + const port = options.port ?? definition.loopback.port; + const { path, redirectUri } = definition.loopback; + const startPromise = new Promise((resolve, reject) => { + const server = createServer((request, response) => { + void handleCallbackRequest(provider, request, response, callbackHandler); + }); + + const finalizeFailure = (error: Error) => { + const failedState: OAuthLoopbackCallbackServerState = { + provider, + attempted: true, + ready: false, + host, + port, + path, + origin: normalizeOrigin(host, port), + redirectUri, + error: error.message || `failed to start ${provider} oauth callback server`, + }; + states.set(provider, failedState); + servers.delete(provider); + reject(error); + }; + + const onStartupError = (error: Error) => { + finalizeFailure(error); + }; + + server.once('error', onStartupError); + server.listen(port, host, () => { + server.off('error', onStartupError); + servers.set(provider, server); + const address = server.address() as AddressInfo | null; + const listeningPort = address?.port || port; + const listeningHost = address?.address || host; + const nextState: OAuthLoopbackCallbackServerState = { + provider, + attempted: true, + ready: true, + host: listeningHost, + port: listeningPort, + path, + origin: normalizeOrigin(listeningHost, listeningPort), + redirectUri, + }; + states.set(provider, nextState); + resolve(getOAuthLoopbackCallbackServerState(provider)); + }); + }).finally(() => { + startPromises.delete(provider); + }); + + startPromises.set(provider, startPromise); + return startPromise; +} + +export async function startOAuthLoopbackCallbackServers( + options: StartOAuthLoopbackCallbackServerOptions = {}, +): Promise { + const results = await Promise.allSettled( + listOAuthProviderDefinitions().map((provider) => + startOAuthLoopbackCallbackServer(provider.metadata.provider, options)), + ); + return results.map((result, index) => { + if (result.status === 'fulfilled') return result.value; + return getOAuthLoopbackCallbackServerState(listOAuthProviderDefinitions()[index]!.metadata.provider); + }); +} + +export async function stopOAuthLoopbackCallbackServers(): Promise { + const activeServers = Array.from(servers.entries()); + servers.clear(); + states.clear(); + startPromises.clear(); + + await Promise.all(activeServers.map(async ([provider, server]) => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }).catch(() => undefined); + states.set(provider, createDefaultState(provider)); + })); +} + +export async function startCodexLoopbackCallbackServer( + options: StartOAuthLoopbackCallbackServerOptions = {}, +): Promise { + return startOAuthLoopbackCallbackServer('codex', options); +} + +export async function stopCodexLoopbackCallbackServer(): Promise { + await stopOAuthLoopbackCallbackServers(); +} diff --git a/src/server/services/oauth/oauthAccount.ts b/src/server/services/oauth/oauthAccount.ts new file mode 100644 index 0000000..4a99753 --- /dev/null +++ b/src/server/services/oauth/oauthAccount.ts @@ -0,0 +1,166 @@ +import { schema } from '../../db/index.js'; +import type { OauthQuotaSnapshot } from './quotaTypes.js'; + +type ParsedOauthInfo = { + provider?: unknown; + accountId?: unknown; + accountKey?: unknown; + email?: unknown; + planType?: unknown; + projectId?: unknown; + tokenExpiresAt?: unknown; + refreshToken?: unknown; + idToken?: unknown; + providerData?: unknown; + quota?: unknown; + modelDiscoveryStatus?: unknown; + lastModelSyncAt?: unknown; + lastModelSyncError?: unknown; + lastDiscoveredModels?: unknown; +}; + +type ParsedExtraConfig = { + oauth?: ParsedOauthInfo; +}; + +export type OauthModelDiscoveryStatus = 'healthy' | 'abnormal'; + +export type OauthInfo = { + provider: string; + accountId?: string; + accountKey?: string; + email?: string; + planType?: string; + projectId?: string; + tokenExpiresAt?: number; + refreshToken?: string; + idToken?: string; + providerData?: Record; + quota?: OauthQuotaSnapshot; + modelDiscoveryStatus?: OauthModelDiscoveryStatus; + lastModelSyncAt?: string; + lastModelSyncError?: string; + lastDiscoveredModels?: string[]; +}; + +function parseExtraConfig(extraConfig?: string | null): ParsedExtraConfig { + if (!extraConfig) return {}; + try { + const parsed = JSON.parse(extraConfig) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + return parsed as ParsedExtraConfig; + } catch { + return {}; + } +} + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function asPositiveInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.trunc(value); + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return undefined; +} + +function asIsoDateTime(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Date.parse(trimmed); + return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString(); +} + +function asStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + return value + .map((item) => asTrimmedString(item)) + .filter((item): item is string => !!item); +} + +function asRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return value as Record; +} + +function asQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return value as OauthQuotaSnapshot; +} + +function asModelDiscoveryStatus(value: unknown): OauthModelDiscoveryStatus | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === 'healthy') return 'healthy'; + if (normalized === 'abnormal') return 'abnormal'; + return undefined; +} + +export function getOauthInfoFromExtraConfig(extraConfig?: string | null): OauthInfo | null { + const parsed = parseExtraConfig(extraConfig).oauth; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const provider = asTrimmedString(parsed.provider); + if (!provider) return null; + const accountKey = asTrimmedString(parsed.accountKey) || asTrimmedString(parsed.accountId); + return { + provider, + accountId: asTrimmedString(parsed.accountId) || accountKey, + accountKey, + email: asTrimmedString(parsed.email), + planType: asTrimmedString(parsed.planType), + projectId: asTrimmedString(parsed.projectId), + tokenExpiresAt: asPositiveInteger(parsed.tokenExpiresAt), + refreshToken: asTrimmedString(parsed.refreshToken), + idToken: asTrimmedString(parsed.idToken), + providerData: asRecord(parsed.providerData), + quota: asQuotaSnapshot(parsed.quota), + modelDiscoveryStatus: asModelDiscoveryStatus(parsed.modelDiscoveryStatus), + lastModelSyncAt: asIsoDateTime(parsed.lastModelSyncAt), + lastModelSyncError: asTrimmedString(parsed.lastModelSyncError), + lastDiscoveredModels: asStringArray(parsed.lastDiscoveredModels), + }; +} + +export function buildOauthInfo( + extraConfig?: string | null, + patch: Partial = {}, +): OauthInfo { + const provider = patch.provider || getOauthInfoFromExtraConfig(extraConfig)?.provider; + if (!provider) { + throw new Error('oauth provider is required'); + } + const current = getOauthInfoFromExtraConfig(extraConfig); + const next: OauthInfo = { + provider, + ...(current || {}), + ...patch, + }; + if (!next.accountKey && next.accountId) { + next.accountKey = next.accountId; + } + if (!next.accountId && next.accountKey) { + next.accountId = next.accountKey; + } + return next; +} + +export function isOauthProvider( + account: Pick | string | null | undefined, + provider?: string, +): boolean { + const extraConfig = typeof account === 'string' || account == null + ? account + : account.extraConfig; + const oauth = getOauthInfoFromExtraConfig(extraConfig); + if (!oauth) return false; + if (!provider) return true; + return oauth.provider === provider; +} diff --git a/src/server/services/oauth/providers.ts b/src/server/services/oauth/providers.ts new file mode 100644 index 0000000..b04d866 --- /dev/null +++ b/src/server/services/oauth/providers.ts @@ -0,0 +1,100 @@ +import { codexOauthProvider } from './codexProvider.js'; +import { claudeOauthProvider } from './claudeProvider.js'; +import { geminiCliOauthProvider } from './geminiCliProvider.js'; +import { antigravityOauthProvider } from './antigravityProvider.js'; + +export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli' | 'antigravity'; + +export type OAuthProviderMetadata = { + provider: OAuthProviderId; + label: string; + platform: string; + enabled: boolean; + loginType: 'oauth'; + requiresProjectId: boolean; + supportsDirectAccountRouting: boolean; + supportsCloudValidation: boolean; + supportsNativeProxy: boolean; +}; + +export type OAuthProviderExchangeResult = { + accessToken: string; + refreshToken?: string; + tokenExpiresAt?: number; + email?: string; + accountKey?: string; + accountId?: string; + planType?: string; + projectId?: string; + idToken?: string; + providerData?: Record; +}; + +export type OAuthProviderRefreshResult = OAuthProviderExchangeResult; + +export type OAuthProviderProxyHeaderInput = { + oauth: { + provider: string; + accountKey?: string; + accountId?: string; + projectId?: string; + providerData?: Record; + }; + downstreamHeaders?: Record; +}; + +export interface OAuthProviderDefinition { + metadata: OAuthProviderMetadata; + site: { + name: string; + url: string; + platform: string; + }; + loopback: { + host: string; + port: number; + path: string; + redirectUri: string; + }; + buildAuthorizationUrl(input: { + state: string; + redirectUri: string; + codeVerifier: string; + projectId?: string; + }): Promise; + resolveRedirectUri?(input: { + requestOrigin?: string; + }): string; + exchangeAuthorizationCode(input: { + code: string; + state: string; + redirectUri: string; + codeVerifier: string; + projectId?: string; + }): Promise; + refreshAccessToken(input: { + refreshToken: string; + oauth?: { + projectId?: string; + providerData?: Record; + }; + }): Promise; + buildProxyHeaders?(input: OAuthProviderProxyHeaderInput): Record; +} + +const PROVIDERS: OAuthProviderDefinition[] = [ + codexOauthProvider, + claudeOauthProvider, + geminiCliOauthProvider, + antigravityOauthProvider, +]; + +const PROVIDER_BY_ID = new Map(PROVIDERS.map((provider) => [provider.metadata.provider, provider] as const)); + +export function listOAuthProviderDefinitions(): OAuthProviderDefinition[] { + return PROVIDERS.slice(); +} + +export function getOAuthProviderDefinition(provider: string): OAuthProviderDefinition | undefined { + return PROVIDER_BY_ID.get(provider as OAuthProviderId); +} diff --git a/src/server/services/oauth/quota.test.ts b/src/server/services/oauth/quota.test.ts new file mode 100644 index 0000000..0466ce7 --- /dev/null +++ b/src/server/services/oauth/quota.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { + buildQuotaSnapshotFromOauthInfo, + parseCodexQuotaResetHint, +} from './quota.js'; + +function buildJwt(payload: Record) { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)) + .toString('base64url'); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`; +} + +describe('oauth quota snapshot helpers', () => { + it('builds a codex quota snapshot from stored id_token claims and reset hint', () => { + const snapshot = buildQuotaSnapshotFromOauthInfo({ + provider: 'codex', + planType: 'plus', + idToken: buildJwt({ + email: 'codex-user@example.com', + 'https://api.openai.com/auth': { + chatgpt_account_id: 'chatgpt-account-123', + chatgpt_plan_type: 'plus', + chatgpt_subscription_active_start: '2026-03-01T00:00:00.000Z', + chatgpt_subscription_active_until: '2026-04-01T00:00:00.000Z', + }, + }), + quota: { + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: '2026-03-18T01:00:00.000Z', + lastLimitResetAt: '2026-03-18T05:00:00.000Z', + providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }, + }); + + expect(snapshot).toEqual({ + status: 'supported', + source: 'reverse_engineered', + lastSyncAt: '2026-03-18T01:00:00.000Z', + lastLimitResetAt: '2026-03-18T05:00:00.000Z', + providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values', + subscription: { + planType: 'plus', + activeStart: '2026-03-01T00:00:00.000Z', + activeUntil: '2026-04-01T00:00:00.000Z', + }, + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is not exposed by current codex oauth artifacts', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is not exposed by current codex oauth artifacts', + }, + }, + }); + }); + + it('returns unsupported snapshots for non-codex providers', () => { + const snapshot = buildQuotaSnapshotFromOauthInfo({ + provider: 'antigravity', + planType: 'pro', + }); + + expect(snapshot).toEqual({ + status: 'unsupported', + source: 'official', + providerMessage: 'official quota windows are not exposed for antigravity oauth', + windows: { + fiveHour: { + supported: false, + message: 'official 5h quota window is unavailable for this provider', + }, + sevenDay: { + supported: false, + message: 'official 7d quota window is unavailable for this provider', + }, + }, + }); + }); + + it('parses codex usage_limit_reached reset hints', () => { + const reset = parseCodexQuotaResetHint(429, JSON.stringify({ + error: { + type: 'usage_limit_reached', + resets_at: 1773800400, + }, + })); + + expect(reset).toEqual({ + resetAt: '2026-03-18T02:20:00.000Z', + message: 'codex usage_limit_reached reset hint observed from upstream', + }); + }); +}); diff --git a/src/server/services/oauth/quota.ts b/src/server/services/oauth/quota.ts new file mode 100644 index 0000000..1406c92 --- /dev/null +++ b/src/server/services/oauth/quota.ts @@ -0,0 +1,259 @@ +import { eq } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { mergeAccountExtraConfig } from '../accountExtraConfig.js'; +import { buildOauthInfo, getOauthInfoFromExtraConfig, type OauthInfo } from './oauthAccount.js'; +import type { OauthQuotaSnapshot, OauthQuotaWindowSnapshot } from './quotaTypes.js'; + +type CodexJwtClaims = { + 'https://api.openai.com/auth'?: { + chatgpt_plan_type?: unknown; + chatgpt_subscription_active_start?: unknown; + chatgpt_subscription_active_until?: unknown; + }; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function asIsoDateTime(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Date.parse(trimmed); + return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString(); +} + +function parseCodexJwtClaims(idToken?: string): CodexJwtClaims | null { + if (!idToken) return null; + const parts = idToken.split('.'); + if (parts.length !== 3) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1] || '', 'base64url').toString('utf8')) as unknown; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + return payload as CodexJwtClaims; + } catch { + return null; + } +} + +function buildUnsupportedWindow(message: string): OauthQuotaWindowSnapshot { + return { supported: false, message }; +} + +function buildCodexUnsupportedWindows(): OauthQuotaSnapshot['windows'] { + return { + fiveHour: buildUnsupportedWindow('official 5h quota window is not exposed by current codex oauth artifacts'), + sevenDay: buildUnsupportedWindow('official 7d quota window is not exposed by current codex oauth artifacts'), + }; +} + +function buildProviderUnsupportedSnapshot(provider: string): OauthQuotaSnapshot { + return { + status: 'unsupported', + source: 'official', + providerMessage: `official quota windows are not exposed for ${provider} oauth`, + windows: { + fiveHour: buildUnsupportedWindow('official 5h quota window is unavailable for this provider'), + sevenDay: buildUnsupportedWindow('official 7d quota window is unavailable for this provider'), + }, + }; +} + +function normalizeStoredWindow(value: unknown): OauthQuotaWindowSnapshot | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const raw = value as Record; + const supported = typeof raw.supported === 'boolean' ? raw.supported : undefined; + if (supported === undefined) return undefined; + const pickNumber = (field: string) => { + const item = raw[field]; + return typeof item === 'number' && Number.isFinite(item) ? item : undefined; + }; + const normalized: OauthQuotaWindowSnapshot = { + supported, + }; + const limit = pickNumber('limit'); + const used = pickNumber('used'); + const remaining = pickNumber('remaining'); + const resetAt = asIsoDateTime(raw.resetAt); + const message = asTrimmedString(raw.message); + if (limit !== undefined) normalized.limit = limit; + if (used !== undefined) normalized.used = used; + if (remaining !== undefined) normalized.remaining = remaining; + if (resetAt) normalized.resetAt = resetAt; + if (message) normalized.message = message; + return normalized; +} + +function normalizeStoredQuotaSnapshot(value: unknown): OauthQuotaSnapshot | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + const raw = value as Record; + const status = raw.status === 'supported' || raw.status === 'unsupported' || raw.status === 'error' + ? raw.status + : undefined; + const source = raw.source === 'official' || raw.source === 'reverse_engineered' + ? raw.source + : undefined; + const windowsRaw = raw.windows; + if (!status || !source || !windowsRaw || typeof windowsRaw !== 'object' || Array.isArray(windowsRaw)) { + return undefined; + } + const windowsObject = windowsRaw as Record; + const fiveHour = normalizeStoredWindow(windowsObject.fiveHour); + const sevenDay = normalizeStoredWindow(windowsObject.sevenDay); + if (!fiveHour || !sevenDay) return undefined; + + const subscriptionRaw = raw.subscription; + const subscription = subscriptionRaw && typeof subscriptionRaw === 'object' && !Array.isArray(subscriptionRaw) + ? { + planType: asTrimmedString((subscriptionRaw as Record).planType), + activeStart: asIsoDateTime((subscriptionRaw as Record).activeStart), + activeUntil: asIsoDateTime((subscriptionRaw as Record).activeUntil), + } + : undefined; + + return { + status, + source, + ...(asIsoDateTime(raw.lastSyncAt) ? { lastSyncAt: asIsoDateTime(raw.lastSyncAt)! } : {}), + ...(asTrimmedString(raw.lastError) ? { lastError: asTrimmedString(raw.lastError)! } : {}), + ...(asTrimmedString(raw.providerMessage) ? { providerMessage: asTrimmedString(raw.providerMessage)! } : {}), + ...(subscription && (subscription.planType || subscription.activeStart || subscription.activeUntil) + ? { subscription } + : {}), + windows: { fiveHour, sevenDay }, + ...(asIsoDateTime(raw.lastLimitResetAt) ? { lastLimitResetAt: asIsoDateTime(raw.lastLimitResetAt)! } : {}), + }; +} + +function buildStoredCodexSnapshot(oauth: Pick): OauthQuotaSnapshot { + const claims = parseCodexJwtClaims(oauth.idToken); + const authClaims = claims?.['https://api.openai.com/auth']; + const storedQuota = normalizeStoredQuotaSnapshot(oauth.quota); + const subscription = { + planType: asTrimmedString(authClaims?.chatgpt_plan_type) || oauth.planType, + activeStart: asIsoDateTime(authClaims?.chatgpt_subscription_active_start), + activeUntil: asIsoDateTime(authClaims?.chatgpt_subscription_active_until), + }; + + return { + status: storedQuota?.status || 'supported', + source: storedQuota?.source || 'reverse_engineered', + ...(storedQuota?.lastSyncAt ? { lastSyncAt: storedQuota.lastSyncAt } : {}), + ...(storedQuota?.lastError ? { lastError: storedQuota.lastError } : {}), + providerMessage: storedQuota?.providerMessage || 'current codex oauth signals do not expose stable 5h/7d remaining values', + ...((subscription.planType || subscription.activeStart || subscription.activeUntil) ? { subscription } : {}), + windows: storedQuota?.windows || buildCodexUnsupportedWindows(), + ...(storedQuota?.lastLimitResetAt ? { lastLimitResetAt: storedQuota.lastLimitResetAt } : {}), + }; +} + +export function buildQuotaSnapshotFromOauthInfo(oauth: Pick): OauthQuotaSnapshot { + if (oauth.provider === 'codex') { + return buildStoredCodexSnapshot(oauth); + } + return buildProviderUnsupportedSnapshot(oauth.provider); +} + +export function parseCodexQuotaResetHint( + statusCode: number, + errorBody: string | null | undefined, + nowMs = Date.now(), +): { resetAt: string; message: string } | null { + if (statusCode !== 429 || !errorBody) return null; + try { + const parsed = JSON.parse(errorBody) as Record; + const error = parsed?.error; + if (!error || typeof error !== 'object' || error.type !== 'usage_limit_reached') { + return null; + } + if (typeof error.resets_at === 'number' && Number.isFinite(error.resets_at) && error.resets_at > 0) { + return { + resetAt: new Date(error.resets_at * 1000).toISOString(), + message: 'codex usage_limit_reached reset hint observed from upstream', + }; + } + if (typeof error.resets_in_seconds === 'number' && Number.isFinite(error.resets_in_seconds) && error.resets_in_seconds > 0) { + return { + resetAt: new Date(nowMs + error.resets_in_seconds * 1000).toISOString(), + message: 'codex usage_limit_reached reset hint observed from upstream', + }; + } + } catch { + return null; + } + return null; +} + +async function persistQuotaSnapshot(accountId: number, snapshot: OauthQuotaSnapshot) { + const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get(); + if (!account) { + throw new Error('oauth account not found'); + } + const oauth = getOauthInfoFromExtraConfig(account.extraConfig); + if (!oauth) { + throw new Error('account is not managed by oauth'); + } + const nextExtraConfig = mergeAccountExtraConfig(account.extraConfig, { + oauth: buildOauthInfo(account.extraConfig, { + quota: snapshot, + }), + }); + await db.update(schema.accounts).set({ + extraConfig: nextExtraConfig, + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, accountId)).run(); + return snapshot; +} + +export async function refreshOauthQuotaSnapshot(accountId: number): Promise { + const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, accountId)).get(); + if (!account) { + throw new Error('oauth account not found'); + } + const oauth = getOauthInfoFromExtraConfig(account.extraConfig); + if (!oauth) { + throw new Error('account is not managed by oauth'); + } + const baseSnapshot = buildQuotaSnapshotFromOauthInfo(oauth); + const snapshot: OauthQuotaSnapshot = { + ...baseSnapshot, + lastSyncAt: new Date().toISOString(), + ...(baseSnapshot.status === 'error' ? {} : { lastError: undefined }), + }; + return persistQuotaSnapshot(accountId, snapshot); +} + +export async function recordOauthQuotaResetHint(input: { + accountId: number; + statusCode: number; + errorText?: string | null; +}): Promise { + const resetHint = parseCodexQuotaResetHint(input.statusCode, input.errorText); + if (!resetHint) return null; + + const account = await db.select().from(schema.accounts).where(eq(schema.accounts.id, input.accountId)).get(); + if (!account) return null; + const oauth = getOauthInfoFromExtraConfig(account.extraConfig); + if (!oauth || oauth.provider !== 'codex') return null; + + const baseSnapshot = buildQuotaSnapshotFromOauthInfo({ + ...oauth, + quota: { + ...normalizeStoredQuotaSnapshot(oauth.quota), + status: 'supported', + source: 'reverse_engineered', + lastLimitResetAt: resetHint.resetAt, + providerMessage: 'current codex oauth signals do not expose stable 5h/7d remaining values', + windows: normalizeStoredQuotaSnapshot(oauth.quota)?.windows || buildCodexUnsupportedWindows(), + }, + }); + + return persistQuotaSnapshot(input.accountId, { + ...baseSnapshot, + lastSyncAt: new Date().toISOString(), + lastLimitResetAt: resetHint.resetAt, + }); +} diff --git a/src/server/services/oauth/quotaTypes.ts b/src/server/services/oauth/quotaTypes.ts new file mode 100644 index 0000000..845a1e5 --- /dev/null +++ b/src/server/services/oauth/quotaTypes.ts @@ -0,0 +1,26 @@ +export type OauthQuotaWindowSnapshot = { + supported: boolean; + limit?: number | null; + used?: number | null; + remaining?: number | null; + resetAt?: string | null; + message?: string; +}; + +export type OauthQuotaSnapshot = { + status: 'supported' | 'unsupported' | 'error'; + source: 'official' | 'reverse_engineered'; + lastSyncAt?: string; + lastError?: string; + providerMessage?: string; + subscription?: { + planType?: string; + activeStart?: string; + activeUntil?: string; + }; + windows: { + fiveHour: OauthQuotaWindowSnapshot; + sevenDay: OauthQuotaWindowSnapshot; + }; + lastLimitResetAt?: string; +}; diff --git a/src/server/services/oauth/refreshSingleflight.ts b/src/server/services/oauth/refreshSingleflight.ts new file mode 100644 index 0000000..867c86f --- /dev/null +++ b/src/server/services/oauth/refreshSingleflight.ts @@ -0,0 +1,16 @@ +import { refreshOauthAccessToken } from './service.js'; + +const refreshInFlight = new Map>>>(); + +export async function refreshOauthAccessTokenSingleflight(accountId: number) { + const existing = refreshInFlight.get(accountId); + if (existing) { + return existing; + } + + const promise = refreshOauthAccessToken(accountId).finally(() => { + refreshInFlight.delete(accountId); + }); + refreshInFlight.set(accountId, promise); + return promise; +} diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts new file mode 100644 index 0000000..9a73d70 --- /dev/null +++ b/src/server/services/oauth/service.ts @@ -0,0 +1,655 @@ +import { and, desc, eq, inArray, isNull, or, sql } from 'drizzle-orm'; +import { db, schema } from '../../db/index.js'; +import { mergeAccountExtraConfig } from '../accountExtraConfig.js'; +import { refreshModelsForAccount, rebuildTokenRoutesFromAvailability } from '../modelService.js'; +import { + createOauthSession, + getOauthSession, + markOauthSessionError, + markOauthSessionSuccess, +} from './sessionStore.js'; +import { getOAuthLoopbackCallbackServerState } from './localCallbackServer.js'; +import { + getOAuthProviderDefinition, + listOAuthProviderDefinitions, + type OAuthProviderDefinition, +} from './providers.js'; +import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauthAccount.js'; +import { buildCodexOauthInfo } from './codexAccount.js'; +import { buildQuotaSnapshotFromOauthInfo, refreshOauthQuotaSnapshot } from './quota.js'; + +type OAuthProviderMetadata = ReturnType[number]; +const MANUAL_CALLBACK_DELAY_MS = 15_000; + +type OAuthStartInstructions = { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + sshTunnelCommand?: string; + sshTunnelKeyCommand?: string; +}; + +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return normalized === 'localhost' + || normalized === '127.0.0.1' + || normalized === '::1' + || normalized === '[::1]'; +} + +function resolveSshTunnelHost(requestOrigin?: string): string | undefined { + if (!requestOrigin) return undefined; + try { + const parsed = new URL(requestOrigin); + if (!parsed.hostname || isLoopbackHost(parsed.hostname)) { + return undefined; + } + return parsed.hostname; + } catch { + return undefined; + } +} + +function buildLoopbackInstructions( + definition: OAuthProviderDefinition, + requestOrigin?: string, +): OAuthStartInstructions { + const sshHost = resolveSshTunnelHost(requestOrigin); + return { + redirectUri: definition.loopback.redirectUri, + callbackPort: definition.loopback.port, + callbackPath: definition.loopback.path, + manualCallbackDelayMs: MANUAL_CALLBACK_DELAY_MS, + sshTunnelCommand: sshHost + ? `ssh -L ${definition.loopback.port}:127.0.0.1:${definition.loopback.port} root@${sshHost} -p 22` + : undefined, + sshTunnelKeyCommand: sshHost + ? `ssh -i -L ${definition.loopback.port}:127.0.0.1:${definition.loopback.port} root@${sshHost} -p 22` + : undefined, + }; +} + +function parseManualCallbackUrl(input: { + callbackUrl: string; + provider: string; +}) { + const raw = asNonEmptyString(input.callbackUrl); + if (!raw) { + throw new Error('invalid oauth callback url'); + } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error('invalid oauth callback url'); + } + + const state = asNonEmptyString(parsed.searchParams.get('state')); + const code = asNonEmptyString(parsed.searchParams.get('code')); + const error = asNonEmptyString(parsed.searchParams.get('error')); + const errorDescription = asNonEmptyString(parsed.searchParams.get('error_description')); + if (!state || (!code && !error)) { + throw new Error('invalid oauth callback url'); + } + + return { + state, + code, + error: error + ? (errorDescription ? `${error}: ${errorDescription}` : error) + : undefined, + }; +} + +function asNonEmptyString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function buildUsername(input: { + email?: string; + accountKey?: string; + provider: string; +}) { + return input.email || input.accountKey || `${input.provider}-user`; +} + +async function getNextAccountSortOrder(): Promise { + const row = await db.select({ + maxSortOrder: sql`COALESCE(MAX(${schema.accounts.sortOrder}), -1)`, + }).from(schema.accounts).get(); + return (row?.maxSortOrder ?? -1) + 1; +} + +async function getNextSiteSortOrder(): Promise { + const row = await db.select({ + maxSortOrder: sql`COALESCE(MAX(${schema.sites.sortOrder}), -1)`, + }).from(schema.sites).get(); + return (row?.maxSortOrder ?? -1) + 1; +} + +async function ensureOauthSite(definition: OAuthProviderDefinition) { + const existing = await db.select().from(schema.sites).where(and( + eq(schema.sites.platform, definition.site.platform), + eq(schema.sites.url, definition.site.url), + )).get(); + if (existing) return existing; + + return db.insert(schema.sites).values({ + name: definition.site.name, + url: definition.site.url, + platform: definition.site.platform, + status: 'active', + useSystemProxy: false, + isPinned: false, + globalWeight: 1, + sortOrder: await getNextSiteSortOrder(), + }).returning().get(); +} + +async function findExistingOauthAccount(input: { + provider: string; + accountKey?: string; + email?: string; + projectId?: string; + rebindAccountId?: number; +}) { + if (typeof input.rebindAccountId === 'number' && input.rebindAccountId > 0) { + return db.select().from(schema.accounts) + .where(eq(schema.accounts.id, input.rebindAccountId)) + .get(); + } + + const accountKey = asNonEmptyString(input.accountKey); + const email = asNonEmptyString(input.email); + const projectId = asNonEmptyString(input.projectId); + + if (accountKey) { + const byKey = await db.select().from(schema.accounts).where(and( + eq(schema.accounts.oauthProvider, input.provider), + eq(schema.accounts.oauthAccountKey, accountKey), + projectId + ? eq(schema.accounts.oauthProjectId, projectId) + : or(isNull(schema.accounts.oauthProjectId), eq(schema.accounts.oauthProjectId, '')), + )).get(); + if (byKey) return byKey; + } + + if (!accountKey && email) { + const byEmail = await db.select().from(schema.accounts).where(and( + eq(schema.accounts.oauthProvider, input.provider), + eq(schema.accounts.username, email), + )).get(); + if (byEmail) return byEmail; + } + + return null; +} + +async function upsertOauthAccount(input: { + definition: OAuthProviderDefinition; + exchange: { + accessToken: string; + refreshToken?: string; + tokenExpiresAt?: number; + email?: string; + accountKey?: string; + accountId?: string; + planType?: string; + projectId?: string; + idToken?: string; + providerData?: Record; + }; + rebindAccountId?: number; +}) { + const site = await ensureOauthSite(input.definition); + const existing = await findExistingOauthAccount({ + provider: input.definition.metadata.provider, + accountKey: input.exchange.accountKey || input.exchange.accountId, + email: input.exchange.email, + projectId: input.exchange.projectId, + rebindAccountId: input.rebindAccountId, + }); + const username = buildUsername({ + email: input.exchange.email, + accountKey: input.exchange.accountKey || input.exchange.accountId, + provider: input.definition.metadata.provider, + }); + const oauth = buildOauthInfo(existing?.extraConfig, { + provider: input.definition.metadata.provider, + accountId: input.exchange.accountId || input.exchange.accountKey, + accountKey: input.exchange.accountKey || input.exchange.accountId, + email: input.exchange.email, + planType: input.exchange.planType, + projectId: input.exchange.projectId, + refreshToken: input.exchange.refreshToken, + tokenExpiresAt: input.exchange.tokenExpiresAt, + idToken: input.exchange.idToken, + providerData: input.exchange.providerData, + }); + const extraConfig = mergeAccountExtraConfig(existing?.extraConfig, { + credentialMode: 'session', + oauth, + }); + + if (existing) { + await db.update(schema.accounts).set({ + siteId: site.id, + username, + accessToken: input.exchange.accessToken, + apiToken: null, + checkinEnabled: false, + status: 'active', + oauthProvider: input.definition.metadata.provider, + oauthAccountKey: oauth.accountKey || oauth.accountId || null, + oauthProjectId: oauth.projectId || null, + extraConfig, + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, existing.id)).run(); + return { + account: await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get(), + site, + created: false, + }; + } + + const created = await db.insert(schema.accounts).values({ + siteId: site.id, + username, + accessToken: input.exchange.accessToken, + apiToken: null, + checkinEnabled: false, + status: 'active', + oauthProvider: input.definition.metadata.provider, + oauthAccountKey: oauth.accountKey || oauth.accountId || null, + oauthProjectId: oauth.projectId || null, + extraConfig, + isPinned: false, + sortOrder: await getNextAccountSortOrder(), + }).returning().get(); + return { account: created, site, created: true }; +} + +export function listOauthProviders() { + return listOAuthProviderDefinitions().map((definition) => { + const state = getOAuthLoopbackCallbackServerState(definition.metadata.provider); + return { + ...definition.metadata, + enabled: state.ready || !state.attempted, + }; + }); +} + +export async function startOauthProviderFlow(input: { + provider: string; + rebindAccountId?: number; + projectId?: string; + requestOrigin?: string; +}) { + const definition = getOAuthProviderDefinition(input.provider); + if (!definition) { + throw new Error(`unsupported oauth provider: ${input.provider}`); + } + const redirectUri = definition.loopback.redirectUri; + const callbackServerState = getOAuthLoopbackCallbackServerState(input.provider); + if (callbackServerState.attempted && !callbackServerState.ready) { + throw new Error(`${input.provider} oauth callback listener is unavailable: ${callbackServerState.error || 'unknown error'}`); + } + const session = createOauthSession({ + provider: input.provider, + redirectUri, + rebindAccountId: input.rebindAccountId, + projectId: input.projectId, + }); + return { + provider: input.provider, + state: session.state, + authorizationUrl: await definition.buildAuthorizationUrl({ + state: session.state, + redirectUri: session.redirectUri, + codeVerifier: session.codeVerifier, + projectId: session.projectId, + }), + instructions: buildLoopbackInstructions(definition, input.requestOrigin), + }; +} + +export function getOauthSessionStatus(state: string) { + const session = getOauthSession(state); + if (!session) return null; + return { + provider: session.provider, + state: session.state, + status: session.status, + accountId: session.accountId, + siteId: session.siteId, + error: session.error, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }; +} + +export async function handleOauthCallback(input: { + provider: string; + state: string; + code?: string; + error?: string; +}) { + const session = getOauthSession(input.state); + if (!session || session.provider !== input.provider) { + throw new Error('oauth session not found or provider mismatch'); + } + const definition = getOAuthProviderDefinition(input.provider); + if (!definition) { + markOauthSessionError(input.state, `unsupported oauth provider: ${input.provider}`); + throw new Error(`unsupported oauth provider: ${input.provider}`); + } + if (input.error) { + markOauthSessionError(input.state, input.error); + throw new Error(input.error); + } + const code = asNonEmptyString(input.code); + if (!code) { + markOauthSessionError(input.state, 'missing oauth code'); + throw new Error('missing oauth code'); + } + + try { + const exchange = await definition.exchangeAuthorizationCode({ + code, + state: input.state, + redirectUri: session.redirectUri, + codeVerifier: session.codeVerifier, + projectId: session.projectId, + }); + const { account, site, created } = await upsertOauthAccount({ + definition, + exchange, + rebindAccountId: session.rebindAccountId, + }); + if (!account) { + markOauthSessionError(input.state, 'failed to persist oauth account'); + throw new Error('failed to persist oauth account'); + } + + const refreshResult = await refreshModelsForAccount(account.id); + if (refreshResult.status !== 'success') { + if (created) { + await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run(); + } + await rebuildTokenRoutesFromAvailability(); + const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`; + markOauthSessionError(input.state, errorMessage); + throw new Error(errorMessage); + } + + await rebuildTokenRoutesFromAvailability(); + markOauthSessionSuccess(input.state, { + accountId: account.id, + siteId: site.id, + }); + return { accountId: account.id, siteId: site.id }; + } catch (error) { + const message = ( + error instanceof Error + ? (error.message || error.name) + : String(error || 'OAuth failed') + ).trim() || 'OAuth failed'; + markOauthSessionError(input.state, message); + throw error; + } +} + +export async function submitOauthManualCallback(input: { + state: string; + callbackUrl: string; +}) { + const session = getOauthSession(input.state); + if (!session) { + throw new Error('oauth session not found'); + } + const parsed = parseManualCallbackUrl({ + callbackUrl: input.callbackUrl, + provider: session.provider, + }); + if (parsed.state !== input.state) { + throw new Error('oauth callback state mismatch'); + } + + await handleOauthCallback({ + provider: session.provider, + state: parsed.state, + code: parsed.code, + error: parsed.error, + }); + + return { success: true }; +} + +export async function listOauthConnections(options: { + limit?: number; + offset?: number; +} = {}) { + const limit = Math.max(1, Math.min(200, Math.trunc(options.limit ?? 50))); + const offset = Math.max(0, Math.trunc(options.offset ?? 0)); + + const totalRow = await db.select({ + count: sql`COUNT(*)`, + }).from(schema.accounts) + .where(sql`${schema.accounts.oauthProvider} IS NOT NULL`) + .get(); + const total = totalRow?.count ?? 0; + + const rows = await db.select().from(schema.accounts) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(sql`${schema.accounts.oauthProvider} IS NOT NULL`) + .orderBy(desc(schema.accounts.id)) + .limit(limit) + .offset(offset) + .all(); + + const accountIds = rows.map((row) => row.accounts.id); + if (accountIds.length <= 0) { + return { items: [], total, limit, offset }; + } + + const modelRows = await db.select({ + accountId: schema.modelAvailability.accountId, + modelName: schema.modelAvailability.modelName, + }).from(schema.modelAvailability) + .where(and( + inArray(schema.modelAvailability.accountId, accountIds), + eq(schema.modelAvailability.available, true), + )) + .all(); + const modelMap = new Map(); + for (const row of modelRows) { + if (typeof row.accountId !== 'number') continue; + const list = modelMap.get(row.accountId) || []; + list.push(row.modelName); + modelMap.set(row.accountId, list); + } + + const routeChannelRows = await db.select({ + accountId: schema.routeChannels.accountId, + count: sql`COUNT(*)`, + }).from(schema.routeChannels) + .where(inArray(schema.routeChannels.accountId, accountIds)) + .groupBy(schema.routeChannels.accountId) + .all(); + const routeChannelCountByAccount = new Map(); + for (const row of routeChannelRows) { + routeChannelCountByAccount.set(row.accountId, row.count ?? 0); + } + + const items = rows.map((row) => { + const oauth = getOauthInfoFromExtraConfig(row.accounts.extraConfig)!; + const models = modelMap.get(row.accounts.id) || []; + const status = ( + oauth.modelDiscoveryStatus === 'abnormal' + || row.accounts.status !== 'active' + || row.sites.status !== 'active' + ) ? 'abnormal' : 'healthy'; + return { + accountId: row.accounts.id, + siteId: row.sites.id, + provider: oauth.provider, + username: row.accounts.username, + email: oauth.email, + accountKey: oauth.accountKey || oauth.accountId, + planType: oauth.planType, + projectId: oauth.projectId, + modelCount: models.length, + modelsPreview: models.slice(0, 10), + quota: buildQuotaSnapshotFromOauthInfo(oauth), + status, + routeChannelCount: routeChannelCountByAccount.get(row.accounts.id) || 0, + lastModelSyncAt: oauth.lastModelSyncAt, + lastModelSyncError: oauth.lastModelSyncError, + site: { + id: row.sites.id, + name: row.sites.name, + url: row.sites.url, + platform: row.sites.platform, + }, + }; + }); + + return { items, total, limit, offset }; +} + +export async function deleteOauthConnection(accountId: number) { + const account = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, accountId)) + .get(); + if (!account) { + throw new Error('oauth account not found'); + } + const oauth = getOauthInfoFromExtraConfig(account.extraConfig); + if (!oauth) { + throw new Error('account is not managed by oauth'); + } + await db.delete(schema.accounts).where(eq(schema.accounts.id, accountId)).run(); + await rebuildTokenRoutesFromAvailability(); + return { success: true }; +} + +export async function refreshOauthConnectionQuota(accountId: number) { + const quota = await refreshOauthQuotaSnapshot(accountId); + return { success: true, quota }; +} + +export async function startOauthRebindFlow(accountId: number, requestOrigin?: string) { + const account = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, accountId)) + .get(); + if (!account) { + throw new Error('oauth account not found'); + } + const oauth = getOauthInfoFromExtraConfig(account.extraConfig); + if (!oauth) { + throw new Error('account is not managed by oauth'); + } + return startOauthProviderFlow({ + provider: oauth.provider, + rebindAccountId: accountId, + projectId: oauth.projectId, + requestOrigin, + }); +} + +export function buildOauthProviderHeaders(input: { + extraConfig?: string | null; + downstreamHeaders?: Record; +}) { + const oauth = getOauthInfoFromExtraConfig(input.extraConfig); + if (!oauth) return {}; + const definition = getOAuthProviderDefinition(oauth.provider); + if (!definition?.buildProxyHeaders) return {}; + return definition.buildProxyHeaders({ + oauth, + downstreamHeaders: input.downstreamHeaders, + }); +} + +export function buildCodexOauthProviderHeaders(input: { + extraConfig?: string | null; + downstreamHeaders?: Record; +}) { + const oauth = buildCodexOauthInfo(input.extraConfig); + const definition = getOAuthProviderDefinition('codex'); + return definition?.buildProxyHeaders?.({ + oauth, + downstreamHeaders: input.downstreamHeaders, + }) || {}; +} + +export async function refreshOauthAccessToken(accountId: number) { + const account = await db.select().from(schema.accounts) + .where(eq(schema.accounts.id, accountId)) + .get(); + if (!account) { + throw new Error('oauth account not found'); + } + const oauth = getOauthInfoFromExtraConfig(account.extraConfig); + if (!oauth?.refreshToken) { + throw new Error('oauth refresh token missing'); + } + const definition = getOAuthProviderDefinition(oauth.provider); + if (!definition) { + throw new Error(`unsupported oauth provider: ${oauth.provider}`); + } + + const refreshed = await definition.refreshAccessToken({ + refreshToken: oauth.refreshToken, + oauth: { + projectId: oauth.projectId, + providerData: oauth.providerData, + }, + }); + const nextOauth = buildOauthInfo(account.extraConfig, { + provider: oauth.provider, + accountId: refreshed.accountId || oauth.accountId, + accountKey: refreshed.accountKey || oauth.accountKey || refreshed.accountId || oauth.accountId, + email: refreshed.email || oauth.email, + planType: refreshed.planType || oauth.planType, + projectId: refreshed.projectId || oauth.projectId, + refreshToken: refreshed.refreshToken || oauth.refreshToken, + tokenExpiresAt: refreshed.tokenExpiresAt || oauth.tokenExpiresAt, + idToken: refreshed.idToken || oauth.idToken, + providerData: { + ...(oauth.providerData || {}), + ...(refreshed.providerData || {}), + }, + }); + const extraConfig = mergeAccountExtraConfig(account.extraConfig, { + credentialMode: 'session', + oauth: nextOauth, + }); + + await db.update(schema.accounts).set({ + accessToken: refreshed.accessToken, + oauthProvider: oauth.provider, + oauthAccountKey: nextOauth.accountKey || nextOauth.accountId || null, + oauthProjectId: nextOauth.projectId || null, + extraConfig, + status: 'active', + updatedAt: new Date().toISOString(), + }).where(eq(schema.accounts.id, accountId)).run(); + + return { + accountId, + accessToken: refreshed.accessToken, + accountKey: nextOauth.accountKey || nextOauth.accountId, + extraConfig, + }; +} + +export async function refreshCodexOauthAccessToken(accountId: number) { + return refreshOauthAccessToken(accountId); +} + +export type { OAuthProviderMetadata }; diff --git a/src/server/services/oauth/sessionStore.ts b/src/server/services/oauth/sessionStore.ts new file mode 100644 index 0000000..6cd1257 --- /dev/null +++ b/src/server/services/oauth/sessionStore.ts @@ -0,0 +1,152 @@ +import { randomBytes, webcrypto } from 'node:crypto'; + +export type OAuthSessionStatus = 'pending' | 'success' | 'error'; + +export type OAuthSessionRecord = { + provider: string; + state: string; + status: OAuthSessionStatus; + codeVerifier: string; + redirectUri: string; + createdAt: string; + updatedAt: string; + expiresAt: string; + accountId?: number; + siteId?: number; + error?: string; + rebindAccountId?: number; + projectId?: string; +}; + +export interface OAuthSessionStore { + create(input: { + provider: string; + redirectUri: string; + rebindAccountId?: number; + projectId?: string; + }): OAuthSessionRecord; + get(state: string): OAuthSessionRecord | null; + markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null; + markError(state: string, error: string): OAuthSessionRecord | null; +} + +const SESSION_TTL_MS = 10 * 60 * 1000; + +function nowIso(): string { + return new Date().toISOString(); +} + +function toBase64Url(input: Buffer): string { + return input.toString('base64url'); +} + +function createPkceVerifier(): string { + return toBase64Url(randomBytes(48)); +} + +export async function createPkceChallenge(codeVerifier: string): Promise { + const digest = await webcrypto.subtle.digest('SHA-256', Buffer.from(codeVerifier, 'utf8')); + return Buffer.from(digest).toString('base64url'); +} + +class MemoryOAuthSessionStore implements OAuthSessionStore { + private readonly sessions = new Map(); + + private pruneExpiredSessions(nowMs = Date.now()) { + for (const [state, session] of this.sessions.entries()) { + if (Date.parse(session.expiresAt) <= nowMs) { + this.sessions.delete(state); + } + } + } + + create(input: { + provider: string; + redirectUri: string; + rebindAccountId?: number; + projectId?: string; + }): OAuthSessionRecord { + this.pruneExpiredSessions(); + const state = toBase64Url(randomBytes(24)); + const codeVerifier = createPkceVerifier(); + const createdAt = nowIso(); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString(); + const record: OAuthSessionRecord = { + provider: input.provider, + state, + status: 'pending', + codeVerifier, + redirectUri: input.redirectUri, + createdAt, + updatedAt: createdAt, + expiresAt, + rebindAccountId: input.rebindAccountId, + projectId: input.projectId, + }; + this.sessions.set(state, record); + return record; + } + + get(state: string): OAuthSessionRecord | null { + this.pruneExpiredSessions(); + return this.sessions.get(state) || null; + } + + markSuccess(state: string, patch: { accountId: number; siteId: number }): OAuthSessionRecord | null { + const existing = this.get(state); + if (!existing) return null; + const next: OAuthSessionRecord = { + ...existing, + status: 'success', + updatedAt: nowIso(), + accountId: patch.accountId, + siteId: patch.siteId, + error: undefined, + }; + this.sessions.set(state, next); + return next; + } + + markError(state: string, error: string): OAuthSessionRecord | null { + const existing = this.get(state); + if (!existing) return null; + const next: OAuthSessionRecord = { + ...existing, + status: 'error', + updatedAt: nowIso(), + error: error.trim() || 'OAuth failed', + }; + this.sessions.set(state, next); + return next; + } +} + +let oauthSessionStore: OAuthSessionStore = new MemoryOAuthSessionStore(); + +export function setOauthSessionStore(store: OAuthSessionStore) { + oauthSessionStore = store; +} + +export function createOauthSession(input: { + provider: string; + redirectUri: string; + rebindAccountId?: number; + projectId?: string; +}): OAuthSessionRecord { + return oauthSessionStore.create(input); +} + +export function getOauthSession(state: string): OAuthSessionRecord | null { + return oauthSessionStore.get(state); +} + +export function markOauthSessionSuccess( + state: string, + patch: { accountId: number; siteId: number }, +): OAuthSessionRecord | null { + return oauthSessionStore.markSuccess(state, patch); +} + +export function markOauthSessionError(state: string, error: string): OAuthSessionRecord | null { + return oauthSessionStore.markError(state, error); +} diff --git a/src/server/services/platforms/antigravity.ts b/src/server/services/platforms/antigravity.ts new file mode 100644 index 0000000..7198b3e --- /dev/null +++ b/src/server/services/platforms/antigravity.ts @@ -0,0 +1,79 @@ +import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; +import { + ANTIGRAVITY_CLIENT_METADATA, + ANTIGRAVITY_GOOGLE_API_CLIENT, + ANTIGRAVITY_UPSTREAM_BASE_URL, + ANTIGRAVITY_USER_AGENT, +} from '../oauth/antigravityProvider.js'; + +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function extractAntigravityModelNames(payload: unknown): string[] { + if (!payload || typeof payload !== 'object') return []; + const record = payload as { models?: unknown }; + if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) { + return Object.keys(record.models).map((name) => name.trim()).filter(Boolean); + } + if (Array.isArray(record.models)) { + return record.models.flatMap((item) => { + if (typeof item === 'string') return item.trim() ? [item.trim()] : []; + if (!item || typeof item !== 'object') return []; + const id = typeof (item as { id?: unknown }).id === 'string' + ? (item as { id: string }).id.trim() + : (typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name.trim() + : ''); + return id ? [id] : []; + }); + } + return []; +} + +export class AntigravityAdapter extends BasePlatformAdapter { + readonly platformName = 'antigravity'; + + async detect(url: string): Promise { + const normalized = (url || '').toLowerCase(); + return normalized.includes('antigravity'); + } + + override async login(_baseUrl: string, _username: string, _password: string) { + return { success: false, message: 'login endpoint not supported' }; + } + + override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { + return null; + } + + async checkin(_baseUrl: string, _accessToken: string): Promise { + return { success: false, message: 'checkin endpoint not supported' }; + } + + async getBalance(_baseUrl: string, _accessToken: string): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + async getModels(baseUrl: string, accessToken: string): Promise { + try { + const payload = await this.fetchJson<{ models?: unknown }>( + `${normalizeBaseUrl(baseUrl || ANTIGRAVITY_UPSTREAM_BASE_URL)}/v1internal:fetchAvailableModels`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'User-Agent': ANTIGRAVITY_USER_AGENT, + 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, + 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, + }, + body: JSON.stringify({}), + }, + ); + return extractAntigravityModelNames(payload); + } catch { + return []; + } + } +} diff --git a/src/server/services/platforms/codex.ts b/src/server/services/platforms/codex.ts new file mode 100644 index 0000000..60bcc25 --- /dev/null +++ b/src/server/services/platforms/codex.ts @@ -0,0 +1,38 @@ +import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; + +function normalizeBaseUrl(baseUrl: string): string { + let normalized = (baseUrl || '').trim(); + while (normalized.endsWith('/')) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +export class CodexAdapter extends BasePlatformAdapter { + readonly platformName = 'codex'; + + async detect(url: string): Promise { + const normalized = normalizeBaseUrl(url).toLowerCase(); + return normalized.includes('chatgpt.com/backend-api/codex'); + } + + override async login(_baseUrl: string, _username: string, _password: string) { + return { success: false as const, message: 'codex oauth login is managed via OAuth flow' }; + } + + override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { + return null; + } + + async checkin(_baseUrl: string, _accessToken: string): Promise { + return { success: false, message: 'codex oauth connections do not support checkin' }; + } + + async getBalance(_baseUrl: string, _accessToken: string): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + async getModels(_baseUrl: string, _token: string): Promise { + return []; + } +} diff --git a/src/server/services/platforms/gemini.ts b/src/server/services/platforms/gemini.ts index 1a5e283..f1315e9 100644 --- a/src/server/services/platforms/gemini.ts +++ b/src/server/services/platforms/gemini.ts @@ -42,7 +42,7 @@ function resolveGeminiNativeModelsUrl(baseUrl: string, apiToken: string): string } export class GeminiAdapter extends BasePlatformAdapter { - readonly platformName = 'gemini'; + readonly platformName: string = 'gemini'; async detect(url: string): Promise { const normalized = (url || '').toLowerCase(); diff --git a/src/server/services/platforms/geminiCli.ts b/src/server/services/platforms/geminiCli.ts new file mode 100644 index 0000000..e8c4224 --- /dev/null +++ b/src/server/services/platforms/geminiCli.ts @@ -0,0 +1,10 @@ +import { GeminiAdapter } from './gemini.js'; + +export class GeminiCliAdapter extends GeminiAdapter { + override readonly platformName = 'gemini-cli'; + + override async detect(url: string): Promise { + const normalized = (url || '').toLowerCase(); + return normalized.includes('cloudcode-pa.googleapis.com'); + } +} diff --git a/src/server/services/platforms/index.test.ts b/src/server/services/platforms/index.test.ts index 12b025b..e726cd4 100644 --- a/src/server/services/platforms/index.test.ts +++ b/src/server/services/platforms/index.test.ts @@ -7,19 +7,34 @@ async function withHttpServer( handler: (req: IncomingMessage, res: ServerResponse) => void, run: (baseUrl: string) => Promise, ) { - const server = createServer(handler); - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => resolve()); - }); - const { port } = server.address() as AddressInfo; - const baseUrl = `http://127.0.0.1:${port}`; - try { - await run(baseUrl); - } finally { - await new Promise((resolve, reject) => { - server.close((err?: Error) => (err ? reject(err) : resolve())); + // Avoid flakiness: CPA uses port 8317 by convention, and our platform detection + // includes a fast-path for localhost:8317. Random ephemeral ports can collide. + for (let attempt = 0; attempt < 5; attempt += 1) { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); }); + + const { port } = server.address() as AddressInfo; + if (port === 8317) { + await new Promise((resolve, reject) => { + server.close((err?: Error) => (err ? reject(err) : resolve())); + }); + continue; + } + + const baseUrl = `http://127.0.0.1:${port}`; + try { + await run(baseUrl); + return; + } finally { + await new Promise((resolve, reject) => { + server.close((err?: Error) => (err ? reject(err) : resolve())); + }); + } } + + throw new Error('withHttpServer: unable to allocate a test port that avoids 8317'); } describe('getAdapter platform aliases', () => { @@ -43,6 +58,16 @@ describe('getAdapter platform aliases', () => { expect(getAdapter('gemini')?.platformName).toBe('gemini'); }); + it('supports antigravity adapter aliases', () => { + expect(getAdapter('antigravity')?.platformName).toBe('antigravity'); + expect(getAdapter('anti-gravity')?.platformName).toBe('antigravity'); + }); + + it('supports dedicated codex adapter and aliases', () => { + expect(getAdapter('codex')?.platformName).toBe('codex'); + expect(getAdapter('chatgpt-codex')?.platformName).toBe('codex'); + }); + it('detects anyrouter URL before generic new-api adapter', async () => { const adapter = await detectPlatform('https://anyrouter.top'); expect(adapter?.platformName).toBe('anyrouter'); diff --git a/src/server/services/platforms/index.ts b/src/server/services/platforms/index.ts index 4829dac..1fc4333 100644 --- a/src/server/services/platforms/index.ts +++ b/src/server/services/platforms/index.ts @@ -7,16 +7,22 @@ import { OneHubAdapter } from './oneHub.js'; import { DoneHubAdapter } from './doneHub.js'; import { Sub2ApiAdapter } from './sub2api.js'; import { OpenAiAdapter } from './openai.js'; +import { CodexAdapter } from './codex.js'; import { ClaudeAdapter } from './claude.js'; import { GeminiAdapter } from './gemini.js'; +import { GeminiCliAdapter } from './geminiCli.js'; +import { AntigravityAdapter } from './antigravity.js'; import { CliProxyApiAdapter } from './cliproxyapi.js'; import { detectPlatformByTitle } from './titleHint.js'; const adapters: PlatformAdapter[] = [ // Specific forks before generic adapters for better auto-detection. new OpenAiAdapter(), + new CodexAdapter(), new ClaudeAdapter(), new GeminiAdapter(), + new GeminiCliAdapter(), + new AntigravityAdapter(), new CliProxyApiAdapter(), new AnyRouterAdapter(), new DoneHubAdapter(), @@ -49,9 +55,15 @@ const platformAliases: Record = { sub2api: 'sub2api', // Official upstream APIs openai: 'openai', + codex: 'codex', + 'chatgpt-codex': 'codex', + 'chatgpt codex': 'codex', anthropic: 'claude', claude: 'claude', gemini: 'gemini', + 'gemini-cli': 'gemini-cli', + antigravity: 'antigravity', + 'anti-gravity': 'antigravity', google: 'gemini', // CLIProxyAPI aliases cliproxyapi: 'cliproxyapi', @@ -83,6 +95,7 @@ function detectPlatformByUrlHint(url: string): string | undefined { // Official upstream endpoints. if (normalized.includes('api.openai.com')) return 'openai'; + if (normalized.includes('chatgpt.com/backend-api/codex')) return 'codex'; if (normalized.includes('api.anthropic.com') || normalized.includes('anthropic.com/v1')) return 'claude'; if ( normalized.includes('generativelanguage.googleapis.com') @@ -91,6 +104,7 @@ function detectPlatformByUrlHint(url: string): string | undefined { ) { return 'gemini'; } + if (normalized.includes('cloudcode-pa.googleapis.com')) return 'gemini-cli'; // NewAPI-family forks and common aliases. if (normalized.includes('anyrouter')) return 'anyrouter'; diff --git a/src/server/services/platforms/titleHint.ts b/src/server/services/platforms/titleHint.ts index 57fe3de..35ee023 100644 --- a/src/server/services/platforms/titleHint.ts +++ b/src/server/services/platforms/titleHint.ts @@ -44,10 +44,7 @@ function extractHtmlTitle(html: string): string { return match[1].replace(/\s+/g, ' ').trim(); } -export async function detectPlatformByTitle(url: string): Promise { - const base = normalizeBaseUrl(url); - if (!base) return undefined; - +async function detectPlatformByTitleOnce(base: string): Promise { try { const { fetch } = await import('undici'); const res = await fetch(`${base}/`, { @@ -71,3 +68,16 @@ export async function detectPlatformByTitle(url: string): Promise { + const base = normalizeBaseUrl(url); + if (!base) return undefined; + + const first = await detectPlatformByTitleOnce(base); + if (first) return first; + + // Under heavy parallel test load, local title probes can occasionally race + // with just-started ephemeral HTTP servers. Retry once before giving up. + await new Promise((resolve) => setTimeout(resolve, 50)); + return detectPlatformByTitleOnce(base); +} diff --git a/src/server/services/proxyLogStore.test.ts b/src/server/services/proxyLogStore.test.ts index 0f57c6c..851299d 100644 --- a/src/server/services/proxyLogStore.test.ts +++ b/src/server/services/proxyLogStore.test.ts @@ -2,12 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { hasProxyLogBillingDetailsColumnMock, + hasProxyLogDownstreamApiKeyIdColumnMock, dbInsertMock, dbInsertValuesMock, dbInsertRunMock, proxyLogsSchema, } = vi.hoisted(() => ({ hasProxyLogBillingDetailsColumnMock: vi.fn(), + hasProxyLogDownstreamApiKeyIdColumnMock: vi.fn(), dbInsertMock: vi.fn(), dbInsertValuesMock: vi.fn(), dbInsertRunMock: vi.fn(), @@ -40,6 +42,7 @@ vi.mock('../db/index.js', () => ({ proxyLogs: proxyLogsSchema, }, hasProxyLogBillingDetailsColumn: (...args: unknown[]) => hasProxyLogBillingDetailsColumnMock(...args), + hasProxyLogDownstreamApiKeyIdColumn: (...args: unknown[]) => hasProxyLogDownstreamApiKeyIdColumnMock(...args), })); import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js'; @@ -47,9 +50,12 @@ import { insertProxyLog, withProxyLogSelectFields } from './proxyLogStore.js'; describe('proxyLogStore', () => { beforeEach(() => { hasProxyLogBillingDetailsColumnMock.mockReset(); + hasProxyLogDownstreamApiKeyIdColumnMock.mockReset(); dbInsertMock.mockReset(); dbInsertValuesMock.mockReset(); dbInsertRunMock.mockReset(); + hasProxyLogBillingDetailsColumnMock.mockResolvedValue(false); + hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(false); dbInsertMock.mockReturnValue({ values: (...args: unknown[]) => dbInsertValuesMock(...args), @@ -95,4 +101,35 @@ describe('proxyLogStore', () => { }); expect(dbInsertValuesMock.mock.calls[1][0].billingDetails).toBeUndefined(); }); + + it('falls back to base values when both billing details and downstream key columns are missing', async () => { + hasProxyLogBillingDetailsColumnMock.mockResolvedValue(true); + hasProxyLogDownstreamApiKeyIdColumnMock.mockResolvedValue(true); + dbInsertRunMock + .mockRejectedValueOnce(new Error('column proxy_logs.billing_details does not exist')) + .mockRejectedValueOnce(new Error('column proxy_logs.downstream_api_key_id does not exist')) + .mockResolvedValueOnce(undefined); + + await insertProxyLog({ + modelRequested: 'gpt-5', + billingDetails: { total: 1 }, + downstreamApiKeyId: 12, + }); + + expect(dbInsertValuesMock).toHaveBeenCalledTimes(3); + expect(dbInsertValuesMock.mock.calls[0][0]).toMatchObject({ + modelRequested: 'gpt-5', + billingDetails: JSON.stringify({ total: 1 }), + downstreamApiKeyId: 12, + }); + expect(dbInsertValuesMock.mock.calls[1][0]).toMatchObject({ + modelRequested: 'gpt-5', + downstreamApiKeyId: 12, + }); + expect(dbInsertValuesMock.mock.calls[2][0]).toMatchObject({ + modelRequested: 'gpt-5', + }); + expect(dbInsertValuesMock.mock.calls[2][0].billingDetails).toBeUndefined(); + expect(dbInsertValuesMock.mock.calls[2][0].downstreamApiKeyId).toBeUndefined(); + }); }); diff --git a/src/server/services/proxyLogStore.ts b/src/server/services/proxyLogStore.ts index 1e3b4ca..b03f736 100644 --- a/src/server/services/proxyLogStore.ts +++ b/src/server/services/proxyLogStore.ts @@ -1,9 +1,10 @@ -import { db, schema, hasProxyLogBillingDetailsColumn } from '../db/index.js'; +import { db, schema, hasProxyLogBillingDetailsColumn, hasProxyLogDownstreamApiKeyIdColumn } from '../db/index.js'; export type ProxyLogInsertInput = { routeId?: number | null; channelId?: number | null; accountId?: number | null; + downstreamApiKeyId?: number | null; modelRequested?: string | null; modelActual?: string | null; status?: string | null; @@ -25,6 +26,7 @@ function buildProxyLogBaseSelectFields() { routeId: schema.proxyLogs.routeId, channelId: schema.proxyLogs.channelId, accountId: schema.proxyLogs.accountId, + downstreamApiKeyId: schema.proxyLogs.downstreamApiKeyId, modelRequested: schema.proxyLogs.modelRequested, modelActual: schema.proxyLogs.modelActual, status: schema.proxyLogs.status, @@ -108,6 +110,20 @@ export function isMissingBillingDetailsColumnError(error: unknown): boolean { ); } +export function isMissingDownstreamApiKeyIdColumnError(error: unknown): boolean { + const message = typeof error === 'object' && error && 'message' in error + ? String((error as { message?: unknown }).message || '') + : String(error || ''); + const lowered = message.toLowerCase(); + return lowered.includes('downstream_api_key_id') + && ( + lowered.includes('does not exist') + || lowered.includes('unknown column') + || lowered.includes('no such column') + || lowered.includes('has no column named') + ); +} + export async function insertProxyLog(input: ProxyLogInsertInput): Promise { const baseValues = { routeId: input.routeId ?? null, @@ -131,16 +147,43 @@ export async function insertProxyLog(input: ProxyLogInsertInput): Promise : JSON.stringify(input.billingDetails); const includeBillingDetails = serializedBillingDetails !== null && await hasProxyLogBillingDetailsColumn(); + const includeDownstreamApiKeyId = input.downstreamApiKeyId != null + && await hasProxyLogDownstreamApiKeyIdColumn(); try { await db.insert(schema.proxyLogs).values( - includeBillingDetails - ? { ...baseValues, billingDetails: serializedBillingDetails } + includeBillingDetails || includeDownstreamApiKeyId + ? { + ...baseValues, + ...(includeBillingDetails ? { billingDetails: serializedBillingDetails } : {}), + ...(includeDownstreamApiKeyId ? { downstreamApiKeyId: input.downstreamApiKeyId } : {}), + } : baseValues, ).run(); } catch (error) { if (includeBillingDetails && isMissingBillingDetailsColumnError(error)) { - await db.insert(schema.proxyLogs).values(baseValues).run(); + try { + await db.insert(schema.proxyLogs).values( + includeDownstreamApiKeyId + ? { ...baseValues, downstreamApiKeyId: input.downstreamApiKeyId } + : baseValues, + ).run(); + } catch (fallbackError) { + if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(fallbackError)) { + await db.insert(schema.proxyLogs).values(baseValues).run(); + return; + } + throw fallbackError; + } + return; + } + + if (includeDownstreamApiKeyId && isMissingDownstreamApiKeyIdColumnError(error)) { + await db.insert(schema.proxyLogs).values( + includeBillingDetails + ? { ...baseValues, billingDetails: serializedBillingDetails } + : baseValues, + ).run(); return; } throw error; diff --git a/src/server/services/tokenRouter.patterns.test.ts b/src/server/services/tokenRouter.patterns.test.ts index e5f1e2c..8f753fa 100644 --- a/src/server/services/tokenRouter.patterns.test.ts +++ b/src/server/services/tokenRouter.patterns.test.ts @@ -154,4 +154,31 @@ describe('TokenRouter patterns and model mapping', () => { expect(decision.actualModel).toBe('claude-opus-4-5'); expect(exposedModels).toContain('claude-opus-4-6'); }); + + it('prefers an exact route over a colliding group display-name alias', async () => { + await createRouteWithSingleChannel( + 're:^claude-(opus|sonnet)-4-5$', + undefined, + { + displayName: 'claude-opus-4-6', + sourceModel: 'claude-opus-4-5', + }, + ); + const exact = await createRouteWithSingleChannel( + 'claude-opus-4-6', + undefined, + { + sourceModel: 'claude-opus-4-6', + }, + ); + const router = new TokenRouter(); + + const selected = await router.selectChannel('claude-opus-4-6'); + const decision = await router.explainSelection('claude-opus-4-6'); + + expect(selected).toBeTruthy(); + expect(selected?.channel.id).toBe(exact.channel.id); + expect(selected?.actualModel).toBe('claude-opus-4-6'); + expect(decision.actualModel).toBe('claude-opus-4-6'); + }); }); diff --git a/src/server/services/tokenRouter.siteStatus.test.ts b/src/server/services/tokenRouter.siteStatus.test.ts index 3b07506..0784b3b 100644 --- a/src/server/services/tokenRouter.siteStatus.test.ts +++ b/src/server/services/tokenRouter.siteStatus.test.ts @@ -119,4 +119,49 @@ describe('TokenRouter site status guard', () => { const selected = await new TokenRouter().selectChannel('gpt-4.1-mini'); expect(selected).toBeNull(); }); + + it('uses codex oauth access token when no api token is present', async () => { + const site = await db.insert(schema.sites).values({ + name: 'codex-site', + url: 'https://chatgpt.com/backend-api/codex', + platform: 'codex', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'codex-user@example.com', + accessToken: 'oauth-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'codex', + accountId: 'chatgpt-account-123', + email: 'codex-user@example.com', + planType: 'plus', + }, + }), + }).returning().get(); + + const route = await db.insert(schema.tokenRoutes).values({ + modelPattern: 'gpt-5.2-codex', + enabled: true, + }).returning().get(); + + await db.insert(schema.routeChannels).values({ + routeId: route.id, + accountId: account.id, + tokenId: null, + priority: 0, + weight: 10, + enabled: true, + manualOverride: false, + }).run(); + + const selected = await new TokenRouter().selectChannel('gpt-5.2-codex'); + expect(selected).not.toBeNull(); + expect(selected?.tokenValue).toBe('oauth-access-token'); + }); }); diff --git a/src/server/services/tokenRouter.test.ts b/src/server/services/tokenRouter.test.ts index 0c92742..1784d9a 100644 --- a/src/server/services/tokenRouter.test.ts +++ b/src/server/services/tokenRouter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { filterRecentlyFailedCandidates, isChannelRecentlyFailed } from './tokenRouter.js'; +import { filterRecentlyFailedCandidates, isChannelRecentlyFailed, matchesModelPattern, parseRegexModelPattern } from './tokenRouter.js'; type Candidate = { channel: { @@ -10,6 +10,11 @@ type Candidate = { }; describe('filterRecentlyFailedCandidates', () => { + it('rejects unsafe nested-quantifier regex route patterns', () => { + expect(parseRegexModelPattern('re:^(a+)+$')).toBeNull(); + expect(matchesModelPattern('aaaa', 're:^(a+)+$')).toBe(false); + }); + it('uses a short default recent-failure window', () => { const nowMs = Date.now(); expect(isChannelRecentlyFailed({ diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 454e02d..526b506 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -1,5 +1,5 @@ -import { eq } from 'drizzle-orm'; -import { minimatch } from 'minimatch'; +import { eq, inArray } from 'drizzle-orm'; +import { minimatch } from 'minimatch'; import { db, schema } from '../db/index.js'; import { config } from '../config.js'; import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js'; @@ -9,10 +9,12 @@ import { type RouteRoutingStrategy, } from './routeRoutingStrategy.js'; import { type DownstreamRoutingPolicy, EMPTY_DOWNSTREAM_ROUTING_POLICY } from './downstreamPolicyTypes.js'; +import { isUsableAccountToken } from './accountTokenService.js'; +import { getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; -interface RouteMatch { - route: typeof schema.tokenRoutes.$inferSelect; - channels: Array<{ +interface RouteMatch { + route: RouteRow; + channels: Array<{ channel: typeof schema.routeChannels.$inferSelect; account: typeof schema.accounts.$inferSelect; site: typeof schema.sites.$inferSelect; @@ -41,6 +43,7 @@ const FAILURE_BACKOFF_BASE_SEC = 15; const MIN_EFFECTIVE_UNIT_COST = 1e-6; const ROUND_ROBIN_FAILURE_THRESHOLD = 3; const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const; +const MAX_ROUTE_REGEX_BODY_LENGTH = 256; function fibonacciNumber(index: number): number { if (index <= 2) return 1; @@ -64,8 +67,12 @@ function resolveRoundRobinCooldownSec(level: number): number { return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0; } -type RouteRow = typeof schema.tokenRoutes.$inferSelect; -type ChannelRow = typeof schema.routeChannels.$inferSelect; +type RouteMode = 'pattern' | 'explicit_group'; +type RouteRow = typeof schema.tokenRoutes.$inferSelect & { + routeMode: RouteMode; + sourceRouteIds: number[]; +}; +type ChannelRow = typeof schema.routeChannels.$inferSelect; type RouteCacheSnapshot = { loadedAt: number; @@ -94,34 +101,71 @@ function isCacheFresh(loadedAt: number, nowMs: number): boolean { } async function loadEnabledRoutes(nowMs = Date.now()): Promise { - if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) { - return routeCacheSnapshot.routes; - } - - const routes = await db.select().from(schema.tokenRoutes) + if (isCacheFresh(routeCacheSnapshot.loadedAt, nowMs)) { + return routeCacheSnapshot.routes; + } + + const rawRoutes = await db.select().from(schema.tokenRoutes) .where(eq(schema.tokenRoutes.enabled, true)) .all(); - routeCacheSnapshot = { - loadedAt: nowMs, - routes, + const explicitGroupRouteIds = rawRoutes + .filter((route) => normalizeRouteMode(route.routeMode) === 'explicit_group') + .map((route) => route.id); + const sourceRows = explicitGroupRouteIds.length > 0 + ? await db.select().from(schema.routeGroupSources) + .where(inArray(schema.routeGroupSources.groupRouteId, explicitGroupRouteIds)) + .all() + : []; + const sourceIdsByRouteId = new Map(); + for (const row of sourceRows) { + if (!sourceIdsByRouteId.has(row.groupRouteId)) { + sourceIdsByRouteId.set(row.groupRouteId, []); + } + sourceIdsByRouteId.get(row.groupRouteId)!.push(row.sourceRouteId); + } + const routes = rawRoutes.map((route) => ({ + ...route, + routeMode: normalizeRouteMode(route.routeMode), + sourceRouteIds: Array.from(new Set(sourceIdsByRouteId.get(route.id) ?? [])), + })); + routeCacheSnapshot = { + loadedAt: nowMs, + routes, }; return routes; } async function loadRouteMatch(route: RouteRow, nowMs = Date.now()): Promise { - const cached = routeMatchCache.get(route.id); - if (cached && isCacheFresh(cached.loadedAt, nowMs)) { - return cached.match; - } - - const channels = await db - .select() - .from(schema.routeChannels) - .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) - .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) - .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) - .where(eq(schema.routeChannels.routeId, route.id)) - .all(); + const cached = routeMatchCache.get(route.id); + if (cached && isCacheFresh(cached.loadedAt, nowMs)) { + return cached.match; + } + + const routeIds = (() => { + if (!isExplicitGroupRoute(route)) { + return [route.id]; + } + return Array.from(new Set(route.sourceRouteIds.filter((routeId) => Number.isFinite(routeId) && routeId > 0))); + })(); + const enabledSourceRouteIds = isExplicitGroupRoute(route) + ? (await loadEnabledRoutes(nowMs)) + .filter((item) => ( + routeIds.includes(item.id) + && !isExplicitGroupRoute(item) + && isExactRouteModelPattern(item.modelPattern) + )) + .map((item) => item.id) + : routeIds; + const channels = enabledSourceRouteIds.length > 0 + ? await db + .select() + .from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .leftJoin(schema.accountTokens, eq(schema.routeChannels.tokenId, schema.accountTokens.id)) + .where(inArray(schema.routeChannels.routeId, enabledSourceRouteIds)) + .all() + : []; const mapped = channels.map((row) => ({ channel: row.route_channels, @@ -242,24 +286,135 @@ type CostSignal = { source: 'observed' | 'configured' | 'catalog' | 'fallback'; }; -export function isRegexModelPattern(pattern: string): boolean { - return pattern.trim().toLowerCase().startsWith('re:'); +export function isRegexModelPattern(pattern: string): boolean { + return pattern.trim().toLowerCase().startsWith('re:'); +} + +function readRegexQuantifierLength(pattern: string, startIndex: number): number { + const ch = pattern[startIndex]; + if (ch === '*' || ch === '+' || ch === '?') return 1; + if (ch !== '{') return 0; + + let index = startIndex + 1; + let sawDigit = false; + while (index < pattern.length && /\d/.test(pattern[index])) { + sawDigit = true; + index += 1; + } + if (!sawDigit) return 0; + + if (pattern[index] === ',') { + index += 1; + while (index < pattern.length && /\d/.test(pattern[index])) { + index += 1; + } + } + + if (pattern[index] !== '}') return 0; + return index - startIndex + 1; +} + +function isSafeRegexPatternBody(body: string): boolean { + if (!body || body.length > MAX_ROUTE_REGEX_BODY_LENGTH) return false; + if (!/^[a-z0-9\s.^$|()[\]{}+*?\\:_/-]+$/i.test(body)) return false; + if (body.includes('(?=') || body.includes('(?!') || body.includes('(?<=') || body.includes('(? = []; + let escaped = false; + let inCharClass = false; + + for (let index = 0; index < body.length; index += 1) { + const ch = body[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (ch === '\\') { + escaped = true; + continue; + } + + if (inCharClass) { + if (ch === ']') inCharClass = false; + continue; + } + + if (ch === '[') { + inCharClass = true; + continue; + } + + if (ch === '(') { + if (body[index + 1] === '?') { + if (body[index + 2] !== ':') { + return false; + } + groupStack.push({ hasInnerQuantifier: false, hasAlternation: false }); + index += 2; + continue; + } + + groupStack.push({ hasInnerQuantifier: false, hasAlternation: false }); + continue; + } + + if (ch === '|') { + if (groupStack.length > 0) { + groupStack[groupStack.length - 1].hasAlternation = true; + } + continue; + } + + if (ch === ')') { + const group = groupStack.pop(); + if (!group) return false; + + const quantifierLength = readRegexQuantifierLength(body, index + 1); + if (quantifierLength > 0 && (group.hasInnerQuantifier || group.hasAlternation)) { + return false; + } + + const parent = groupStack[groupStack.length - 1]; + if (parent && (group.hasInnerQuantifier || quantifierLength > 0)) { + parent.hasInnerQuantifier = true; + } + continue; + } + + const quantifierLength = readRegexQuantifierLength(body, index); + if (quantifierLength > 0) { + if (groupStack.length > 0) { + groupStack[groupStack.length - 1].hasInnerQuantifier = true; + } + index += quantifierLength - 1; + } + } + + return !escaped && !inCharClass && groupStack.length === 0; +} + +export function parseRegexModelPattern(pattern: string): RegExp | null { + if (!isRegexModelPattern(pattern)) return null; + const body = pattern.trim().slice(3).trim(); + if (!body) return null; + if (!isSafeRegexPatternBody(body)) return null; + try { + return new RegExp(body); + } catch { + return null; + } } -export function parseRegexModelPattern(pattern: string): RegExp | null { - if (!isRegexModelPattern(pattern)) return null; - const body = pattern.trim().slice(3).trim(); - if (!body) return null; - try { - return new RegExp(body); - } catch { - return null; - } -} - -export function matchesModelPattern(model: string, pattern: string): boolean { - const normalizedPattern = (pattern || '').trim(); - if (!normalizedPattern) return false; +export function matchesModelPattern(model: string, pattern: string): boolean { + const normalizedPattern = (pattern || '').trim(); + if (!normalizedPattern) return false; if (normalizedPattern === model) return true; @@ -268,25 +423,88 @@ export function matchesModelPattern(model: string, pattern: string): boolean { return !!re && re.test(model); } - return minimatch(model, normalizedPattern); -} + return minimatch(model, normalizedPattern); +} + +function isExactRouteModelPattern(pattern: string): boolean { + const normalizedPattern = (pattern || '').trim(); + if (!normalizedPattern) return false; + if (isRegexModelPattern(normalizedPattern)) return false; + return !/[\*\?\[]/.test(normalizedPattern); +} + +function normalizeRouteMode(routeMode: string | null | undefined): RouteMode { + return routeMode === 'explicit_group' ? 'explicit_group' : 'pattern'; +} + +function isExplicitGroupRoute(route: Pick | Pick): boolean { + return normalizeRouteMode(route.routeMode) === 'explicit_group'; +} + +function normalizeRouteDisplayName(displayName: string | null | undefined): string { + return (displayName || '').trim(); +} -function normalizeRouteDisplayName(displayName: string | null | undefined): string { - return (displayName || '').trim(); -} - -function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean { - const alias = normalizeRouteDisplayName(displayName); - return !!alias && alias === model; -} - -function matchesRouteRequestModel(model: string, route: typeof schema.tokenRoutes.$inferSelect): boolean { - return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName); -} - -function getExposedModelNameForRoute(route: typeof schema.tokenRoutes.$inferSelect): string { - return normalizeRouteDisplayName(route.displayName) || route.modelPattern; -} +function isRouteDisplayNameMatch(model: string, displayName: string | null | undefined): boolean { + const alias = normalizeRouteDisplayName(displayName); + return !!alias && alias === model; +} + +function matchesRouteRequestModel(model: string, route: RouteRow): boolean { + if (isExplicitGroupRoute(route)) { + return isRouteDisplayNameMatch(model, route.displayName); + } + return matchesModelPattern(model, route.modelPattern) || isRouteDisplayNameMatch(model, route.displayName); +} + +function getExposedModelNameForRoute(route: RouteRow): string { + return normalizeRouteDisplayName(route.displayName) || route.modelPattern; +} + +function hasCustomDisplayName(route: Pick): boolean { + const displayName = normalizeRouteDisplayName(route.displayName); + const modelPattern = (route.modelPattern || '').trim(); + return !!displayName && displayName !== modelPattern; +} + +function buildVisibleEnabledRoutes(routes: RouteRow[]): RouteRow[] { + const exactModelNames = new Set( + routes + .filter((route) => !isExplicitGroupRoute(route) && isExactRouteModelPattern(route.modelPattern)) + .map((route) => (route.modelPattern || '').trim()) + .filter(Boolean), + ); + const coveringGroups = routes.filter((route) => ( + route.enabled + && ( + (isExplicitGroupRoute(route) && normalizeRouteDisplayName(route.displayName).length > 0 && route.sourceRouteIds.length > 0) + || (!isExplicitGroupRoute(route) && !isExactRouteModelPattern(route.modelPattern) && hasCustomDisplayName(route)) + ) + )); + + if (coveringGroups.length === 0) return routes; + + return routes.filter((route) => { + if (isExplicitGroupRoute(route)) { + return normalizeRouteDisplayName(route.displayName).length > 0; + } + if (!isExactRouteModelPattern(route.modelPattern)) return true; + if (hasCustomDisplayName(route)) return true; + + const exactModel = (route.modelPattern || '').trim(); + if (!exactModel) return true; + + return !coveringGroups.some((groupRoute) => { + if (groupRoute.id === route.id) return false; + const groupDisplayName = normalizeRouteDisplayName(groupRoute.displayName); + if (!groupDisplayName || exactModelNames.has(groupDisplayName)) return false; + if (isExplicitGroupRoute(groupRoute)) { + return groupRoute.sourceRouteIds.includes(route.id); + } + return matchesModelPattern(exactModel, groupRoute.modelPattern); + }); + }); +} function normalizeModelAlias(modelName: string): string { const normalized = (modelName || '').trim().toLowerCase(); @@ -358,10 +576,10 @@ function normalizeChannelSourceModel(channelSourceModel: string | null | undefin function resolveActualModelForSelectedChannel( requestedModel: string, - route: typeof schema.tokenRoutes.$inferSelect, + route: RouteRow, mappedModel: string, channelSourceModel: string | null | undefined, -): string { +): string { const sourceModel = normalizeChannelSourceModel(channelSourceModel); if (isRouteDisplayNameMatch(requestedModel, route.displayName) && sourceModel) { return sourceModel; @@ -369,7 +587,7 @@ function resolveActualModelForSelectedChannel( return mappedModel; } -function resolveRouteStrategy(route: typeof schema.tokenRoutes.$inferSelect): RouteRoutingStrategy { +function resolveRouteStrategy(route: RouteRow): RouteRoutingStrategy { return normalizeRouteRoutingStrategy(route.routingStrategy); } @@ -441,6 +659,17 @@ export class TokenRouter { if (!match) return null; return await this.selectFromMatch(match, requestedModel, downstreamPolicy); } + + async previewSelectedChannel( + requestedModel: string, + downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, + ): Promise { + if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; + + const match = await this.findRoute(requestedModel, downstreamPolicy); + if (!match) return null; + return await this.selectFromMatch(match, requestedModel, downstreamPolicy, [], false); + } /** * Select next channel for failover (exclude already-tried channels). @@ -884,7 +1113,7 @@ export class TokenRouter { */ async getAvailableModels(): Promise { const routes = await loadEnabledRoutes(); - const exposed = routes + const exposed = buildVisibleEnabledRoutes(routes) .map((route) => getExposedModelNameForRoute(route).trim()) .filter((name) => name.length > 0); return Array.from(new Set(exposed)); @@ -897,6 +1126,7 @@ export class TokenRouter { requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy, excludeChannelIds: number[] = [], + recordSelection = true, ): Promise { const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); @@ -922,7 +1152,9 @@ export class TokenRouter { const tokenValue = this.resolveChannelTokenValue(selected); if (!tokenValue) return null; - await this.recordChannelSelection(selected.channel.id); + if (recordSelection) { + await this.recordChannelSelection(selected.channel.id); + } const actualModel = resolveActualModelForSelectedChannel( requestedModel, @@ -993,12 +1225,16 @@ export class TokenRouter { routes = routes.filter((route) => allowSet.has(route.id)); } - // Find matching route by model pattern or display alias. - const matchedRoute = routes.find((r) => { - return matchesRouteRequestModel(model, r); - }); - - if (!matchedRoute) return null; + const matchedRoute = routes.find((route) => ( + !isExplicitGroupRoute(route) + && isExactRouteModelPattern(route.modelPattern) + && (route.modelPattern || '').trim() === model + )) + || routes.find((route) => isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) + || routes.find((route) => !isExplicitGroupRoute(route) && isRouteDisplayNameMatch(model, route.displayName)) + || routes.find((route) => !isExplicitGroupRoute(route) && matchesModelPattern(model, route.modelPattern)); + + if (!matchedRoute) return null; return await this.loadRouteMatch(matchedRoute); } @@ -1014,7 +1250,7 @@ export class TokenRouter { return await this.loadRouteMatch(route); } - private async loadRouteMatch(route: typeof schema.tokenRoutes.$inferSelect): Promise { + private async loadRouteMatch(route: RouteRow): Promise { return await loadRouteMatch(route); } @@ -1023,16 +1259,23 @@ export class TokenRouter { account: typeof schema.accounts.$inferSelect; site?: typeof schema.sites.$inferSelect | null; token: typeof schema.accountTokens.$inferSelect | null; - }): string | null { + }): string | null { if (candidate.channel.tokenId) { if (!candidate.token) return null; - if (!candidate.token.enabled) return null; + if (!isUsableAccountToken(candidate.token)) return null; const token = candidate.token.token?.trim(); return token ? token : null; } const fallback = candidate.account.apiToken?.trim(); - return fallback || null; + if (fallback) return fallback; + + if (getOauthInfoFromExtraConfig(candidate.account.extraConfig)) { + const accessToken = candidate.account.accessToken?.trim(); + return accessToken || null; + } + + return null; } private getCandidateEligibilityReasons( diff --git a/src/server/transformers/anthropic/messages/conversion.test.ts b/src/server/transformers/anthropic/messages/conversion.test.ts index b9d77f9..03f0e03 100644 --- a/src/server/transformers/anthropic/messages/conversion.test.ts +++ b/src/server/transformers/anthropic/messages/conversion.test.ts @@ -524,6 +524,44 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => { ]); }); + it('preserves tool_reference blocks inside tool_result content arrays', () => { + const result = sanitizeAnthropicMessagesBody({ + model: 'claude-opus-4-6', + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: [ + { type: 'tool_reference', tool_name: 'lookup' }, + { type: 'text', text: 'follow-up' }, + ], + }, + ], + }, + ], + }); + + expect(result.messages).toEqual([ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool_1', + content: [ + { type: 'tool_reference', tool_name: 'lookup' }, + { type: 'text', text: 'follow-up' }, + ], + cache_control: { type: 'ephemeral' }, + }, + ], + }, + ]); + }); + it('adds a second message cache anchor around the 20-block window for long prompts', () => { const longContent = Array.from({ length: 25 }, (_, index) => ({ type: 'text', diff --git a/src/server/transformers/anthropic/messages/conversion.ts b/src/server/transformers/anthropic/messages/conversion.ts index c240e2e..c71be7b 100644 --- a/src/server/transformers/anthropic/messages/conversion.ts +++ b/src/server/transformers/anthropic/messages/conversion.ts @@ -187,12 +187,39 @@ function normalizeTextCandidate(value: unknown): string { return asTrimmedString(value.text ?? value.content ?? value.output_text); } -function normalizeToolMessageContent(raw: unknown): string { +function sanitizeToolReferenceBlock(item: Record): Record | null { + const toolName = asTrimmedString(item.tool_name ?? item.toolName ?? item.name); + if (!toolName) return null; + const next: Record = { + ...item, + type: 'tool_reference', + tool_name: toolName, + }; + delete next.toolName; + if ('name' in next) delete next.name; + return next; +} + +function normalizeToolMessageContent(raw: unknown): string | Array> { if (raw === undefined || raw === null) return ''; if (typeof raw === 'string') return raw; if (typeof raw === 'number' || typeof raw === 'boolean') return String(raw); if (Array.isArray(raw)) { + const structuredBlocks = raw + .map((item) => { + if (typeof item === 'string') return toAnthropicTextBlock(item); + if (!isRecord(item)) return null; + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'tool_reference') return sanitizeToolReferenceBlock(item); + if (type === 'text' || type === 'input_text' || type === 'output_text') { + return toAnthropicTextBlock(normalizeTextCandidate(item)); + } + return null; + }) + .filter((item): item is Record => item !== null); + if (structuredBlocks.length > 0) return structuredBlocks; + const textParts = raw .map((item) => { if (typeof item === 'string') return item; @@ -329,7 +356,7 @@ function sanitizeAnthropicContentBlock(item: Record): Record = { type: 'tool_result', tool_use_id: toolUseId, @@ -355,6 +382,10 @@ function sanitizeAnthropicContentBlock(item: Record): Record 0 + : toolResultContent.length > 0; + if (toolUseId && hasToolResultContent) { toolResultBlocks.push({ type: 'tool_result', tool_use_id: toolUseId, diff --git a/src/server/transformers/gemini/generate-content/compatibility.ts b/src/server/transformers/gemini/generate-content/compatibility.ts new file mode 100644 index 0000000..3a946d7 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/compatibility.ts @@ -0,0 +1,314 @@ +import type { NormalizedFinalResponse } from '../../shared/normalized.js'; +import { extractReasoningMetadataFromGeminiRequest } from './convert.js'; + +type GeminiRecord = Record; + +function isRecord(value: unknown): value is GeminiRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseJsonIfPossible(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return { raw: value }; + } +} + +function buildDataUrl(part: GeminiRecord): string | null { + const inlineData = isRecord(part.inlineData) ? part.inlineData : null; + if (!inlineData) return null; + const mimeType = asTrimmedString(inlineData.mime_type ?? inlineData.mimeType) || 'application/octet-stream'; + const data = asTrimmedString(inlineData.data); + if (!data) return null; + return `data:${mimeType};base64,${data}`; +} + +function toOpenAiContent(contentParts: GeminiRecord[]): string | Array> { + const blocks: Array> = []; + let textContent = ''; + + for (const part of contentParts) { + if (typeof part.text === 'string' && part.text.length > 0) { + if (part.thought === true) continue; + textContent += part.text; + continue; + } + + const dataUrl = buildDataUrl(part); + if (dataUrl) { + blocks.push({ + type: 'image_url', + image_url: { url: dataUrl }, + }); + } + } + + if (blocks.length <= 0) { + return textContent; + } + + if (textContent) { + blocks.unshift({ + type: 'text', + text: textContent, + }); + } + return blocks; +} + +function extractToolDeclarations(tools: unknown): Array> | undefined { + if (!Array.isArray(tools)) return undefined; + const next = tools.flatMap((tool) => { + if (!isRecord(tool) || !Array.isArray(tool.functionDeclarations)) return []; + return tool.functionDeclarations.flatMap((declaration) => { + if (!isRecord(declaration)) return []; + const name = asTrimmedString(declaration.name); + if (!name) return []; + return [{ + type: 'function', + function: { + name, + ...(asTrimmedString(declaration.description) + ? { description: asTrimmedString(declaration.description) } + : {}), + parameters: isRecord(declaration.parametersJsonSchema) + ? declaration.parametersJsonSchema + : (isRecord(declaration.parameters) ? declaration.parameters : { type: 'object', properties: {} }), + }, + }]; + }); + }); + return next.length > 0 ? next : undefined; +} + +function extractToolChoice(toolConfig: unknown): string | undefined { + const functionCallingConfig = ( + isRecord(toolConfig) && isRecord(toolConfig.functionCallingConfig) + ? toolConfig.functionCallingConfig + : null + ); + const mode = asTrimmedString(functionCallingConfig?.mode).toUpperCase(); + if (mode === 'NONE') return 'none'; + if (mode === 'ANY' || mode === 'VALIDATED') return 'required'; + if (mode === 'AUTO') return 'auto'; + return undefined; +} + +function buildGeminiMessages(body: GeminiRecord): Array> { + const messages: Array> = []; + + if (isRecord(body.systemInstruction) && Array.isArray(body.systemInstruction.parts)) { + const content = toOpenAiContent( + body.systemInstruction.parts.filter((part): part is GeminiRecord => isRecord(part)), + ); + const hasContent = typeof content === 'string' ? content.length > 0 : content.length > 0; + if (hasContent) { + messages.push({ + role: 'system', + content, + }); + } + } + + const contents = Array.isArray(body.contents) ? body.contents : []; + for (const contentItem of contents) { + if (!isRecord(contentItem)) continue; + const role = asTrimmedString(contentItem.role) === 'model' ? 'assistant' : 'user'; + const parts = Array.isArray(contentItem.parts) + ? contentItem.parts.filter((part): part is GeminiRecord => isRecord(part)) + : []; + + const toolCalls = parts + .map((part, index) => { + const functionCall = isRecord(part.functionCall) ? part.functionCall : null; + const name = asTrimmedString(functionCall?.name); + if (!functionCall || !name) return null; + return { + id: asTrimmedString(functionCall.id) || `call_${index}`, + type: 'function', + function: { + name, + arguments: JSON.stringify(functionCall.args ?? {}), + }, + }; + }) + .filter((item): item is NonNullable => !!item); + + const functionResponses = parts + .map((part) => (isRecord(part.functionResponse) ? part.functionResponse : null)) + .filter((item): item is GeminiRecord => !!item); + + if (functionResponses.length > 0) { + for (let index = 0; index < functionResponses.length; index += 1) { + const functionResponse = functionResponses[index] as GeminiRecord; + const toolName = asTrimmedString(functionResponse.name) || `tool_${index}`; + const toolResponse = functionResponse.response; + messages.push({ + role: 'tool', + tool_call_id: toolName, + content: JSON.stringify(toolResponse ?? {}), + }); + } + continue; + } + + const openAiParts = parts.filter((part) => !part.functionCall && !part.functionResponse); + const content = toOpenAiContent(openAiParts); + const hasContent = typeof content === 'string' ? content.length > 0 : content.length > 0; + if (!hasContent && toolCalls.length <= 0) continue; + + const message: Record = { + role, + }; + if (hasContent) { + message.content = content; + } + if (toolCalls.length > 0) { + message.tool_calls = toolCalls; + if (!hasContent) message.content = ''; + } + messages.push(message); + } + + return messages; +} + +export function buildOpenAiBodyFromGeminiRequest(input: { + body: GeminiRecord; + modelName: string; + stream: boolean; +}): Record { + const openAiBody: Record = { + model: input.modelName, + stream: input.stream, + messages: buildGeminiMessages(input.body), + }; + + const generationConfig = isRecord(input.body.generationConfig) ? input.body.generationConfig : null; + const maxTokens = Number( + generationConfig?.maxOutputTokens + ?? input.body.max_output_tokens + ?? input.body.max_completion_tokens + ?? input.body.max_tokens + ?? 0, + ); + if (Number.isFinite(maxTokens) && maxTokens > 0) { + openAiBody.max_tokens = Math.trunc(maxTokens); + } + const temperature = Number(generationConfig?.temperature); + if (Number.isFinite(temperature)) openAiBody.temperature = temperature; + const topP = Number(generationConfig?.topP); + if (Number.isFinite(topP)) openAiBody.top_p = topP; + if (Array.isArray(generationConfig?.stopSequences) && generationConfig!.stopSequences.length > 0) { + openAiBody.stop = generationConfig!.stopSequences; + } + + const tools = extractToolDeclarations(input.body.tools); + if (tools) { + openAiBody.tools = tools; + } + const toolChoice = extractToolChoice(input.body.toolConfig); + if (toolChoice) { + openAiBody.tool_choice = toolChoice; + } + + const reasoning = extractReasoningMetadataFromGeminiRequest(input.body); + if (reasoning) { + openAiBody.reasoning = { + effort: reasoning.reasoningEffort, + budget_tokens: reasoning.reasoningBudget, + }; + openAiBody.reasoning_effort = reasoning.reasoningEffort; + } + + return openAiBody; +} + +function mapFinishReason(finishReason: string): string { + const normalized = finishReason.trim().toLowerCase(); + if (!normalized) return 'STOP'; + if (normalized === 'stop' || normalized === 'completed' || normalized === 'tool_calls') return 'STOP'; + if (normalized === 'length' || normalized === 'max_tokens' || normalized === 'max_output_tokens') return 'MAX_TOKENS'; + if (normalized === 'content_filter' || normalized === 'safety') return 'SAFETY'; + return normalized.toUpperCase().replace(/[^A-Z0-9_]+/g, '_'); +} + +function buildUsageMetadata(usage?: { + promptTokens?: number | null; + completionTokens?: number | null; + totalTokens?: number | null; +}): Record | undefined { + if (!usage) return undefined; + const metadata: Record = {}; + if (typeof usage.promptTokens === 'number' && Number.isFinite(usage.promptTokens)) { + metadata.promptTokenCount = usage.promptTokens; + } + if (typeof usage.completionTokens === 'number' && Number.isFinite(usage.completionTokens)) { + metadata.candidatesTokenCount = usage.completionTokens; + } + if (typeof usage.totalTokens === 'number' && Number.isFinite(usage.totalTokens)) { + metadata.totalTokenCount = usage.totalTokens; + } + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +export function serializeNormalizedFinalToGemini(input: { + normalized: NormalizedFinalResponse; + usage?: { + promptTokens?: number | null; + completionTokens?: number | null; + totalTokens?: number | null; + }; +}): Record { + const parts: Array> = []; + + if (input.normalized.reasoningContent) { + parts.push({ + text: input.normalized.reasoningContent, + thought: true, + }); + } + if (input.normalized.content) { + parts.push({ + text: input.normalized.content, + }); + } + for (const toolCall of input.normalized.toolCalls) { + parts.push({ + functionCall: { + ...(toolCall.id ? { id: toolCall.id } : {}), + name: toolCall.name, + args: parseJsonIfPossible(toolCall.arguments), + }, + }); + } + + const response: Record = { + responseId: input.normalized.id || '', + modelVersion: input.normalized.model || '', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts, + }, + finishReason: mapFinishReason(input.normalized.finishReason), + }, + ], + }; + + const usageMetadata = buildUsageMetadata(input.usage); + if (usageMetadata) { + response.usageMetadata = usageMetadata; + } + + return response; +} diff --git a/src/server/transformers/gemini/generate-content/inbound.ts b/src/server/transformers/gemini/generate-content/inbound.ts index 6a90633..e62dfb6 100644 --- a/src/server/transformers/gemini/generate-content/inbound.ts +++ b/src/server/transformers/gemini/generate-content/inbound.ts @@ -194,14 +194,22 @@ export const geminiGenerateContentInbound = { next.generationConfig = generationConfig; } - const strippedKeys = new Set([ - 'reasoning', - 'reasoning_effort', - 'reasoning_budget', + // Only forward fields that Gemini API supports. Unknown fields + // (e.g. requestId, frequency_penalty) cause upstream 400 errors. + const allowedPassthroughKeys = new Set([ + 'contents', + 'systemInstruction', + 'cachedContent', + 'safetySettings', + 'generationConfig', + 'tools', + 'toolConfig', + 'labels', + 'model', ]); for (const [key, value] of Object.entries(body)) { - if (strippedKeys.has(key)) continue; + if (!allowedPassthroughKeys.has(key)) continue; if (next[key] !== undefined) continue; next[key] = cloneJsonValue(value); } diff --git a/src/server/transformers/gemini/generate-content/index.ts b/src/server/transformers/gemini/generate-content/index.ts index 13dcf91..ab706ee 100644 --- a/src/server/transformers/gemini/generate-content/index.ts +++ b/src/server/transformers/gemini/generate-content/index.ts @@ -83,6 +83,7 @@ import { geminiGenerateContentStream } from './stream.js'; import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js'; import { geminiGenerateContentUsage } from './usage.js'; import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js'; +import { buildOpenAiBodyFromGeminiRequest, serializeNormalizedFinalToGemini } from './compatibility.js'; export const geminiGenerateContentTransformer = { protocol: 'gemini/generate-content' as const, @@ -98,6 +99,10 @@ export const geminiGenerateContentTransformer = { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning, }, + compatibility: { + buildOpenAiBodyFromGeminiRequest, + serializeNormalizedFinalToGemini, + }, parseProxyRequestPath: parseGeminiProxyRequestPath, resolveProxyApiVersion: resolveGeminiProxyApiVersion, resolveBaseUrl: resolveGeminiNativeBaseUrl, @@ -114,4 +119,6 @@ export { geminiGenerateContentUsage, reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning, + buildOpenAiBodyFromGeminiRequest, + serializeNormalizedFinalToGemini, }; diff --git a/src/server/transformers/gemini/generate-content/stream.test.ts b/src/server/transformers/gemini/generate-content/stream.test.ts index f171c02..2b8e987 100644 --- a/src/server/transformers/gemini/generate-content/stream.test.ts +++ b/src/server/transformers/gemini/generate-content/stream.test.ts @@ -5,8 +5,10 @@ import { extractResponseMetadata, serializeGeminiAggregateResponse } from './out import { applyJsonPayloadToAggregate, applySsePayloadsToAggregate, + consumeUpstreamSseBuffer, geminiGenerateContentStream, parseGeminiStreamPayload, + serializeUpstreamJsonPayload, serializeAggregateSsePayload, } from './stream.js'; import { extractGeminiUsage } from './usage.js'; @@ -169,6 +171,56 @@ describe('geminiGenerateContentStream', () => { }); }); + it('returns raw upstream sse blocks while aggregating tool-calling state', () => { + const state = createGeminiGenerateContentAggregateState(); + const firstBlock = 'data: {"promptFeedback":{"blockReason":"BLOCK_REASON_UNSPECIFIED"},"candidates":[{"content":{"parts":[{"functionCall":{"id":"tool-1","name":"lookup","args":{"q":"cat"}},"thoughtSignature":"sig-tool-1"}]}}]}\r\n\r\n'; + const secondBlock = 'data: {"candidates":[{"content":{"parts":[{"text":"answer"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":4,"totalTokenCount":14}}\r\n\r\n'; + const doneBlock = 'data: [DONE]\r\n\r\n'; + const result = consumeUpstreamSseBuffer( + state, + `${firstBlock}${secondBlock}${doneBlock}data: {"responseId":"partial"`, + ); + + expect(result.lines).toEqual([firstBlock, secondBlock, doneBlock]); + expect(result.events).toEqual([ + { + promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' }, + candidates: [ + { + content: { + parts: [{ functionCall: { id: 'tool-1', name: 'lookup', args: { q: 'cat' } }, thoughtSignature: 'sig-tool-1' }], + }, + }, + ], + }, + { + candidates: [ + { + content: { + parts: [{ text: 'answer' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 4, + totalTokenCount: 14, + }, + }, + ]); + expect(result.rest).toBe('data: {"responseId":"partial"'); + expect(extractGeminiUsage(result.state)).toEqual({ + promptTokens: 10, + completionTokens: 4, + totalTokens: 14, + cachedTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + reasoningTokens: 0, + }); + }); + it('normalizes object payloads through the JSON-array path', () => { const state = createGeminiGenerateContentAggregateState(); @@ -203,4 +255,47 @@ describe('geminiGenerateContentStream', () => { ], }); }); + + it('keeps json stream chunks raw while still aggregating usage', () => { + const state = createGeminiGenerateContentAggregateState(); + const payload = [ + { + promptFeedback: { blockReason: 'BLOCK_REASON_UNSPECIFIED' }, + candidates: [ + { + content: { + parts: [{ functionCall: { id: 'tool-1', name: 'lookup', args: { q: 'cat' } } }], + }, + }, + ], + }, + { + serverContent: { modelTurn: { parts: [{ text: 'tool result received' }] } }, + candidates: [ + { + content: { + parts: [{ text: 'final answer' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 5, + totalTokenCount: 13, + }, + }, + ]; + + expect(serializeUpstreamJsonPayload(state, payload, true)).toEqual(payload); + expect(extractGeminiUsage(state)).toEqual({ + promptTokens: 8, + completionTokens: 5, + totalTokens: 13, + cachedTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + reasoningTokens: 0, + }); + }); }); diff --git a/src/server/transformers/gemini/generate-content/stream.ts b/src/server/transformers/gemini/generate-content/stream.ts index 1393597..010750f 100644 --- a/src/server/transformers/gemini/generate-content/stream.ts +++ b/src/server/transformers/gemini/generate-content/stream.ts @@ -7,6 +7,7 @@ import { serializeGeminiAggregateResponse } from './outbound.js'; type ParsedSsePayloads = { events: unknown[]; + lines: string[]; rest: string; }; @@ -15,10 +16,11 @@ type GeminiGenerateContentStreamFormat = 'sse' | 'json'; type ParsedGeminiStreamPayload = { format: GeminiGenerateContentStreamFormat; events: unknown[]; + lines?: string[]; rest: string; }; -type AppliedGeminiStreamPayloads = ParsedSsePayloads & { +type AppliedGeminiStreamPayloads = ParsedGeminiStreamPayload & { state: GeminiGenerateContentAggregateState; }; @@ -27,19 +29,24 @@ function serializeSsePayload(payload: unknown): string { } function parseSsePayloads(buffer: string): ParsedSsePayloads { - const normalized = buffer.replace(/\r\n/g, '\n'); const events: unknown[] = []; - let rest = normalized; + const lines: string[] = []; + let cursor = 0; - while (true) { - const boundary = rest.indexOf('\n\n'); - if (boundary < 0) break; + while (cursor < buffer.length) { + const boundaryMatch = /\r?\n\r?\n/.exec(buffer.slice(cursor)); + if (!boundaryMatch || typeof boundaryMatch.index !== 'number') break; + + const boundary = cursor + boundaryMatch.index; + const block = buffer.slice(cursor, boundary); + const rawBlock = buffer.slice(cursor, boundary + boundaryMatch[0].length); + cursor = boundary + boundaryMatch[0].length; - const block = rest.slice(0, boundary); - rest = rest.slice(boundary + 2); if (!block.trim()) continue; + lines.push(rawBlock); const data = block + .replace(/\r\n/g, '\n') .split('\n') .filter((line) => line.startsWith('data:')) .map((line) => line.slice(5).trimStart()) @@ -55,7 +62,11 @@ function parseSsePayloads(buffer: string): ParsedSsePayloads { } } - return { events, rest }; + return { + events, + lines, + rest: buffer.slice(cursor), + }; } function isRecord(value: unknown): value is Record { @@ -77,6 +88,7 @@ function parseGeminiStreamPayload( return { format: 'sse', events: parsed.events, + lines: parsed.lines, rest: parsed.rest, }; } @@ -123,8 +135,7 @@ function applyParsedPayloadToAggregate( } return { - events: parsed.events, - rest: parsed.rest, + ...parsed, state, }; } @@ -151,7 +162,7 @@ function consumeUpstreamSseBuffer( const applied = applySsePayloadsToAggregate(state, buffer); return { ...applied, - lines: applied.events.map((event) => serializeAggregateSsePayload(event)), + lines: applied.lines ?? [], }; } @@ -182,10 +193,11 @@ function serializeUpstreamJsonPayload( streamAction = false, ): unknown { if (streamAction) { - return parseJsonArrayPayload(payload).map((event) => { + const events = parseJsonArrayPayload(payload); + for (const event of events) { applyGeminiGenerateContentAggregate(state, event); - return serializeAggregateJsonPayload(event); - }); + } + return payload; } applyJsonPayloadToAggregate(state, payload); diff --git a/src/server/transformers/openai/chat/proxyStream.ts b/src/server/transformers/openai/chat/proxyStream.ts index 15af7ad..54161ae 100644 --- a/src/server/transformers/openai/chat/proxyStream.ts +++ b/src/server/transformers/openai/chat/proxyStream.ts @@ -14,6 +14,7 @@ type StreamReader = { type ChatProxyStreamSessionInput = { downstreamFormat: DownstreamFormat; modelName: string; + successfulUpstreamPath: string; onParsedPayload?: (payload: unknown) => void; writeLines: (lines: string[]) => void; writeRaw: (chunk: string) => void; @@ -23,6 +24,11 @@ type ResponseSink = { end(): void; }; +type ChatProxyStreamResult = { + status: 'completed' | 'failed'; + errorMessage: string | null; +}; + export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) { const downstreamTransformer = input.downstreamFormat === 'claude' ? anthropicMessagesTransformer @@ -39,6 +45,36 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) ? createOpenAiChatAggregateState() : null; let finalized = false; + let terminalResult: ChatProxyStreamResult = { + status: 'completed', + errorMessage: null, + }; + + const extractFailureMessage = (payload: unknown, fallback = 'upstream stream failed'): string => { + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + const record = payload as Record; + if (record.error && typeof record.error === 'object' && !Array.isArray(record.error)) { + const message = (record.error as Record).message; + if (typeof message === 'string' && message.trim()) return message.trim(); + } + if (typeof record.message === 'string' && record.message.trim()) return record.message.trim(); + if (record.response && typeof record.response === 'object' && !Array.isArray(record.response)) { + const responseError = (record.response as Record).error; + if (responseError && typeof responseError === 'object' && !Array.isArray(responseError)) { + const message = (responseError as Record).message; + if (typeof message === 'string' && message.trim()) return message.trim(); + } + } + } + return fallback; + }; + + const markFailed = (payload: unknown, fallbackMessage?: string) => { + terminalResult = { + status: 'failed', + errorMessage: extractFailureMessage(payload, fallbackMessage), + }; + }; const finalize = () => { if (finalized) return; @@ -51,7 +87,12 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) return; } - if (input.downstreamFormat === 'openai' && chatAggregateState && chatAggregateState.choices.size > 0) { + if ( + input.downstreamFormat === 'openai' + && terminalResult.status !== 'failed' + && chatAggregateState + && chatAggregateState.choices.size > 0 + ) { const needsTerminalFinishChunk = Array.from(chatAggregateState.choices.values()) .some((choice) => !choice.finishReason); if (needsTerminalFinishChunk) { @@ -109,6 +150,12 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) } if (parsedPayload && typeof parsedPayload === 'object') { + const payloadType = typeof (parsedPayload as Record).type === 'string' + ? String((parsedPayload as Record).type) + : ''; + if (payloadType === 'response.failed' || payloadType === 'error') { + markFailed(parsedPayload); + } const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, input.modelName); if (input.downstreamFormat === 'openai' && chatAggregateState) { applyOpenAiChatStreamEvent(chatAggregateState, normalizedEvent); @@ -129,10 +176,18 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) }; return { - consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink) { + consumeUpstreamFinalPayload(payload: unknown, fallbackText: string, response?: ResponseSink): ChatProxyStreamResult { if (payload && typeof payload === 'object') { input.onParsedPayload?.(payload); } + if (payload && typeof payload === 'object' && !Array.isArray(payload)) { + const payloadType = typeof (payload as Record).type === 'string' + ? String((payload as Record).type) + : ''; + if (payloadType === 'response.failed' || payloadType === 'error') { + markFailed(payload); + } + } if (input.downstreamFormat === 'openai') { const normalizedFinal = openAiChatOutbound.normalizeFinal(payload, input.modelName, fallbackText); streamContext.id = normalizedFinal.id; @@ -156,8 +211,9 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) } finalize(); response?.end(); + return terminalResult; }, - async run(reader: StreamReader | null | undefined, response: ResponseSink) { + async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise { const lifecycle = createProxyStreamLifecycle({ reader, response, @@ -166,6 +222,7 @@ export function createChatProxyStreamSession(input: ChatProxyStreamSessionInput) onEof: finalize, }); await lifecycle.run(); + return terminalResult; }, }; } diff --git a/src/server/transformers/openai/responses/compatibility.ts b/src/server/transformers/openai/responses/compatibility.ts index 8afe4bd..ca6b323 100644 --- a/src/server/transformers/openai/responses/compatibility.ts +++ b/src/server/transformers/openai/responses/compatibility.ts @@ -213,11 +213,17 @@ export function buildResponsesCompatibilityBodies( if (temperature !== null) richCandidate.temperature = temperature; const topP = toFiniteNumber(body.top_p); if (topP !== null) richCandidate.top_p = topP; - const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : ''; - if (instructions) richCandidate.instructions = instructions; + const instructions = getExplicitResponsesInstructions(body); + if (instructions !== null) richCandidate.instructions = instructions; const passthroughFields = [ + 'parallel_tool_calls', + 'include', 'reasoning', + 'previous_response_id', + 'truncation', + 'text', + 'service_tier', 'safety_identifier', 'max_tool_calls', 'prompt_cache_key', @@ -398,6 +404,11 @@ function parseUpstreamErrorShape(rawText: string): { } } +function getExplicitResponsesInstructions(body: Record): string | null { + if (!Object.prototype.hasOwnProperty.call(body, 'instructions')) return null; + return typeof body.instructions === 'string' ? body.instructions.trim() : ''; +} + function stripResponsesMetadata( body: Record, ): Record | null { @@ -431,8 +442,8 @@ function buildCoreResponsesBody( const topP = toFiniteNumber(body.top_p); if (topP !== null) core.top_p = topP; - const instructions = typeof body.instructions === 'string' ? body.instructions.trim() : ''; - if (instructions) core.instructions = instructions; + const instructions = getExplicitResponsesInstructions(body); + if (instructions !== null) core.instructions = instructions; const passthroughFields = [ 'reasoning', @@ -458,10 +469,15 @@ function buildStrictResponsesBody( if (!model) return null; if (body.input === undefined) return null; + const explicitInstructions = getExplicitResponsesInstructions(body); + return { model, input: body.input, stream: body.stream === true, + ...(explicitInstructions !== null + ? { instructions: explicitInstructions } + : {}), }; } diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index 31c2956..c6e8395 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -853,6 +853,7 @@ describe('convertResponsesBodyToOpenAiBody', () => { model: 'gpt-5', input: 'hello', stream: true, + parallel_tool_calls: true, include: ['reasoning.encrypted_content'], reasoning: { effort: 'high', @@ -864,12 +865,20 @@ describe('convertResponsesBodyToOpenAiBody', () => { prompt_cache_retention: { scope: 'workspace' }, background: true, top_logprobs: 2, + previous_response_id: 'resp_prev_9', + truncation: 'auto', + service_tier: 'priority', + text: { + verbosity: 'high', + }, }); expect(candidates).toContainEqual({ model: 'gpt-5', input: 'hello', stream: true, + parallel_tool_calls: true, + include: ['reasoning.encrypted_content'], reasoning: { effort: 'high', summary: 'auto', @@ -880,6 +889,12 @@ describe('convertResponsesBodyToOpenAiBody', () => { prompt_cache_retention: { scope: 'workspace' }, background: true, top_logprobs: 2, + previous_response_id: 'resp_prev_9', + truncation: 'auto', + service_tier: 'priority', + text: { + verbosity: 'high', + }, }); }); diff --git a/src/server/transformers/openai/responses/proxyStream.ts b/src/server/transformers/openai/responses/proxyStream.ts index 14ebc71..c7e73a3 100644 --- a/src/server/transformers/openai/responses/proxyStream.ts +++ b/src/server/transformers/openai/responses/proxyStream.ts @@ -13,9 +13,15 @@ type ResponseSink = { end(): void; }; +type ResponsesProxyStreamResult = { + status: 'completed' | 'failed'; + errorMessage: string | null; +}; + type ResponsesProxyStreamSessionInput = { modelName: string; successfulUpstreamPath: string; + strictTerminalEvents?: boolean; getUsage: () => { promptTokens: number; completionTokens: number; @@ -33,20 +39,66 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; } +function getResponsesStreamFailureMessage(payload: unknown, fallback = 'upstream stream failed'): string { + if (isRecord(payload)) { + if (isRecord(payload.error) && typeof payload.error.message === 'string' && payload.error.message.trim()) { + return payload.error.message.trim(); + } + if (typeof payload.message === 'string' && payload.message.trim()) { + return payload.message.trim(); + } + if (isRecord(payload.response) && isRecord(payload.response.error) && typeof payload.response.error.message === 'string' && payload.response.error.message.trim()) { + return payload.response.error.message.trim(); + } + } + return fallback; +} + export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) { const streamContext = openAiResponsesStream.createContext(input.modelName); const responsesState = createOpenAiResponsesAggregateState(input.modelName); let finalized = false; + let terminalResult: ResponsesProxyStreamResult = { + status: 'completed', + errorMessage: null, + }; const finalize = () => { if (finalized) return; finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; input.writeLines(completeResponsesStream(responsesState, streamContext, input.getUsage())); }; + const fail = (payload: unknown, fallbackMessage?: string) => { + if (finalized) return; + finalized = true; + terminalResult = { + status: 'failed', + errorMessage: getResponsesStreamFailureMessage(payload, fallbackMessage), + }; + input.writeLines(failResponsesStream(responsesState, streamContext, input.getUsage(), payload)); + }; + + const closeOut = () => { + if (finalized) return; + if (input.strictTerminalEvents) { + finalized = true; + terminalResult = { + status: 'failed', + errorMessage: 'stream closed before response.completed', + }; + return; + } + finalize(); + }; + const handleEventBlock = (eventBlock: ParsedSseEvent): boolean => { if (eventBlock.data === '[DONE]') { - finalize(); + closeOut(); return true; } @@ -71,8 +123,7 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes || payloadType === 'response.failed' ); if (isFailureEvent) { - input.writeLines(failResponsesStream(responsesState, streamContext, input.getUsage(), parsedPayload)); - finalized = true; + fail(parsedPayload); return true; } @@ -97,15 +148,16 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes }; return { - async run(reader: StreamReader | null | undefined, response: ResponseSink) { + async run(reader: StreamReader | null | undefined, response: ResponseSink): Promise { const lifecycle = createProxyStreamLifecycle({ reader, response, pullEvents: (buffer) => openAiResponsesStream.pullSseEvents(buffer), handleEvent: handleEventBlock, - onEof: finalize, + onEof: closeOut, }); await lifecycle.run(); + return terminalResult; }, }; } diff --git a/src/server/transformers/shared/normalized.ts b/src/server/transformers/shared/normalized.ts index c40caa7..ab8cf03 100644 --- a/src/server/transformers/shared/normalized.ts +++ b/src/server/transformers/shared/normalized.ts @@ -88,7 +88,7 @@ export type TransformerMetadata = { }; export type NormalizedRequest = { - protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli'; model: string; stream: boolean; rawBody: unknown; @@ -98,7 +98,7 @@ export type NormalizedRequest = { }; export type NormalizedResponseEnvelope = { - protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli'; model: string; final: import('./chatFormatsCore.js').NormalizedFinalResponse; usage?: unknown; @@ -107,7 +107,7 @@ export type NormalizedResponseEnvelope = { }; export type NormalizedStreamEnvelope = { - protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli'; model: string; event: import('./chatFormatsCore.js').NormalizedStreamEvent; metadata?: TransformerMetadata; diff --git a/src/server/transformers/shared/protocolModel.ts b/src/server/transformers/shared/protocolModel.ts index 4a28964..e80b148 100644 --- a/src/server/transformers/shared/protocolModel.ts +++ b/src/server/transformers/shared/protocolModel.ts @@ -7,7 +7,8 @@ export type TransformerProtocol = | 'openai/chat' | 'anthropic/messages' | 'openai/responses' - | 'gemini/generate-content'; + | 'gemini/generate-content' + | 'gemini-cli/generate-content'; export type ProtocolRequestEnvelope< TProtocol extends TransformerProtocol = TransformerProtocol, diff --git a/src/web/App.sidebar.test.ts b/src/web/App.sidebar.test.ts index 6b41f8e..70500fa 100644 --- a/src/web/App.sidebar.test.ts +++ b/src/web/App.sidebar.test.ts @@ -10,4 +10,23 @@ describe('App sidebar config', () => { expect(source).not.toContain("{ to: '/accounts', label: '账号'"); expect(source).not.toContain("{ to: '/tokens', label: '令牌管理'"); }); + + it('places downstream key navigation under 控制台 instead of 系统', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); + const consoleGroupIndex = source.indexOf("label: '控制台'"); + const downstreamIndex = source.indexOf("{ to: '/downstream-keys', label: '下游密钥'"); + const systemGroupIndex = source.indexOf("label: '系统'"); + + expect(consoleGroupIndex).toBeGreaterThanOrEqual(0); + expect(downstreamIndex).toBeGreaterThan(consoleGroupIndex); + expect(systemGroupIndex).toBeGreaterThan(downstreamIndex); + }); + + it('adds standalone OAuth 管理 navigation entry', () => { + const source = readFileSync(resolve(process.cwd(), 'src/web/App.tsx'), 'utf8'); + + expect(source).toContain("{ to: '/oauth', label: 'OAuth 管理'"); + expect(source).toContain("const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));"); + expect(source).toContain('} />'); + }); }); diff --git a/src/web/App.tsx b/src/web/App.tsx index bd941f7..cc68962 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -26,6 +26,7 @@ const CheckinLog = lazy(() => import('./pages/CheckinLog.js')); const TokenRoutes = lazy(() => import('./pages/TokenRoutes.js')); const ProxyLogs = lazy(() => import('./pages/ProxyLogs.js')); const Settings = lazy(() => import('./pages/Settings.js')); +const DownstreamKeys = lazy(() => import('./pages/DownstreamKeys.js')); const ImportExport = lazy(() => import('./pages/ImportExport.js')); const NotificationSettings = lazy(() => import('./pages/NotificationSettings.js')); const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js')); @@ -33,6 +34,7 @@ const Models = lazy(() => import('./pages/Models.js')); const About = lazy(() => import('./pages/About.js')); const ModelTester = lazy(() => import('./pages/ModelTester.js')); const Monitors = lazy(() => import('./pages/Monitors.js')); +const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js')); type ThemeMode = 'system' | 'light' | 'dark'; @@ -327,6 +329,8 @@ const sidebarGroups = [ { to: '/', label: '仪表盘', icon: }, { to: '/sites', label: '站点', icon: }, { to: '/accounts', label: '连接管理', icon: }, + { to: '/oauth', label: 'OAuth 管理', icon: }, + { to: '/downstream-keys', label: '下游密钥', icon: }, { to: '/checkin', label: '签到记录', icon: }, { to: '/routes', label: '路由', icon: }, { to: '/logs', label: '使用日志', icon: }, @@ -761,12 +765,14 @@ function AppShell() { } /> } /> } /> + } /> } /> } /> } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/web/api.ts b/src/web/api.ts index ee1c165..fd39f2e 100644 --- a/src/web/api.ts +++ b/src/web/api.ts @@ -204,6 +204,10 @@ export type ProxyLogListItem = { siteName?: string | null; siteUrl?: string | null; errorMessage?: string | null; + downstreamKeyId?: number | null; + downstreamKeyName?: string | null; + downstreamKeyGroupName?: string | null; + downstreamKeyTags?: string[]; promptTokens?: number | null; completionTokens?: number | null; estimatedCost?: number | null; @@ -242,6 +246,96 @@ export type ProxyLogsResponse = { summary: ProxyLogsSummary; }; +export type OAuthProviderInfo = { + provider: string; + label: string; + platform: string; + enabled: boolean; + loginType: 'oauth'; + requiresProjectId: boolean; + supportsDirectAccountRouting: boolean; + supportsCloudValidation: boolean; + supportsNativeProxy: boolean; +}; + +export type OAuthStartInstructions = { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + sshTunnelCommand?: string; + sshTunnelKeyCommand?: string; +}; + +export type OAuthStartResponse = { + provider: string; + state: string; + authorizationUrl: string; + instructions: OAuthStartInstructions; +}; + +export type OAuthSessionInfo = { + provider: string; + state: string; + status: 'pending' | 'success' | 'error'; + accountId?: number; + siteId?: number; + error?: string; +}; + +export type OAuthQuotaWindowInfo = { + supported: boolean; + limit?: number | null; + used?: number | null; + remaining?: number | null; + resetAt?: string | null; + message?: string | null; +}; + +export type OAuthQuotaInfo = { + status: 'supported' | 'unsupported' | 'error'; + source: 'official' | 'reverse_engineered'; + lastSyncAt?: string | null; + lastError?: string | null; + providerMessage?: string | null; + subscription?: { + planType?: string | null; + activeStart?: string | null; + activeUntil?: string | null; + } | null; + windows: { + fiveHour: OAuthQuotaWindowInfo; + sevenDay: OAuthQuotaWindowInfo; + }; + lastLimitResetAt?: string | null; +}; + +export type OAuthConnectionInfo = { + accountId: number; + siteId: number; + provider: string; + username?: string | null; + email?: string | null; + accountKey?: string | null; + planType?: string | null; + projectId?: string | null; + modelCount: number; + modelsPreview: string[]; + status: 'healthy' | 'abnormal'; + quota?: OAuthQuotaInfo | null; + routeChannelCount?: number; + lastModelSyncAt?: string | null; + lastModelSyncError?: string | null; + site?: { id: number; name: string; url: string; platform: string } | null; +}; + +export type OAuthConnectionsResponse = { + items: OAuthConnectionInfo[]; + total: number; + limit: number; + offset: number; +}; + export const api = { // Sites getSites: () => request('/api/sites'), @@ -353,6 +447,31 @@ export const api = { // Search search: (query: string) => request('/api/search', { method: 'POST', body: JSON.stringify({ query, limit: 20 }) }), + // OAuth + getOAuthProviders: () => request('/api/oauth/providers') as Promise<{ providers: OAuthProviderInfo[] }>, + startOAuthProvider: (provider: string, data?: { accountId?: number; projectId?: string }) => request(`/api/oauth/providers/${encodeURIComponent(provider)}/start`, { + method: 'POST', + body: JSON.stringify(data || {}), + }) as Promise, + getOAuthSession: (state: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}`) as Promise, + submitOAuthManualCallback: (state: string, callbackUrl: string) => request(`/api/oauth/sessions/${encodeURIComponent(state)}/manual-callback`, { + method: 'POST', + body: JSON.stringify({ callbackUrl }), + }) as Promise<{ success: true }>, + getOAuthConnections: (params?: { limit?: number; offset?: number }) => + request(`/api/oauth/connections${buildQueryString(params)}`) as Promise, + refreshOAuthConnectionQuota: (accountId: number) => request(`/api/oauth/connections/${accountId}/quota/refresh`, { + method: 'POST', + body: JSON.stringify({}), + }) as Promise<{ success: true; quota: OAuthQuotaInfo }>, + rebindOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}/rebind`, { + method: 'POST', + body: JSON.stringify({}), + }) as Promise, + deleteOAuthConnection: (accountId: number) => request(`/api/oauth/connections/${accountId}`, { + method: 'DELETE', + }) as Promise<{ success: true }>, + // Events getEvents: (params?: string) => request(`/api/events${params ? '?' + params : ''}`), getEventCount: () => request('/api/events/count'), @@ -401,9 +520,26 @@ export const api = { deleteDownstreamApiKey: (id: number) => request(`/api/downstream-keys/${id}`, { method: 'DELETE', }), + batchDownstreamApiKeys: (data: { + ids: number[]; + action: 'enable' | 'disable' | 'delete' | 'resetUsage' | 'updateMetadata'; + groupOperation?: 'keep' | 'set' | 'clear'; + groupName?: string; + tagOperation?: 'keep' | 'append'; + tags?: string[]; + }) => + request('/api/downstream-keys/batch', { + method: 'POST', + body: JSON.stringify(data), + }), resetDownstreamApiKeyUsage: (id: number) => request(`/api/downstream-keys/${id}/reset-usage`, { method: 'POST', }), + getDownstreamApiKeysSummary: (params?: { range?: '24h' | '7d' | 'all'; status?: 'all' | 'enabled' | 'disabled'; search?: string }) => + request(`/api/downstream-keys/summary${buildQueryString(params)}`), + getDownstreamApiKeyOverview: (id: number) => request(`/api/downstream-keys/${id}/overview`), + getDownstreamApiKeyTrend: (id: number, params?: { range?: '24h' | '7d' | 'all' }) => + request(`/api/downstream-keys/${id}/trend${buildQueryString(params)}`), exportBackup: (type: 'all' | 'accounts' | 'preferences' = 'all') => request(`/api/settings/backup/export?type=${encodeURIComponent(type)}`), importBackup: (data: any) => diff --git a/src/web/components/CenteredModal.tsx b/src/web/components/CenteredModal.tsx index a0b2c26..f8dffb3 100644 --- a/src/web/components/CenteredModal.tsx +++ b/src/web/components/CenteredModal.tsx @@ -10,6 +10,9 @@ type CenteredModalProps = { footer?: React.ReactNode; maxWidth?: number; bodyStyle?: React.CSSProperties; + closeOnBackdrop?: boolean; + closeOnEscape?: boolean; + showCloseButton?: boolean; }; export default function CenteredModal({ @@ -20,6 +23,9 @@ export default function CenteredModal({ footer, maxWidth = 860, bodyStyle, + closeOnBackdrop = false, + closeOnEscape = false, + showCloseButton = true, }: CenteredModalProps) { const presence = useAnimatedVisibility(open, 220); @@ -33,7 +39,7 @@ export default function CenteredModal({ }, [open]); useEffect(() => { - if (!open || typeof document === 'undefined') return; + if (!open || !closeOnEscape || typeof document === 'undefined') return; const handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; @@ -41,21 +47,33 @@ export default function CenteredModal({ return () => { document.removeEventListener('keydown', handleKeydown); }; - }, [open, onClose]); + }, [closeOnEscape, open, onClose]); if (!presence.shouldRender) return null; const modal = (
event.stopPropagation()} > -
{title}
+
+
{title}
+ {showCloseButton ? ( + + ) : null} +
{children}
diff --git a/src/web/components/SiteBadgeLink.tsx b/src/web/components/SiteBadgeLink.tsx new file mode 100644 index 0000000..c18bc1b --- /dev/null +++ b/src/web/components/SiteBadgeLink.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +type SiteBadgeLinkProps = { + siteId?: number | null; + siteName?: string | null; + className?: string; + badgeClassName?: string; + badgeStyle?: React.CSSProperties; +}; + +export default function SiteBadgeLink({ + siteId, + siteName, + className = 'badge-link', + badgeClassName = 'badge badge-muted', + badgeStyle, +}: SiteBadgeLinkProps) { + const label = String(siteName || '').trim() || '-'; + const normalizedSiteId = Number(siteId); + + if (!Number.isFinite(normalizedSiteId) || normalizedSiteId <= 0) { + return ( + + {label} + + ); + } + + return ( + + + {label} + + + ); +} diff --git a/src/web/components/Toast.tsx b/src/web/components/Toast.tsx index bd7b687..99b19b7 100644 --- a/src/web/components/Toast.tsx +++ b/src/web/components/Toast.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useState, useCallback, useRef } from 'react'; +import React, { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react'; type ToastType = 'success' | 'error' | 'info'; @@ -47,12 +47,18 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { setTimeout(() => removeToast(id), 3200); }, [removeToast]); - const value: ToastContextValue = { + const success = useCallback((msg: string) => addToast('success', msg), [addToast]); + const error = useCallback((msg: string) => addToast('error', msg), [addToast]); + const info = useCallback((msg: string) => addToast('info', msg), [addToast]); + + // Keep the context object stable so effect dependencies on `useToast()` do not refire + // every time the toast list changes. + const value = useMemo(() => ({ toast: addToast, - success: useCallback((msg: string) => addToast('success', msg), [addToast]), - error: useCallback((msg: string) => addToast('error', msg), [addToast]), - info: useCallback((msg: string) => addToast('info', msg), [addToast]), - }; + success, + error, + info, + }), [addToast, error, info, success]); return ( diff --git a/src/web/components/centered-modal.test.tsx b/src/web/components/centered-modal.test.tsx index 4fa8346..5ffcf46 100644 --- a/src/web/components/centered-modal.test.tsx +++ b/src/web/components/centered-modal.test.tsx @@ -1,8 +1,46 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { act, create } from 'react-test-renderer'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; +import CenteredModal from './CenteredModal.js'; describe('CenteredModal component', () => { + it('does not close on backdrop click by default and exposes an explicit close button', async () => { + const onClose = vi.fn(); + let root: ReturnType | null = null; + + try { + await act(async () => { + root = create( + +
content
+
, + ); + }); + + const closeButton = root.root.find((node) => ( + node.type === 'button' + && node.props['aria-label'] === '关闭弹框' + )); + + const backdrop = root.root.find((node) => ( + typeof node.props.className === 'string' + && node.props.className.includes('modal-backdrop') + )); + + expect(backdrop.props.onClick).toBeUndefined(); + expect(onClose).not.toHaveBeenCalled(); + + await act(async () => { + closeButton.props.onClick(); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + } finally { + root?.unmount(); + } + }); + it('uses the shared centered modal shell pattern', () => { const source = readFileSync(resolve(process.cwd(), 'src/web/components/CenteredModal.tsx'), 'utf8'); diff --git a/src/web/components/charts/DownstreamKeyTrendChart.tsx b/src/web/components/charts/DownstreamKeyTrendChart.tsx new file mode 100644 index 0000000..fe88470 --- /dev/null +++ b/src/web/components/charts/DownstreamKeyTrendChart.tsx @@ -0,0 +1,205 @@ +import React, { useMemo, useState } from 'react'; +import { VChart } from '@visactor/react-vchart'; + +type Metric = 'tokens' | 'requests' | 'cost'; + +const METRIC_OPTIONS: Array<{ key: Metric; label: string }> = [ + { key: 'tokens', label: 'Tokens' }, + { key: 'requests', label: '请求数' }, + { key: 'cost', label: '成本' }, +]; + +export type DownstreamKeyTrendBucket = { + startUtc: string | null; + totalRequests: number; + totalTokens: number; + totalCost: number; + successRate: number | null; +}; + +export default function DownstreamKeyTrendChart({ + buckets, + loading, + height = 260, +}: { + buckets: DownstreamKeyTrendBucket[]; + loading?: boolean; + height?: number; +}) { + const [metric, setMetric] = useState('tokens'); + + const flatData = useMemo(() => { + if (!Array.isArray(buckets) || buckets.length === 0) return []; + return buckets + .map((bucket) => { + const date = bucket.startUtc || ''; + const value = metric === 'tokens' + ? Number(bucket.totalTokens || 0) + : (metric === 'requests' + ? Number(bucket.totalRequests || 0) + : Number(bucket.totalCost || 0)); + return { date, value }; + }) + .filter((row) => row.date.length > 0); + }, [buckets, metric]); + + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!flatData || flatData.length === 0) { + return ( +
+
+ +
+
+
暂无趋势数据
+
该 Key 在所选时间范围内没有可用的 tokens 记录
+
+
+ ); + } + + const spec: Record = { + type: 'area' as const, + data: [{ id: 'data', values: flatData }], + xField: 'date', + yField: 'value', + area: { + style: { + curveType: 'monotone', + fillOpacity: 0.2, + }, + }, + line: { + style: { + curveType: 'monotone', + lineWidth: 2, + }, + }, + point: { visible: false }, + axes: [ + { + orient: 'bottom', + label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } }, + domainLine: { style: { stroke: 'var(--color-border-light)' } }, + tick: { style: { stroke: 'var(--color-border-light)' } }, + }, + { + orient: 'left', + label: { style: { fontSize: 11, fill: 'var(--color-text-muted)' } }, + grid: { style: { stroke: 'var(--color-border-light)', lineDash: [4, 4] } }, + domainLine: { visible: false }, + }, + ], + tooltip: { + dimension: { + title: { value: (datum: Record) => String(datum?.date || '') }, + content: [ + { + key: () => METRIC_OPTIONS.find((opt) => opt.key === metric)?.label || 'Value', + value: (datum: Record) => { + const value = Number(datum?.value ?? 0); + if (metric === 'cost') return `$${value.toFixed(6)}`; + return value.toLocaleString(); + }, + }, + ], + }, + }, + color: ['var(--color-primary)'], + background: 'transparent', + animationAppear: { + area: { type: 'fadeIn', duration: 500, easing: 'cubicOut' }, + line: { type: 'clipIn', duration: 700, easing: 'cubicOut' }, + }, + padding: { left: 8, right: 16, top: 8, bottom: 8 }, + }; + + return ( +
+
+ +
+
+ +
+
+ ); +} + +function MetricToggle({ + metric, + onChange, +}: { + metric: Metric; + onChange: (m: Metric) => void; +}) { + return ( +
+ {METRIC_OPTIONS.map((opt) => ( + + ))} +
+ ); +} + +const containerStyle: React.CSSProperties = { + background: 'var(--color-bg-card)', + borderRadius: 'var(--radius-md)', + border: '1px solid var(--color-border-light)', + boxShadow: 'var(--shadow-card)', + padding: 16, +}; + +const headerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 10, +}; + +const toggleGroupStyle: React.CSSProperties = { + display: 'inline-flex', + gap: 0, + borderRadius: 'var(--radius-sm)', + border: '1px solid var(--color-border)', + overflow: 'hidden', +}; + +const toggleBtnBase: React.CSSProperties = { + padding: '6px 12px', + fontSize: 12, + fontWeight: 500, + cursor: 'pointer', + border: 'none', + transition: 'all 0.2s ease', + fontFamily: 'inherit', +}; + +const toggleBtnActive: React.CSSProperties = { + background: 'var(--color-primary)', + color: '#ffffff', +}; + +const toggleBtnInactive: React.CSSProperties = { + background: 'var(--color-bg-card)', + color: 'var(--color-text-secondary)', +}; diff --git a/src/web/components/site-badge-link.test.tsx b/src/web/components/site-badge-link.test.tsx new file mode 100644 index 0000000..b01b69a --- /dev/null +++ b/src/web/components/site-badge-link.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { create } from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; +import SiteBadgeLink from './SiteBadgeLink.js'; + +describe('SiteBadgeLink', () => { + it('renders a focus-navigation link when site id is valid', () => { + const root = create( + + + , + ); + + const link = root.root.findByType('a'); + expect(String(link.props.href || '')).toContain('/sites?focusSiteId=7'); + expect(String(link.props.className || '')).toContain('badge-link'); + expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Demo Site'); + + root.unmount(); + }); + + it('falls back to plain badge text when site id is invalid', () => { + const root = create( + + + , + ); + + expect(root.root.findAllByType('a')).toHaveLength(0); + expect(root.root.findByProps({ className: 'badge badge-muted' }).children.join('')).toContain('Unknown Site'); + + root.unmount(); + }); +}); diff --git a/src/web/index.css b/src/web/index.css index 3151f7e..d30401a 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -1891,6 +1891,149 @@ body { outline: none; } +.downstream-key-modal-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.downstream-key-modal-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} + +.downstream-key-modal-label { + font-size: 12px; + line-height: 1.4; + font-weight: 500; + color: var(--color-text-muted); +} + +.downstream-key-modal-field-full { + width: 100%; +} + +.downstream-key-modal-toggle { + display: flex; + align-items: center; + gap: 10px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 10px 12px; + background: var(--color-bg); + cursor: pointer; + min-height: 42px; +} + +.downstream-key-modal-toggle-title { + font-size: 13px; + line-height: 1.35; + font-weight: 600; + color: var(--color-text-primary); +} + +.downstream-key-modal-help { + font-size: 12px; + line-height: 1.55; + color: var(--color-text-muted); +} + +.downstream-key-modal-meta { + font-size: 12px; + line-height: 1.45; + color: var(--color-text-secondary); +} + +.downstream-key-modal-section-title { + font-size: 13px; + line-height: 1.35; + font-weight: 600; + color: var(--color-text-primary); +} + +.downstream-key-advanced { + display: flex; + flex-direction: column; + gap: 10px; +} + +.downstream-key-advanced-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg); + color: var(--color-text-primary); + cursor: pointer; + font-size: 13px; + font-weight: 600; + line-height: 1.35; +} + +.downstream-key-advanced-toggle:hover { + border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border)); + background: color-mix(in srgb, var(--color-primary) 5%, var(--color-bg)); +} + +.downstream-key-advanced-content { + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px; + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-bg) 60%, var(--color-bg-card)); +} + +.downstream-key-advanced-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.downstream-key-advanced-panel { + border: 1px solid var(--color-border-light); + border-radius: var(--radius-md); + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + background: var(--color-bg-card); +} + +.downstream-key-modal-grid input, +.downstream-key-modal-grid textarea, +.downstream-key-advanced input, +.downstream-key-advanced textarea { + font-size: 13px; +} + +.downstream-key-modal-grid input::placeholder, +.downstream-key-modal-grid textarea::placeholder, +.downstream-key-advanced input::placeholder, +.downstream-key-advanced textarea::placeholder { + font-size: 13px; + color: color-mix(in srgb, var(--color-text-muted) 92%, white); +} + +.downstream-key-modal-grid textarea, +.downstream-key-advanced textarea { + line-height: 1.5; +} + +@media (max-width: 900px) { + .downstream-key-modal-grid, + .downstream-key-advanced-grid { + grid-template-columns: 1fr; + } +} + .route-enable-toggle { border: 1px solid transparent; transition: background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; @@ -2455,11 +2598,47 @@ textarea { .modal-header { padding: 20px 24px 0; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; font-size: 16px; font-weight: 600; color: var(--color-text-primary); } +.modal-title { + min-width: 0; + flex: 1; +} + +.modal-close-button { + border: 0; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + font-size: 24px; + line-height: 1; + padding: 0; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + transition: background 0.2s ease, color 0.2s ease; +} + +.modal-close-button:hover { + background: var(--color-bg-secondary); + color: var(--color-text-primary); +} + +.modal-close-button:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + .modal-body { padding: 16px 24px; } @@ -4724,4 +4903,4 @@ textarea { .route-card-grid { grid-template-columns: 1fr; } -} \ No newline at end of file +} diff --git a/src/web/pages/About.tsx b/src/web/pages/About.tsx index 3f658d0..b3c5e62 100644 --- a/src/web/pages/About.tsx +++ b/src/web/pages/About.tsx @@ -1,7 +1,7 @@ import { tr } from '../i18n.js'; import { SITE_DOCS_URL } from '../docsLink.js'; -const VERSION = '1.2.2'; +const VERSION = '1.2.3'; const FEATURES = [ { icon: '🌐', title: '统一代理网关', desc: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' }, diff --git a/src/web/pages/Accounts.tsx b/src/web/pages/Accounts.tsx index 498d274..9b279e0 100644 --- a/src/web/pages/Accounts.tsx +++ b/src/web/pages/Accounts.tsx @@ -7,6 +7,7 @@ import ModernSelect from '../components/ModernSelect.js'; import { MobileCard, MobileField } from '../components/MobileCard.js'; import { useIsMobile } from '../components/useIsMobile.js'; import DeleteConfirmModal from '../components/DeleteConfirmModal.js'; +import SiteBadgeLink from '../components/SiteBadgeLink.js'; import { buildAddAccountPrereqHint, buildVerifyFailureHint, @@ -358,7 +359,9 @@ export default function Accounts() { skipModelFetch: tokenForm.skipModelFetch, }); closeAddPanel(); - if (result.tokenType === 'apikey') { + if (result.queued) { + toast.info(result.message || '账号已添加,后台正在同步初始化信息。'); + } else if (result.tokenType === 'apikey') { toast.success('已添加为 API Key 账号(可用于代理转发)'); } else { const parts: string[] = []; @@ -1653,21 +1656,12 @@ export default function Accounts() {
- - {a.site?.name || '-'} - - - ) : ( - - {a.site?.name || '-'} - + value={( + )} /> - {a.site?.url ? ( - - - {a.site?.name || '-'} - - - ) : ( - - {a.site?.name || '-'} - - )} + {(() => { diff --git a/src/web/pages/DownstreamKeys.test.tsx b/src/web/pages/DownstreamKeys.test.tsx new file mode 100644 index 0000000..2af5761 --- /dev/null +++ b/src/web/pages/DownstreamKeys.test.tsx @@ -0,0 +1,547 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, create, type ReactTestInstance } from 'react-test-renderer'; +import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from '../components/Toast.js'; +import DownstreamKeys from './DownstreamKeys.js'; + +const { apiMock } = vi.hoisted(() => ({ + apiMock: { + getDownstreamApiKeysSummary: vi.fn(), + getDownstreamApiKeys: vi.fn(), + getRoutesLite: vi.fn(), + getDownstreamApiKeyOverview: vi.fn(), + getDownstreamApiKeyTrend: vi.fn(), + createDownstreamApiKey: vi.fn(), + batchDownstreamApiKeys: vi.fn(), + updateDownstreamApiKey: vi.fn(), + deleteDownstreamApiKey: vi.fn(), + resetDownstreamApiKeyUsage: vi.fn(), + }, +})); + +vi.mock('../api.js', () => ({ api: apiMock })); + +vi.mock('react-dom', async () => { + const actual = await vi.importActual('react-dom'); + return { + ...actual, + createPortal: (node: unknown) => node, + }; +}); + +vi.mock('../components/useAnimatedVisibility.js', () => ({ + useAnimatedVisibility: (open: boolean) => ({ + shouldRender: open, + isVisible: open, + }), +})); + +vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({ + default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => ( +
{`trend:${buckets.length}`}
+ ), +})); + +vi.mock('../components/ModernSelect.js', () => ({ + default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => ( + + ), +})); + +function collectText(node: ReactTestInstance): string { + return (node.children || []).map((child) => { + if (typeof child === 'string') return child; + return collectText(child); + }).join(''); +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function buildSummaryItem(overrides?: Partial) { + return { + id: 1, + name: 'smoke-key', + keyMasked: 'sk-s****0315', + enabled: true, + description: 'local smoke', + groupName: '项目A', + tags: ['移动端', 'VIP'], + expiresAt: null, + maxCost: null, + usedCost: 0, + maxRequests: null, + usedRequests: 0, + supportedModels: ['gpt-4.1-mini'], + allowedRouteIds: [11], + siteWeightMultipliers: {}, + lastUsedAt: '2026-03-15T08:27:25.378Z', + createdAt: '2026-03-15T08:27:25.378Z', + updatedAt: '2026-03-15T08:27:25.378Z', + rangeUsage: { + totalRequests: 3, + successRequests: 2, + failedRequests: 1, + successRate: 66.7, + totalTokens: 4200, + totalCost: 0.42, + }, + ...overrides, + }; +} + +function buildRawItem(overrides?: Partial) { + return { + id: 1, + name: 'smoke-key', + key: 'sk-smoke-0315', + keyMasked: 'sk-s****0315', + description: 'local smoke', + groupName: '项目A', + tags: ['移动端', 'VIP'], + enabled: true, + expiresAt: null, + maxCost: null, + usedCost: 0, + maxRequests: null, + usedRequests: 0, + supportedModels: ['gpt-4.1-mini'], + allowedRouteIds: [11], + siteWeightMultipliers: {}, + lastUsedAt: '2026-03-15T08:27:25.378Z', + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + (globalThis as any).document = { + body: { style: {} }, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }; + apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem()] }); + apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem()] }); + apiMock.getRoutesLite.mockResolvedValue([ + { id: 11, modelPattern: 'claude-*', displayName: '默认群组', enabled: true }, + { id: 12, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true }, + ]); + apiMock.getDownstreamApiKeyOverview.mockResolvedValue({ + success: true, + item: buildSummaryItem(), + usage: { + last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 }, + last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 }, + all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 }, + }, + }); + apiMock.getDownstreamApiKeyTrend.mockResolvedValue({ + success: true, + buckets: [ + { startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 }, + { startUtc: '2026-03-15T09:00:00.000Z', totalRequests: 1, totalTokens: 3000, totalCost: 0.3, successRate: 0 }, + ], + }); + apiMock.createDownstreamApiKey.mockResolvedValue({ success: true }); + apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1], failedItems: [] }); + apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true }); + apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true }); + apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true }); +}); + +afterEach(() => { + vi.clearAllMocks(); + delete (globalThis as any).document; +}); + +describe('DownstreamKeys page', () => { + it('loads management data and renders merged row content', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + expect(apiMock.getDownstreamApiKeysSummary).toHaveBeenCalledWith({ range: '24h' }); + expect(apiMock.getDownstreamApiKeys).toHaveBeenCalled(); + expect(apiMock.getRoutesLite).toHaveBeenCalled(); + + const text = collectText(root!.root); + expect(text).toContain('下游密钥'); + expect(text).toContain('范围概览'); + expect(text).toContain('筛选与列表'); + expect(text).toContain('smoke-key'); + expect(text).toContain('sk-s****0315'); + expect(text).toContain('默认群组'); + expect(text).toContain('4.2K'); + expect(text).toContain('主分组'); + expect(text).toContain('移动端'); + } finally { + root?.unmount(); + } + }); + + it('filters rows locally by search and status', async () => { + apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ + success: true, + items: [ + buildSummaryItem(), + buildSummaryItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', keyMasked: 'sk-b****0315' }), + ], + }); + apiMock.getDownstreamApiKeys.mockResolvedValue({ + success: true, + items: [ + buildRawItem(), + buildRawItem({ id: 2, name: 'batch-key', enabled: false, description: 'other project', key: 'sk-batch-0315', keyMasked: 'sk-b****0315', supportedModels: ['gpt-4o-mini'], allowedRouteIds: [] }), + ], + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const input = root!.root.findAllByType('input').find((node) => node.props.placeholder === '搜索名称、备注、模型、主分组或标签'); + await act(async () => { + input!.props.onChange({ target: { value: 'batch' } }); + }); + await flushMicrotasks(); + expect(collectText(root!.root)).toContain('batch-key'); + expect(collectText(root!.root)).not.toContain('smoke-key'); + + const select = root!.root.findAllByType('select').find((node) => collectText(node).includes('仅禁用')); + await act(async () => { + select.props.onChange({ target: { value: 'disabled' } }); + }); + await flushMicrotasks(); + expect(collectText(root!.root)).toContain('batch-key'); + expect(collectText(root!.root)).not.toContain('smoke-key'); + } finally { + root?.unmount(); + } + }); + + it('supports create flow and drawer trend loading', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0]; + await act(async () => { + createBtn.props.onClick(); + }); + await flushMicrotasks(); + + expect(collectText(root!.root)).toContain('高级配置'); + expect(collectText(root!.root)).not.toContain('站点倍率 JSON'); + + const inputs = root!.root.findAllByType('input'); + const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A'); + const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端'); + const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...'); + expect(tagInput?.props.style?.fontSize).toBe(13); + await act(async () => { + nameInput!.props.onChange({ target: { value: 'new-key' } }); + keyInput!.props.onChange({ target: { value: 'sk-new-key-0315' } }); + }); + await flushMicrotasks(); + + const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0]; + await act(async () => { + saveBtn.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({ + name: 'new-key', + key: 'sk-new-key-0315', + siteWeightMultipliers: {}, + })); + + const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0]; + await act(async () => { + row.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledWith(1); + expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '24h' }); + expect(collectText(root!.root)).toContain('trend:2'); + + const range7d = root!.root.findAll((node) => node.type === 'button' && collectText(node) === '7d')[0]; + await act(async () => { + range7d.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledWith(1, { range: '7d' }); + expect(collectText(root!.root)).toContain('12.4K'); + expect(collectText(root!.root)).toContain('9'); + expect(collectText(root!.root)).toContain('88.9%'); + } finally { + root?.unmount(); + } + }); + + it('does not refetch drawer trend repeatedly after a trend error toast', async () => { + apiMock.getDownstreamApiKeyTrend + .mockRejectedValueOnce(new Error('function date_trunc(unknown, text) does not exist')) + .mockResolvedValue({ + success: true, + buckets: [ + { startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 }, + ], + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const row = root!.root.findAll((node) => node.type === 'tr' && typeof node.props.onClick === 'function')[0]; + await act(async () => { + row.props.onClick(); + }); + await flushMicrotasks(); + await flushMicrotasks(); + + expect(apiMock.getDownstreamApiKeyOverview).toHaveBeenCalledTimes(1); + expect(apiMock.getDownstreamApiKeyTrend).toHaveBeenCalledTimes(1); + + const toastMessages = root!.root.findAll((node) => { + if (typeof node.props.className !== 'string') return false; + return node.props.className.includes('toast-error') && collectText(node).includes('function date_trunc(unknown, text) does not exist'); + }); + expect(toastMessages).toHaveLength(1); + } finally { + root?.unmount(); + } + }); + + it('separates exact models from group routes in advanced config and uses single-column layout', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0]; + await act(async () => { + createBtn.props.onClick(); + }); + await flushMicrotasks(); + + const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0]; + await act(async () => { + advancedBtn.props.onClick(); + }); + await flushMicrotasks(); + + const panels = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-panel'); + const modelPanel = panels.find((node) => collectText(node).includes('模型白名单')); + const groupPanel = panels.find((node) => collectText(node).includes('群组范围')); + const advancedGrid = root!.root.findAll((node) => node.props.className === 'downstream-key-advanced-grid')[0]; + + expect(modelPanel).toBeTruthy(); + expect(groupPanel).toBeTruthy(); + expect(collectText(modelPanel!)).toContain('gpt-4.1-mini'); + expect(collectText(modelPanel!)).not.toContain('默认群组'); + expect(collectText(modelPanel!)).not.toContain('claude-*'); + expect(collectText(groupPanel!)).toContain('默认群组'); + expect(collectText(groupPanel!)).not.toContain('GPT 4.1 Mini'); + expect(advancedGrid.props.style?.gridTemplateColumns).toBe('1fr'); + } finally { + root?.unmount(); + } + }); + + it('uses backend batch api for selected rows', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false); + await act(async () => { + checkbox!.props.onChange({ target: { checked: true } }); + }); + await flushMicrotasks(); + expect(collectText(root!.root)).toContain('已选 1 个密钥'); + + const batchButton = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量启用'))[0]; + await act(async () => { + batchButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({ + ids: [1], + action: 'enable', + }); + } finally { + root?.unmount(); + } + }); + + it('supports group and tag editing plus batch metadata update', async () => { + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const createBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('新增下游密钥'))[0]; + await act(async () => { + createBtn.props.onClick(); + }); + await flushMicrotasks(); + + const advancedBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('高级配置'))[0]; + await act(async () => { + advancedBtn.props.onClick(); + }); + await flushMicrotasks(); + + const inputs = root!.root.findAllByType('input'); + const nameInput = inputs.find((node) => node.props.placeholder === '例如:项目 A / 移动端'); + const keyInput = inputs.find((node) => node.props.placeholder === 'sk-...'); + const groupInput = inputs.find((node) => node.props.placeholder === '例如:VIP / 内部项目 / A组'); + const tagInput = inputs.find((node) => node.props.placeholder === '输入标签后按回车或逗号,例如:移动端、VIP、项目A'); + await act(async () => { + nameInput!.props.onChange({ target: { value: 'grouped-key' } }); + keyInput!.props.onChange({ target: { value: 'sk-grouped-key-0315' } }); + groupInput!.props.onChange({ target: { value: '项目B' } }); + tagInput!.props.onChange({ target: { value: '灰度,高优' } }); + }); + await flushMicrotasks(); + await act(async () => { + tagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() }); + }); + await flushMicrotasks(); + + const saveBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('创建密钥'))[0]; + await act(async () => { + saveBtn.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.createDownstreamApiKey).toHaveBeenCalledWith(expect.objectContaining({ + groupName: '项目B', + tags: ['灰度', '高优'], + })); + + const checkbox = root!.root.findAllByType('input').find((node) => node.props.type === 'checkbox' && typeof node.props.onChange === 'function' && node.props.checked === false); + await act(async () => { + checkbox!.props.onChange({ target: { checked: true } }); + }); + await flushMicrotasks(); + + const batchMetadataBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('批量归类/标签'))[0]; + await act(async () => { + batchMetadataBtn.props.onClick(); + }); + await flushMicrotasks(); + + const groupModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('统一设为主分组')); + const tagModeSelect = root!.root.findAllByType('select').find((node) => Array.isArray(node.props.children) && collectText(node).includes('追加标签')); + await act(async () => { + groupModeSelect!.props.onChange({ target: { value: 'set' } }); + tagModeSelect!.props.onChange({ target: { value: 'append' } }); + }); + await flushMicrotasks(); + + const batchGroupInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '例如:VIP / 内部项目'); + const batchTagInput = root!.root.findAllByType('input').find((node) => node.props.placeholder === '批量追加标签'); + await act(async () => { + batchGroupInput!.props.onChange({ target: { value: '统一项目组' } }); + batchTagInput!.props.onChange({ target: { value: '批量标签' } }); + }); + await flushMicrotasks(); + await act(async () => { + batchTagInput!.props.onKeyDown({ key: 'Enter', preventDefault: vi.fn() }); + }); + await flushMicrotasks(); + + const applyBtn = root!.root.findAll((node) => node.type === 'button' && collectText(node).includes('应用到所选密钥'))[0]; + await act(async () => { + applyBtn.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith(expect.objectContaining({ + ids: [1], + action: 'updateMetadata', + groupOperation: 'set', + groupName: '统一项目组', + tagOperation: 'append', + tags: ['批量标签'], + })); + } finally { + root?.unmount(); + } + }); +}); diff --git a/src/web/pages/DownstreamKeys.tsx b/src/web/pages/DownstreamKeys.tsx new file mode 100644 index 0000000..80b3981 --- /dev/null +++ b/src/web/pages/DownstreamKeys.tsx @@ -0,0 +1,1750 @@ +import React, { Suspense, lazy, useDeferredValue, useEffect, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { api } from '../api.js'; +import CenteredModal from '../components/CenteredModal.js'; +import DeleteConfirmModal from '../components/DeleteConfirmModal.js'; +import { useToast } from '../components/Toast.js'; +import ModernSelect from '../components/ModernSelect.js'; +import { useAnimatedVisibility } from '../components/useAnimatedVisibility.js'; +import { tr } from '../i18n.js'; + +const DownstreamKeyTrendChart = lazy(() => import('../components/charts/DownstreamKeyTrendChart.js')); +type DownstreamKeyTrendBucket = import('../components/charts/DownstreamKeyTrendChart.js').DownstreamKeyTrendBucket; + +type Range = '24h' | '7d' | 'all'; +type Status = 'all' | 'enabled' | 'disabled'; + +type SummaryItem = { + id: number; + name: string; + keyMasked: string; + enabled: boolean; + description: string | null; + groupName: string | null; + tags: string[]; + expiresAt: string | null; + maxCost: number | null; + usedCost: number; + maxRequests: number | null; + usedRequests: number; + supportedModels: string[]; + allowedRouteIds: number[]; + siteWeightMultipliers: Record; + lastUsedAt: string | null; + createdAt: string | null; + updatedAt: string | null; + rangeUsage: { + totalRequests: number; + successRequests: number; + failedRequests: number; + successRate: number | null; + totalTokens: number; + totalCost: number; + }; +}; + +type AggregateUsage = { + totalRequests: number; + successRequests: number; + failedRequests: number; + successRate: number | null; + totalTokens: number; + totalCost: number; +}; + +type OverviewResponse = { + success: boolean; + item: SummaryItem; + usage: null | { + last24h: AggregateUsage | null; + last7d: AggregateUsage | null; + all: AggregateUsage | null; + }; +}; + +type DownstreamApiKeyItem = { + id: number; + name: string; + key: string; + keyMasked: string; + description: string | null; + groupName: string | null; + tags: string[]; + enabled: boolean; + expiresAt: string | null; + maxCost: number | null; + usedCost: number; + maxRequests: number | null; + usedRequests: number; + supportedModels: string[]; + allowedRouteIds: number[]; + siteWeightMultipliers: Record; + lastUsedAt: string | null; +}; + +type ManagedItem = SummaryItem & { + key?: string; +}; + +type RouteSelectorItem = { + id: number; + modelPattern: string; + displayName?: string | null; + enabled: boolean; +}; + +type EditorForm = { + name: string; + key: string; + description: string; + groupName: string; + tags: string[]; + maxCost: string; + maxRequests: string; + expiresAt: string; + enabled: boolean; + selectedModels: string[]; + selectedGroupRouteIds: number[]; + siteWeightMultipliersText: string; +}; + +type DeleteConfirmState = + | null + | { mode: 'single'; item: ManagedItem } + | { mode: 'batch'; ids: number[] }; + +type TagMatchMode = 'any' | 'all'; + +type BatchMetadataForm = { + groupOperation: 'keep' | 'set' | 'clear'; + groupName: string; + tagOperation: 'keep' | 'append'; + tags: string[]; +}; + +function formatIso(value: string | null | undefined): string { + const text = (value || '').trim(); + if (!text) return '--'; + const date = new Date(text); + if (Number.isNaN(date.getTime())) return text; + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function formatMoney(value: number): string { + if (!Number.isFinite(value)) return '$0'; + if (value >= 1) return `$${value.toFixed(3)}`; + return `$${value.toFixed(6)}`; +} + +function formatCompactTokens(value: number): string { + if (!Number.isFinite(value)) return '0'; + if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`; + if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`; + if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return String(Math.trunc(value)); +} + +function toDateTimeLocal(isoString: string | null | undefined): string { + if (!isoString) return ''; + const ts = Date.parse(isoString); + if (!Number.isFinite(ts)) return ''; + const date = new Date(ts); + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const hh = String(date.getHours()).padStart(2, '0'); + const mi = String(date.getMinutes()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}T${hh}:${mi}`; +} + +function isExactModelPattern(modelPattern: string): boolean { + const normalized = modelPattern.trim(); + if (!normalized) return false; + if (normalized.toLowerCase().startsWith('re:')) return false; + return !/[\*\?]/.test(normalized); +} + +function routeTitle(route: RouteSelectorItem): string { + const displayName = (route.displayName || '').trim(); + return displayName || route.modelPattern; +} + +function isGroupRouteOption(route: RouteSelectorItem): boolean { + return !isExactModelPattern(route.modelPattern); +} + +function uniqStrings(values: string[]): string[] { + return [...new Set(values.map((value) => String(value || '').trim()).filter(Boolean))]; +} + +function normalizeTags(values: string[]): string[] { + const result: string[] = []; + const seen = new Set(); + for (const raw of values) { + const value = String(raw || '').trim(); + if (!value) continue; + const normalized = value.slice(0, 32); + const dedupeKey = normalized.toLowerCase(); + if (seen.has(dedupeKey)) continue; + seen.add(dedupeKey); + result.push(normalized); + if (result.length >= 20) break; + } + return result; +} + +function uniqIds(values: number[]): number[] { + return [...new Set(values.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0).map((value) => Math.trunc(value)))]; +} + +function parseTagText(value: string): string[] { + return normalizeTags(value.split(/[\r\n,,]+/g)); +} + +function parseInlineRegex(value: string): RegExp | null { + const text = value.trim(); + if (!text.startsWith('/') || text.length < 2) return null; + const lastSlash = text.lastIndexOf('/'); + if (lastSlash <= 0) return null; + const pattern = text.slice(1, lastSlash); + const flags = text.slice(lastSlash + 1); + if (!pattern) return null; + if (!/^[dgimsuvy]*$/i.test(flags)) return null; + try { + return new RegExp(pattern, flags || 'i'); + } catch { + return null; + } +} + +function buildSearchMatcher(search: string): ((haystack: string) => boolean) | null { + const text = search.trim(); + if (!text) return null; + const regex = parseInlineRegex(text); + if (regex) { + return (haystack: string) => regex.test(haystack); + } + const normalized = text.toLowerCase(); + return (haystack: string) => haystack.toLowerCase().includes(normalized); +} + +function splitSearchInput(value: string): { textSearch: string; inlineTags: string[] } { + const raw = value.trim(); + if (!raw) return { textSearch: '', inlineTags: [] }; + if (parseInlineRegex(raw)) return { textSearch: raw, inlineTags: [] }; + + const parts = raw.split(/[\r\n,,]+/g).map((item) => item.trim()).filter(Boolean); + if (parts.length <= 1) return { textSearch: raw, inlineTags: [] }; + + return { + textSearch: '', + inlineTags: normalizeTags(parts), + }; +} + +function tagChipStyle(kind: 'normal' | 'accent' = 'normal'): React.CSSProperties { + return { + display: 'inline-flex', + alignItems: 'center', + gap: 6, + padding: '2px 8px', + borderRadius: 999, + fontSize: 11, + border: '1px solid var(--color-border-light)', + color: kind === 'accent' ? 'var(--color-primary)' : 'var(--color-text-secondary)', + background: kind === 'accent' + ? 'color-mix(in srgb, var(--color-primary) 10%, transparent)' + : 'var(--color-bg-card)', + }; +} + +function buildEditorForm(item?: ManagedItem | DownstreamApiKeyItem | null): EditorForm { + return { + name: item?.name || '', + key: item?.key || '', + description: item?.description || '', + groupName: item?.groupName || '', + tags: normalizeTags(Array.isArray(item?.tags) ? item!.tags : []), + maxCost: item?.maxCost === null || item?.maxCost === undefined ? '' : String(item.maxCost), + maxRequests: item?.maxRequests === null || item?.maxRequests === undefined ? '' : String(item.maxRequests), + expiresAt: toDateTimeLocal(item?.expiresAt), + enabled: item?.enabled ?? true, + selectedModels: uniqStrings(Array.isArray(item?.supportedModels) ? item!.supportedModels : []), + selectedGroupRouteIds: uniqIds(Array.isArray(item?.allowedRouteIds) ? item!.allowedRouteIds : []), + siteWeightMultipliersText: JSON.stringify(item?.siteWeightMultipliers || {}, null, 2), + }; +} + +function summarizeModelLimit(models: string[]): string { + if (!Array.isArray(models) || models.length === 0) return '全部模型'; + if (models.length === 1) return models[0]; + return `${models[0]} +${models.length - 1}`; +} + +function summarizeRouteLimit(routeIds: number[], routeMap: Map): string { + if (!Array.isArray(routeIds) || routeIds.length === 0) return '全部群组'; + const names = routeIds + .map((id) => routeMap.get(id)) + .filter(Boolean) + .map((item) => routeTitle(item!)); + if (names.length === 0) return `${routeIds.length} 个群组`; + if (names.length === 1) return names[0]; + return `${names[0]} +${names.length - 1}`; +} + +function summarizeSiteWeightMultipliers(weights: Record | undefined): string { + const entries = Object.entries(weights || {}); + if (entries.length === 0) return '默认倍率'; + if (entries.length === 1) return `${entries[0][0]} => ${entries[0][1]}`; + return `${entries[0][0]} => ${entries[0][1]} +${entries.length - 1}`; +} + +function summarizeTags(tags: string[]): string { + if (!Array.isArray(tags) || tags.length === 0) return '无标签'; + if (tags.length === 1) return tags[0]; + return `${tags[0]} +${tags.length - 1}`; +} + +function TagChips({ + tags, + accent = false, + maxVisible = 3, +}: { + tags: string[]; + accent?: boolean; + maxVisible?: number; +}) { + if (!Array.isArray(tags) || tags.length === 0) { + return 无标签; + } + + const visible = tags.slice(0, maxVisible); + const hidden = tags.length - visible.length; + return ( +
+ {visible.map((tag) => ( + + {tag} + + ))} + {hidden > 0 ? {`+${hidden}`} : null} +
+ ); +} + +function TagInput({ + tags, + onChange, + suggestions = [], + placeholder, +}: { + tags: string[]; + onChange: (tags: string[]) => void; + suggestions?: string[]; + placeholder?: string; +}) { + const [draft, setDraft] = useState(''); + + useEffect(() => { + setDraft(''); + }, [tags.length]); + + const commitDraft = () => { + const nextTags = normalizeTags([...tags, ...parseTagText(draft)]); + if (nextTags.length !== tags.length) { + onChange(nextTags); + } + setDraft(''); + }; + + const removeTag = (target: string) => { + onChange(tags.filter((tag) => tag !== target)); + }; + + const suggestionPool = suggestions.filter((tag) => !tags.some((current) => current.toLowerCase() === tag.toLowerCase())).slice(0, 12); + + return ( +
+
+
+ {tags.map((tag) => ( + + ))} +
+ setDraft(e.target.value)} + onBlur={commitDraft} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + commitDraft(); + } else if (e.key === 'Backspace' && !draft && tags.length > 0) { + e.preventDefault(); + onChange(tags.slice(0, -1)); + } + }} + placeholder={placeholder || '输入标签后按回车或逗号'} + style={{ width: '100%', border: 'none', outline: 'none', background: 'transparent', color: 'var(--color-text-primary)', padding: 0, fontSize: 13, lineHeight: 1.45 }} + /> +
+ {suggestionPool.length > 0 ? ( +
+ {suggestionPool.map((tag) => ( + + ))} +
+ ) : null} +
+ ); +} + +function SummaryMetric({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+ {label} + {value} +
+ ); +} + +function resolveOverviewUsageByRange( + overview: OverviewResponse | null, + range: Range, +): AggregateUsage | null { + if (!overview?.usage) return null; + if (range === '24h') return overview.usage.last24h; + if (range === '7d') return overview.usage.last7d; + return overview.usage.all; +} + +function TrendChartFallback({ height = 260 }: { height?: number }) { + return ( +
+
+
+
+ ); +} + +function InlineToggle({ + value, + onChange, +}: { + value: TagMatchMode; + onChange: (value: TagMatchMode) => void; +}) { + const options: Array<{ value: TagMatchMode; label: string }> = [ + { value: 'any', label: '匹配任一标签' }, + { value: 'all', label: '匹配全部标签' }, + ]; + + const base: React.CSSProperties = { + padding: '6px 12px', + fontSize: 12, + fontWeight: 600, + border: '1px solid var(--color-border)', + background: 'var(--color-bg-card)', + color: 'var(--color-text-muted)', + cursor: 'pointer', + }; + + const active: React.CSSProperties = { + background: 'var(--color-primary)', + color: '#fff', + borderColor: 'var(--color-primary)', + }; + + return ( +
+ {options.map((option, index) => ( + + ))} +
+ ); +} + +function RangeToggle({ range, onChange }: { range: Range; onChange: (r: Range) => void }) { + const base: React.CSSProperties = { + padding: '6px 12px', + fontSize: 12, + fontWeight: 600, + border: '1px solid var(--color-border)', + background: 'var(--color-bg-card)', + color: 'var(--color-text-muted)', + cursor: 'pointer', + }; + + const active: React.CSSProperties = { + background: 'var(--color-primary)', + color: '#fff', + borderColor: 'var(--color-primary)', + }; + + return ( +
+ + + +
+ ); +} + +function StatusBadge({ enabled }: { enabled: boolean }) { + return ( + + {enabled ? '启用' : '禁用'} + + ); +} + +function Drawer({ + open, + onClose, + item, + initialRange, +}: { + open: boolean; + onClose: () => void; + item: SummaryItem | null; + initialRange: Range; +}) { + const toast = useToast(); + const presence = useAnimatedVisibility(open, 220); + const [overview, setOverview] = useState(null); + const [overviewLoading, setOverviewLoading] = useState(false); + const [trendRange, setTrendRange] = useState(initialRange); + const [trendLoading, setTrendLoading] = useState(false); + const [buckets, setBuckets] = useState([]); + + useEffect(() => { + if (!open) return; + setTrendRange(initialRange); + }, [open, initialRange]); + + useEffect(() => { + if (!open || !item?.id) return; + let cancelled = false; + setOverviewLoading(true); + api.getDownstreamApiKeyOverview(item.id) + .then((res: any) => { + if (cancelled) return; + setOverview(res as OverviewResponse); + }) + .catch((err: any) => { + if (cancelled) return; + toast.error(err?.message || '加载 Key 概览失败'); + }) + .finally(() => { + if (cancelled) return; + setOverviewLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, item?.id, toast]); + + useEffect(() => { + if (!open || !item?.id) return; + let cancelled = false; + setTrendLoading(true); + api.getDownstreamApiKeyTrend(item.id, { range: trendRange }) + .then((res: any) => { + if (cancelled) return; + setBuckets(Array.isArray(res?.buckets) ? res.buckets : []); + }) + .catch((err: any) => { + if (cancelled) return; + toast.error(err?.message || '加载趋势失败'); + }) + .finally(() => { + if (cancelled) return; + setTrendLoading(false); + }); + return () => { + cancelled = true; + }; + }, [open, item?.id, trendRange, toast]); + + if (!presence.shouldRender) return null; + + const currentRangeUsage = resolveOverviewUsageByRange(overview, trendRange) || item?.rangeUsage || null; + + const panel = ( +
+
e.stopPropagation()} + style={{ + width: 'min(92vw, 560px)', + maxWidth: 560, + height: '100vh', + maxHeight: '100vh', + borderRadius: 0, + animation: presence.isVisible ? 'drawer-slide-in 0.3s cubic-bezier(0.22, 1, 0.36, 1) both' : 'drawer-slide-out 0.22s cubic-bezier(0.4, 0, 1, 1) both', + }} + > +
+
+
+ {item?.name || '--'} + +
+
+ {item?.keyMasked || '****'} +
+
+ + {item?.groupName ? `主分组 · ${item.groupName}` : '未分组'} + + +
+
+ +
+ +
+
+
+
+
使用趋势
+
按选定时间窗口查看请求、Tokens 与成本变化。
+
+ +
+ + }> + + +
+ +
+
+ 基础信息 +
+ {overviewLoading ? ( +
+ ) : ( +
+
+
最近使用
+
{formatIso(item?.lastUsedAt)}
+
+
+
累计请求
+
{(item?.usedRequests || 0).toLocaleString()}
+
+
+
累计成本
+
{formatMoney(Number(item?.usedCost || 0))}
+
+
+
到期时间
+
{formatIso(item?.expiresAt)}
+
+
+
主分组
+
{item?.groupName || '未分组'}
+
+
+
标签
+ +
+
+ )} +
+ +
+
+ 当前范围汇总 +
+
+
+
Tokens
+
{formatCompactTokens(currentRangeUsage?.totalTokens || 0)}
+
+
+
请求数
+
{(currentRangeUsage?.totalRequests || 0).toLocaleString()}
+
+
+
成功率
+
{currentRangeUsage?.successRate == null ? '--' : `${currentRangeUsage.successRate}%`}
+
+
+
成本
+
{formatMoney(Number(currentRangeUsage?.totalCost || 0))}
+
+
+
+ + {overview?.usage ? ( + <> +
+
+ 固定窗口对比 +
+
+ {[ + { label: '24h', data: overview.usage.last24h }, + { label: '7d', data: overview.usage.last7d }, + { label: '全部', data: overview.usage.all }, + ].map((section) => ( +
+
{section.label}
+
Tokens
+
{formatCompactTokens(section.data?.totalTokens || 0)}
+
请求数
+
{(section.data?.totalRequests || 0).toLocaleString()}
+
成功率
+
{section.data?.successRate == null ? '--' : `${section.data.successRate}%`}
+
+ ))} +
+
+ + ) : null} +
+
+
+ ); + + return createPortal(panel, document.body); +} + +function EditorModal({ + open, + editingItem, + form, + onChange, + onClose, + onSave, + saving, + routeOptions, + groupSuggestions, + tagSuggestions, +}: { + open: boolean; + editingItem: ManagedItem | null; + form: EditorForm; + onChange: (updater: (prev: EditorForm) => EditorForm) => void; + onClose: () => void; + onSave: () => void; + saving: boolean; + routeOptions: RouteSelectorItem[]; + groupSuggestions: string[]; + tagSuggestions: string[]; +}) { + const [modelSearch, setModelSearch] = useState(''); + const [groupSearch, setGroupSearch] = useState(''); + const [advancedOpen, setAdvancedOpen] = useState(false); + + useEffect(() => { + if (!open) { + setModelSearch(''); + setGroupSearch(''); + setAdvancedOpen(false); + } + }, [open]); + + const exactModels = useMemo( + () => uniqStrings(routeOptions.filter((item) => isExactModelPattern(item.modelPattern)).map((item) => item.modelPattern)).sort((a, b) => a.localeCompare(b)), + [routeOptions], + ); + const groupRouteOptions = useMemo( + () => routeOptions.filter(isGroupRouteOption), + [routeOptions], + ); + const validGroupRouteIdSet = useMemo( + () => new Set(groupRouteOptions.map((route) => route.id)), + [groupRouteOptions], + ); + const normalizedSelectedGroupRouteIds = useMemo( + () => uniqIds(form.selectedGroupRouteIds.filter((id) => validGroupRouteIdSet.has(id))), + [form.selectedGroupRouteIds, validGroupRouteIdSet], + ); + + const filteredModels = useMemo(() => { + const keyword = modelSearch.trim().toLowerCase(); + if (!keyword) return exactModels; + return exactModels.filter((model) => model.toLowerCase().includes(keyword)); + }, [exactModels, modelSearch]); + + const filteredGroups = useMemo(() => { + const keyword = groupSearch.trim().toLowerCase(); + if (!keyword) return groupRouteOptions; + return groupRouteOptions.filter((route) => { + const title = routeTitle(route).toLowerCase(); + return title.includes(keyword) || route.modelPattern.toLowerCase().includes(keyword); + }); + }, [groupRouteOptions, groupSearch]); + + const selectedModelCount = form.selectedModels.length; + const selectedGroupCount = normalizedSelectedGroupRouteIds.length; + const inputStyle: React.CSSProperties = { + width: '100%', + padding: '10px 12px', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + background: 'var(--color-bg)', + color: 'var(--color-text-primary)', + fontSize: 13, + lineHeight: 1.45, + }; + + return ( + + + + + )} + > +
+ 支持为每个下游密钥独立配置分组、标签、额度与有效期。高级限制项可按需展开。 +
+ +
+
+
名称
+ onChange((prev) => ({ ...prev, name: e.target.value }))} placeholder="例如:项目 A / 移动端" style={inputStyle} /> +
+
+
下游密钥
+ onChange((prev) => ({ ...prev, key: e.target.value }))} placeholder="sk-..." style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }} /> +
+
+
主分组
+ onChange((prev) => ({ ...prev, groupName: e.target.value }))} + placeholder="例如:VIP / 内部项目 / A组" + list="downstream-group-suggestions" + style={inputStyle} + /> +
+
+
请求额度
+ onChange((prev) => ({ ...prev, maxRequests: e.target.value }))} placeholder="留空表示不限" style={inputStyle} /> +
+
+
成本额度
+ onChange((prev) => ({ ...prev, maxCost: e.target.value }))} placeholder="留空表示不限" style={inputStyle} /> +
+
+
过期时间
+ onChange((prev) => ({ ...prev, expiresAt: e.target.value }))} style={inputStyle} /> +
+ +
+ +
+
备注说明
+