1
0
forked from boos/BoosAPI

Merge remote-tracking branch 'origin/main' into feat/account-level-proxy

# Conflicts:
#	src/server/proxy-core/surfaces/chatSurface.ts
#	src/server/proxy-core/surfaces/openAiResponsesSurface.ts
#	src/server/routes/proxy/embeddings.ts
#	src/server/routes/proxy/images.ts
#	src/server/services/modelService.ts
This commit is contained in:
Hureru
2026-03-21 10:09:19 +08:00
198 changed files with 20039 additions and 1105 deletions
+38 -1
View File
@@ -299,6 +299,35 @@ jobs:
- name: Audit production dependencies
run: npm audit --omit=dev --audit-level=high
docker-smoke-armv7:
name: Docker Smoke Build (armv7)
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
with:
platforms: arm
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build linux/arm/v7 image
uses: docker/build-push-action@v7
with:
context: .
file: docker/Dockerfile
platforms: linux/arm/v7
push: false
provenance: false
cache-from: type=gha,scope=ci-armv7-pr
cache-to: type=gha,mode=max,scope=ci-armv7-pr
publish-docker-arch:
name: Publish Docker Image (${{ matrix.arch }})
if: github.event_name == 'push'
@@ -314,6 +343,9 @@ jobs:
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
- arch: armv7
platform: linux/arm/v7
runner: ubuntu-latest
steps:
- name: Checkout
@@ -329,6 +361,10 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Set up QEMU
if: matrix.arch == 'armv7'
uses: docker/setup-qemu-action@v4
- name: Log in to Docker Hub
uses: docker/login-action@v4
with:
@@ -419,5 +455,6 @@ jobs:
docker buildx imagetools create \
--tag "$tag" \
"${tag}-amd64" \
"${tag}-arm64"
"${tag}-arm64" \
"${tag}-armv7"
done <<< "${{ steps.meta.outputs.tags }}"
+9 -1
View File
@@ -292,6 +292,9 @@ jobs:
- arch: arm64
platform: linux/arm64
runner: ubuntu-24.04-arm
- arch: armv7
platform: linux/arm/v7
runner: ubuntu-latest
steps:
- name: Checkout
@@ -307,6 +310,10 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Set up QEMU
if: matrix.arch == 'armv7'
uses: docker/setup-qemu-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
@@ -413,5 +420,6 @@ jobs:
docker buildx imagetools create \
--tag "$tag" \
"${tag}-amd64" \
"${tag}-arm64"
"${tag}-arm64" \
"${tag}-armv7"
done <<< "${{ steps.meta.outputs.tags }}"
+7 -2
View File
@@ -279,7 +279,7 @@
### 📦 轻量部署
- **单 Docker 容器**,默认本地数据目录部署,支持外接 MySQL / PostgreSQL 运行时数据库
- Alpine 基础镜像,体积精简
- Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署
- 数据完整导入导出,迁移无忧
---
@@ -343,6 +343,11 @@ docker run -d --name metapi \
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。
> [!NOTE]
> Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署。
> 当前 `armv7l` 支持范围仅限服务端 / Docker 运行,不包含桌面安装包。
<!-- markdownlint-disable-next-line MD028 -->
> [!IMPORTANT]
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。
@@ -369,7 +374,7 @@ Docker Compose、桌面安装包、反向代理、升级与数据库选项等详
| **数据库** | SQLite / MySQL / PostgreSQL +[Drizzle ORM](https://orm.drizzle.team) |
| **数据可视化** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) |
| **定时任务** | [node-cron](https://github.com/node-cron/node-cron) |
| **容器化** | Docker (Alpine) + Docker Compose |
| **容器化** | Docker (Debian slim) + Docker Compose |
| **测试** | [Vitest](https://vitest.dev) |
---
+7 -2
View File
@@ -287,7 +287,7 @@ Alert scenarios: low balance warning, site/account anomalies, check-in failures,
### Lightweight Deployment
- **Single Docker container** with a default local data directory, plus optional external MySQL / PostgreSQL runtime DB
- Alpine base image, minimal footprint
- Docker images support `amd64`, `arm64`, and `armv7l` (`linux/arm/v7`) server deployments
- Full data import/export for worry-free migration
---
@@ -344,6 +344,11 @@ docker run -d --name metapi \
After starting, visit `http://localhost:4000` and log in with your `AUTH_TOKEN`!
> [!NOTE]
> Docker images support `amd64`, `arm64`, and `armv7l` (`linux/arm/v7`) server deployments.
> Current `armv7l` support is limited to server / Docker usage and does not include Electron desktop packaging support.
<!-- markdownlint-disable-next-line MD028 -->
> [!IMPORTANT]
> Make sure to change `AUTH_TOKEN` and `PROXY_TOKEN` — do not use default values. Data is stored in the `./data` directory and persists across upgrades.
@@ -477,7 +482,7 @@ For detailed per-client setup, examples, and troubleshooting, see [docs/client-i
| **Database** | SQLite / MySQL / PostgreSQL + [Drizzle ORM](https://orm.drizzle.team) |
| **Charts** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) |
| **Scheduling** | [node-cron](https://github.com/node-cron/node-cron) |
| **Containerization** | Docker (Alpine) + Docker Compose |
| **Containerization** | Docker (Debian slim) + Docker Compose |
| **Testing** | [Vitest](https://vitest.dev) |
---
+4 -4
View File
@@ -1,5 +1,5 @@
# syntax=docker/dockerfile:1.7
FROM node:25-bookworm-slim AS builder
FROM node:22-bookworm-slim AS builder
WORKDIR /app
@@ -10,13 +10,13 @@ RUN apt-get update \
ENV PYTHON=/usr/bin/python3
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
RUN --mount=type=cache,target=/root/.npm npm ci --omit=peer --no-audit --no-fund
COPY . .
RUN npm run build
RUN npm run build:web && npm run build:server
RUN --mount=type=cache,target=/root/.npm npm prune --omit=dev --no-audit --no-fund
FROM node:25-bookworm-slim
FROM node:22-bookworm-slim
WORKDIR /app
+1 -1
View File
@@ -2,7 +2,7 @@ services:
metapi:
image: 1467078763/metapi:latest
ports:
- "4000:4000"
- "127.0.0.1:4000:4000"
volumes:
- ./data:/app/data
environment:
+9
View File
@@ -213,6 +213,15 @@ Metapi 的路由引擎按多因子加权选择最优通道。
> `TOKEN_ROUTER_CACHE_TTL_MS`、`PROXY_LOG_RETENTION_DAYS`、`PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES` 当前仍属于部署级环境变量,不在后台运行时设置里单独维护。
## 站点公告
管理后台新增了「站点公告」页面,用于保存和浏览 Metapi 已同步到本地的上游公告记录。
- 首次发现的上游公告会写入站内通知,并按现有通知渠道外发一次
- 后续重复同步只更新本地公告记录,不会重复外发同一条公告
- 当前支持的上游公告来源包括 `new-api`、`done-hub` 与 `sub2api`
- 「清空公告」只删除 Metapi 本地保存的公告记录,不会修改上游站点数据
## 下一步
- [部署指南](./deployment.md) — Docker Compose 与反向代理
+19
View File
@@ -379,6 +379,25 @@ Metapi 自动追踪每个账号的健康状态:
---
## 📰 站点公告
Metapi 会定期抓取已接入站点的公告,并在首次发现时写入站内通知与「站点公告」页面。
当前支持的公告来源:
- `new-api`:读取 `/api/notice` 的全站公告
- `done-hub`:读取 `/api/notice` 的全站公告
- `sub2api`:读取 `/api/v1/announcements` 的公告列表
行为约定:
1. 首次发现的公告会触发一次 Metapi 通知
2. 已经保存过的公告再次同步时只更新本地记录
3. `sub2api` 这类需要登录态的公告接口,会优先使用站点下已启用账号的会话令牌
4. 「站点公告」页面的清空操作只影响 Metapi 本地数据库
---
## 🛠️ 故障排查
### 问题:添加站点后无法获取模型列表
+26
View File
@@ -0,0 +1,26 @@
CREATE TABLE `site_announcements` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`site_id` integer NOT NULL,
`platform` text NOT NULL,
`source_key` text NOT NULL,
`title` text NOT NULL,
`content` text NOT NULL,
`level` text DEFAULT 'info' NOT NULL,
`source_url` text,
`starts_at` text,
`ends_at` text,
`upstream_created_at` text,
`upstream_updated_at` text,
`first_seen_at` text DEFAULT (datetime('now')),
`last_seen_at` text DEFAULT (datetime('now')),
`read_at` text,
`dismissed_at` text,
`raw_payload` text,
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`,`source_key`);
--> statement-breakpoint
CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`,`first_seen_at`);
--> statement-breakpoint
CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`);
@@ -0,0 +1,6 @@
ALTER TABLE `proxy_logs` ADD `client_family` text;--> statement-breakpoint
ALTER TABLE `proxy_logs` ADD `client_app_id` text;--> statement-breakpoint
ALTER TABLE `proxy_logs` ADD `client_app_name` text;--> statement-breakpoint
ALTER TABLE `proxy_logs` ADD `client_confidence` text;--> statement-breakpoint
CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`,`created_at`);
File diff suppressed because it is too large Load Diff
+14
View File
@@ -99,6 +99,20 @@
"when": 1773855000000,
"tag": "0014_explicit_group_routes",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1773900000000,
"tag": "0015_site_announcements",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1773979652606,
"tag": "0016_proxy_logs_client_fields",
"breakpoints": true
}
]
}
+540 -4
View File
@@ -22,6 +22,7 @@
"electron-updater": "^6.6.2",
"fastify": "^5.8.2",
"get-port": "^7.1.0",
"marked": "^17.0.4",
"minimatch": "^10.2.4",
"minimist": "^1.2.8",
"mysql2": "^3.15.3",
@@ -48,6 +49,7 @@
"drizzle-kit": "^0.28.1",
"electron": "^41.0.2",
"electron-builder": "^26.0.12",
"jsdom": "^29.0.1",
"mermaid": "^11.12.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -349,6 +351,67 @@
"node": ">=18"
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz",
"integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^3.1.1",
"@csstools/css-color-parser": "^4.0.2",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0",
"lru-cache": "^11.2.6"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.3.tgz",
"integrity": "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -638,6 +701,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",
@@ -682,6 +758,146 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz",
"integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz",
"integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.1.1"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz",
"integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -2188,6 +2404,24 @@
"node": ">=12"
}
},
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@fastify/accept-negotiator": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
@@ -6134,6 +6368,16 @@
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -7000,6 +7244,20 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -7635,6 +7893,20 @@
"lodash-es": "^4.17.21"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/dayjs": {
"version": "1.11.19",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
@@ -7659,6 +7931,13 @@
}
}
},
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -9664,6 +9943,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
@@ -9876,6 +10168,13 @@
"node": ">=8"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
@@ -10021,6 +10320,67 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsdom": {
"version": "29.0.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz",
"integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.0.1",
"@asamuzakjp/dom-selector": "^7.0.3",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.1",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.7",
"parse5": "^8.0.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.24.5",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
"integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsdom/node_modules/undici": {
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -10620,10 +10980,9 @@
"license": "MIT"
},
"node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"dev": true,
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
@@ -10678,6 +11037,13 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/mermaid": {
"version": "11.12.3",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz",
@@ -10707,6 +11073,19 @@
"uuid": "^11.1.0"
}
},
"node_modules/mermaid/node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"dev": true,
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mermaid/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
@@ -11524,6 +11903,32 @@
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -12675,6 +13080,19 @@
"node": ">=11.0.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
@@ -13376,6 +13794,13 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
@@ -13639,6 +14064,26 @@
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.0.26",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.26.tgz",
"integrity": "sha512-WiGwQjr0qYdNNG8KpMKlSvpxz652lqa3Rd+/hSaDcY4Uo6SKWZq2LAF+hsAhUewTtYhXlorBKgNF3Kk8hnjGoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.26"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.26",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.26.tgz",
"integrity": "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew==",
"dev": true,
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@@ -13703,6 +14148,32 @@
"geo2topo": "bin/geo2topo"
}
},
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -16766,6 +17237,19 @@
}
}
},
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/wait-on": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz",
@@ -16796,6 +17280,41 @@
"defaults": "^1.0.3"
}
},
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -16960,6 +17479,16 @@
}
}
},
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
@@ -16970,6 +17499,13 @@
"node": ">=8.0"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+2
View File
@@ -72,6 +72,7 @@
"electron-updater": "^6.6.2",
"fastify": "^5.8.2",
"get-port": "^7.1.0",
"marked": "^17.0.4",
"minimatch": "^10.2.4",
"minimist": "^1.2.8",
"mysql2": "^3.15.3",
@@ -98,6 +99,7 @@
"drizzle-kit": "^0.28.1",
"electron": "^41.0.2",
"electron-builder": "^26.0.12",
"jsdom": "^29.0.1",
"mermaid": "^11.12.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
+44 -3
View File
@@ -6,6 +6,7 @@ import sharp from 'sharp';
export const DESKTOP_ICON_SIZE = 512;
export const DESKTOP_ICON_PADDING = 40;
export const DESKTOP_ICON_RADIUS = 96;
export const DESKTOP_TRAY_TEMPLATE_PADDING = 152;
function createRoundedMask(size, cornerRadius) {
return Buffer.from(
@@ -44,34 +45,73 @@ async function renderDesktopIconBuffer({
.toBuffer();
}
async function renderTrayTemplateIconBuffer({
sourcePath,
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_TRAY_TEMPLATE_PADDING,
}) {
const innerSize = Math.max(1, size - padding * 2);
const alphaMask = await sharp(sourcePath)
.resize(innerSize, innerSize, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.ensureAlpha()
.extractChannel('alpha')
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 },
},
})
.composite([{ input: alphaMask, left: padding, top: padding, blend: 'dest-in' }])
.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'),
trayTemplateOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-tray-template.png'),
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_ICON_PADDING,
cornerRadius = DESKTOP_ICON_RADIUS,
} = {}) {
const outputBuffer = await renderDesktopIconBuffer({
const [outputBuffer, trayTemplateBuffer] = await Promise.all([
renderDesktopIconBuffer({
sourcePath,
size,
padding,
cornerRadius,
});
}),
renderTrayTemplateIconBuffer({
sourcePath,
size,
}),
]);
await Promise.all([
mkdir(dirname(buildOutputPath), { recursive: true }),
mkdir(dirname(webOutputPath), { recursive: true }),
mkdir(dirname(trayTemplateOutputPath), { recursive: true }),
]);
await Promise.all([
sharp(outputBuffer).toFile(buildOutputPath),
sharp(outputBuffer).toFile(webOutputPath),
sharp(trayTemplateBuffer).toFile(trayTemplateOutputPath),
]);
return {
buildOutputPath,
webOutputPath,
trayTemplateOutputPath,
};
}
@@ -81,5 +121,6 @@ if (isDirectRun) {
const outputs = await generateDesktopIconAssets();
console.log(`[metapi-desktop] Generated desktop icons:
- ${outputs.buildOutputPath}
- ${outputs.webOutputPath}`);
- ${outputs.webOutputPath}
- ${outputs.trayTemplateOutputPath}`);
}
+19 -1
View File
@@ -21,13 +21,14 @@ afterEach(async () => {
});
describe('generateDesktopIconAssets', () => {
it('writes rounded desktop icon outputs for build and runtime usage', async () => {
it('writes rounded desktop icon outputs and a tray template asset', 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');
const trayTemplateOutputPath = join(dir, 'desktop-tray-template.png');
await sharp({
create: {
@@ -42,15 +43,19 @@ describe('generateDesktopIconAssets', () => {
sourcePath,
buildOutputPath,
webOutputPath,
trayTemplateOutputPath,
});
const buildMeta = await sharp(buildOutputPath).metadata();
const webMeta = await sharp(webOutputPath).metadata();
const trayMeta = await sharp(trayTemplateOutputPath).metadata();
expect(buildMeta.width).toBe(512);
expect(buildMeta.height).toBe(512);
expect(webMeta.width).toBe(512);
expect(webMeta.height).toBe(512);
expect(trayMeta.width).toBe(512);
expect(trayMeta.height).toBe(512);
const { data, info } = await sharp(buildOutputPath)
.ensureAlpha()
@@ -64,5 +69,18 @@ describe('generateDesktopIconAssets', () => {
expect(await sharp(buildOutputPath).png().toBuffer())
.toEqual(await sharp(webOutputPath).png().toBuffer());
const trayBuffer = await sharp(trayTemplateOutputPath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
expect(alphaAt(trayBuffer.data, trayBuffer.info.width, 0, 0)).toBe(0);
expect(alphaAt(trayBuffer.data, trayBuffer.info.width, Math.floor(trayBuffer.info.width / 2), Math.floor(trayBuffer.info.height / 2))).toBe(255);
const trayCenterOffset = (Math.floor(trayBuffer.info.height / 2) * trayBuffer.info.width + Math.floor(trayBuffer.info.width / 2)) * 4;
expect(trayBuffer.data[trayCenterOffset]).toBe(0);
expect(trayBuffer.data[trayCenterOffset + 1]).toBe(0);
expect(trayBuffer.data[trayCenterOffset + 2]).toBe(0);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('docker workflows', () => {
it('publishes armv7 docker images in ci and release workflows', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(ciWorkflow).toContain('arch: armv7');
expect(ciWorkflow).toContain('platform: linux/arm/v7');
expect(ciWorkflow).toContain('"${tag}-armv7"');
expect(releaseWorkflow).toContain('arch: armv7');
expect(releaseWorkflow).toContain('platform: linux/arm/v7');
expect(releaseWorkflow).toContain('"${tag}-armv7"');
});
it('smoke builds the armv7 docker image on pull requests', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
expect(ciWorkflow).toContain('Docker Smoke Build (armv7)');
expect(ciWorkflow).toContain('if: github.event_name == \'pull_request\'');
expect(ciWorkflow).toContain('platforms: linux/arm/v7');
expect(ciWorkflow).toContain('push: false');
});
it('uses an armv7-capable node base image in the Dockerfile', () => {
const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8');
expect(dockerfile).toContain('FROM node:22-bookworm-slim AS builder');
expect(dockerfile).toContain('FROM node:22-bookworm-slim');
});
it('keeps server docker builds isolated from desktop packaging dependencies', () => {
const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8');
expect(dockerfile).toContain('npm ci --omit=peer --no-audit --no-fund');
expect(dockerfile).toContain('RUN npm run build:web && npm run build:server');
});
});
+5
View File
@@ -7,12 +7,17 @@ describe('desktop icon assets', () => {
const {
DESKTOP_BUILD_ICON_RELATIVE_PATH,
DESKTOP_RUNTIME_ICON_RELATIVE_PATH,
DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH,
getDesktopRuntimeIconPath,
getDesktopTrayIconPath,
} = 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(DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-tray-template.png'));
expect(getDesktopRuntimeIconPath('/app')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png'));
expect(getDesktopTrayIconPath('/app', 'darwin')).toBe(join('/app', 'dist', 'web', 'desktop-tray-template.png'));
expect(getDesktopTrayIconPath('/app', 'win32')).toBe(join('/app', 'dist', 'web', 'desktop-icon.png'));
});
it('points electron-builder at the generated desktop package icon', async () => {
+11
View File
@@ -2,8 +2,19 @@ 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_TRAY_TEMPLATE_ICON_RELATIVE_PATH = join('dist', 'web', 'desktop-tray-template.png');
export const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png';
export function getDesktopRuntimeIconPath(appPath: string) {
return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH);
}
export function getDesktopTrayTemplateIconPath(appPath: string) {
return join(appPath, DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH);
}
export function getDesktopTrayIconPath(appPath: string, platform = process.platform) {
return platform === 'darwin'
? getDesktopTrayTemplateIconPath(appPath)
: getDesktopRuntimeIconPath(appPath);
}
+11 -3
View File
@@ -21,7 +21,7 @@ import {
resolveDesktopServerWorkingDir,
waitForServerReady,
} from './runtime.js';
import { getDesktopRuntimeIconPath } from './iconAssets.js';
import { getDesktopRuntimeIconPath, getDesktopTrayIconPath } from './iconAssets.js';
const { autoUpdater } = electronUpdater;
@@ -52,7 +52,7 @@ function getServerEntryPath() {
}
function getTrayIconPath() {
return getDesktopRuntimeIconPath(app.getAppPath());
return getDesktopTrayIconPath(app.getAppPath(), process.platform);
}
function getWindowIconPath() {
@@ -121,7 +121,15 @@ function buildTrayMenu() {
function setupTray() {
if (tray) return;
const trayImage = nativeImage.createFromPath(getTrayIconPath());
let trayImage = nativeImage.createFromPath(getTrayIconPath());
if (process.platform === 'darwin' && !trayImage.isEmpty()) {
trayImage = trayImage.resize({
width: 18,
height: 18,
quality: 'best',
});
trayImage.setTemplateImage(true);
}
tray = new Tray(trayImage);
tray.setToolTip('Metapi');
tray.setContextMenu(buildTrayMenu());
+9
View File
@@ -36,6 +36,15 @@ describe('buildConfig', () => {
const config = buildConfig({});
expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org');
expect(config.telegramMessageThreadId).toBe('');
});
it('accepts telegram message thread id from environment', () => {
const config = buildConfig({
TELEGRAM_MESSAGE_THREAD_ID: '77',
});
expect(config.telegramMessageThreadId).toBe('77');
});
it('ships CLI-aligned OAuth defaults', () => {
+23
View File
@@ -1,5 +1,6 @@
import 'dotenv/config';
import type { FastifyServerOptions } from 'fastify';
import { normalizePayloadRulesConfig } from './services/payloadRules.js';
const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024;
const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
@@ -32,6 +33,15 @@ function parseOptionalSecret(value: string | undefined): string {
return (value || '').trim();
}
function parseJsonValue(value: string | undefined): unknown {
if (!value) return undefined;
try {
return JSON.parse(value);
} catch {
return undefined;
}
}
function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' {
const normalized = (value || 'sqlite').trim().toLowerCase();
if (normalized === 'mysql') return 'mysql';
@@ -57,6 +67,10 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
checkinScheduleMode: (env.CHECKIN_SCHEDULE_MODE || 'cron').trim().toLowerCase() === 'interval'
? 'interval' as const
: 'cron' as const,
checkinIntervalHours: Math.min(24, Math.max(1, Math.trunc(parseNumber(env.CHECKIN_INTERVAL_HOURS, 6)))),
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *',
logCleanupConfigured: false,
@@ -73,6 +87,8 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
telegramApiBaseUrl: 'https://api.telegram.org',
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
telegramChatId: env.TELEGRAM_CHAT_ID || '',
telegramUseSystemProxy: parseBoolean(env.TELEGRAM_USE_SYSTEM_PROXY, false),
telegramMessageThreadId: (env.TELEGRAM_MESSAGE_THREAD_ID || '').trim(),
smtpEnabled: parseBoolean(env.SMTP_ENABLED, false),
smtpHost: env.SMTP_HOST || '',
smtpPort: parseInt(env.SMTP_PORT || '587'),
@@ -94,8 +110,15 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
tokenRouterCacheTtlMs: Math.max(100, Math.trunc(parseNumber(env.TOKEN_ROUTER_CACHE_TTL_MS, 1_500))),
proxyLogRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_DAYS, 30))),
proxyLogRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES, 30))),
proxyFileRetentionDays: Math.max(0, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_DAYS, 30))),
proxyFileRetentionPruneIntervalMinutes: Math.max(1, Math.trunc(parseNumber(env.PROXY_FILE_RETENTION_PRUNE_INTERVAL_MINUTES, 60))),
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
codexHeaderDefaults: {
userAgent: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_USER_AGENT),
betaFeatures: parseOptionalSecret(env.CODEX_HEADER_DEFAULTS_BETA_FEATURES),
},
payloadRules: normalizePayloadRulesConfig(parseJsonValue(env.PAYLOAD_RULES_JSON || env.PAYLOAD_RULES)),
routingWeights: {
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
+20 -14
View File
@@ -1,24 +1,26 @@
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, `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, `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, `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', `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 `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` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `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` VARCHAR(191), `last_balance_refresh` VARCHAR(191), `extra_config` JSON, `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `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` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `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` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), 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` VARCHAR(191), `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` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `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` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
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` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `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` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `deleted_at` VARCHAR(191));
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` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `billing_details` JSON, `downstream_api_key_id` INT, `client_family` TEXT, `client_app_id` TEXT, `client_app_name` TEXT, `client_confidence` TEXT);
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` VARCHAR(191), `created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')));
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` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `updated_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `display_name` TEXT, `display_icon` TEXT, `decision_snapshot` JSON, `decision_refreshed_at` VARCHAR(191), `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` VARCHAR(191), `last_selected_at` VARCHAR(191), `last_fail_at` VARCHAR(191), `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` VARCHAR(191), 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);
CREATE TABLE IF NOT EXISTS `site_announcements` (`id` INT AUTO_INCREMENT NOT NULL PRIMARY KEY, `site_id` INT NOT NULL, `platform` TEXT NOT NULL, `source_key` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `level` VARCHAR(191) NOT NULL DEFAULT 'info', `source_url` TEXT, `starts_at` VARCHAR(191), `ends_at` VARCHAR(191), `upstream_created_at` VARCHAR(191), `upstream_updated_at` VARCHAR(191), `first_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `last_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), `read_at` VARCHAR(191), `dismissed_at` VARCHAR(191), `raw_payload` TEXT, FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON DELETE CASCADE);
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` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), 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` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s')), FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE CASCADE);
CREATE UNIQUE INDEX `downstream_api_keys_key_unique` ON `downstream_api_keys` (`key`(191));
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_announcements_site_source_key_unique` ON `site_announcements` (`site_id`, `source_key`(191));
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));
@@ -43,6 +45,8 @@ CREATE INDEX `model_availability_account_available_idx` ON `model_availability`
CREATE INDEX `model_availability_model_name_idx` ON `model_availability` (`model_name`(191));
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_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`(191), `created_at`);
CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`(191), `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`);
@@ -55,6 +59,8 @@ 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_announcements_read_at_idx` ON `site_announcements` (`read_at`);
CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`, `first_seen_at`);
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`);
+1 -4
View File
@@ -1,4 +1 @@
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`);
-- no schema changes detected for mysql
+20 -14
View File
@@ -1,24 +1,26 @@
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, "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, "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, "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', "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 "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" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "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" TEXT, "last_balance_refresh" TEXT, "extra_config" JSONB, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "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" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "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" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), 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" TEXT, "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" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "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" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
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" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "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" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "deleted_at" TEXT);
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" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "billing_details" JSONB, "downstream_api_key_id" INTEGER, "client_family" TEXT, "client_app_id" TEXT, "client_app_name" TEXT, "client_confidence" TEXT);
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" TEXT, "created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'));
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" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "updated_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "display_name" TEXT, "display_icon" TEXT, "decision_snapshot" JSONB, "decision_refreshed_at" TEXT, "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" TEXT, "last_selected_at" TEXT, "last_fail_at" TEXT, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TEXT, 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);
CREATE TABLE IF NOT EXISTS "site_announcements" ("id" INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL PRIMARY KEY, "site_id" INTEGER NOT NULL, "platform" TEXT NOT NULL, "source_key" TEXT NOT NULL, "title" TEXT NOT NULL, "content" TEXT NOT NULL, "level" TEXT NOT NULL DEFAULT 'info', "source_url" TEXT, "starts_at" TEXT, "ends_at" TEXT, "upstream_created_at" TEXT, "upstream_updated_at" TEXT, "first_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "last_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), "read_at" TEXT, "dismissed_at" TEXT, "raw_payload" TEXT, FOREIGN KEY ("site_id") REFERENCES "sites"("id") ON DELETE CASCADE);
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" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), 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" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS'), FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE CASCADE);
CREATE UNIQUE INDEX "downstream_api_keys_key_unique" ON "downstream_api_keys" ("key");
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_announcements_site_source_key_unique" ON "site_announcements" ("site_id", "source_key");
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");
@@ -43,6 +45,8 @@ CREATE INDEX "model_availability_account_available_idx" ON "model_availability"
CREATE INDEX "model_availability_model_name_idx" ON "model_availability" ("model_name");
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_client_app_id_created_at_idx" ON "proxy_logs" ("client_app_id", "created_at");
CREATE INDEX "proxy_logs_client_family_created_at_idx" ON "proxy_logs" ("client_family", "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");
@@ -55,6 +59,8 @@ 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_announcements_read_at_idx" ON "site_announcements" ("read_at");
CREATE INDEX "site_announcements_site_id_first_seen_at_idx" ON "site_announcements" ("site_id", "first_seen_at");
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");
+1 -4
View File
@@ -1,4 +1 @@
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");
-- no schema changes detected for postgres
+193
View File
@@ -653,6 +653,30 @@
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"client_family": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"client_app_id": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"client_app_name": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"client_confidence": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
}
}
},
@@ -906,6 +930,112 @@
}
}
},
"site_announcements": {
"columns": {
"id": {
"logicalType": "integer",
"notNull": true,
"defaultValue": null,
"primaryKey": true
},
"site_id": {
"logicalType": "integer",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"platform": {
"logicalType": "text",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"source_key": {
"logicalType": "text",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"title": {
"logicalType": "text",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"content": {
"logicalType": "text",
"notNull": true,
"defaultValue": null,
"primaryKey": false
},
"level": {
"logicalType": "text",
"notNull": true,
"defaultValue": "'info'",
"primaryKey": false
},
"source_url": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"starts_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"ends_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"upstream_created_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"upstream_updated_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"first_seen_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": "datetime('now')",
"primaryKey": false
},
"last_seen_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": "datetime('now')",
"primaryKey": false
},
"read_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"dismissed_at": {
"logicalType": "datetime",
"notNull": false,
"defaultValue": null,
"primaryKey": false
},
"raw_payload": {
"logicalType": "text",
"notNull": false,
"defaultValue": null,
"primaryKey": false
}
}
},
"site_disabled_models": {
"columns": {
"id": {
@@ -1350,6 +1480,24 @@
],
"unique": false
},
{
"name": "proxy_logs_client_app_id_created_at_idx",
"table": "proxy_logs",
"columns": [
"client_app_id",
"created_at"
],
"unique": false
},
{
"name": "proxy_logs_client_family_created_at_idx",
"table": "proxy_logs",
"columns": [
"client_family",
"created_at"
],
"unique": false
},
{
"name": "proxy_logs_created_at_idx",
"table": "proxy_logs",
@@ -1468,6 +1616,32 @@
],
"unique": false
},
{
"name": "site_announcements_read_at_idx",
"table": "site_announcements",
"columns": [
"read_at"
],
"unique": false
},
{
"name": "site_announcements_site_id_first_seen_at_idx",
"table": "site_announcements",
"columns": [
"site_id",
"first_seen_at"
],
"unique": false
},
{
"name": "site_announcements_site_source_key_unique",
"table": "site_announcements",
"columns": [
"site_id",
"source_key"
],
"unique": true
},
{
"name": "site_disabled_models_site_id_idx",
"table": "site_disabled_models",
@@ -1591,6 +1765,14 @@
"source_route_id"
]
},
{
"name": "site_announcements_site_source_key_unique",
"table": "site_announcements",
"columns": [
"site_id",
"source_key"
]
},
{
"name": "site_disabled_models_site_model_unique",
"table": "site_disabled_models",
@@ -1716,6 +1898,17 @@
],
"onDelete": "CASCADE"
},
{
"table": "site_announcements",
"columns": [
"site_id"
],
"referencedTable": "sites",
"referencedColumns": [
"id"
],
"onDelete": "CASCADE"
},
{
"table": "site_disabled_models",
"columns": [
+228
View File
@@ -33,6 +33,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
'proxy_video_tasks',
'proxy_files',
'downstream_api_keys',
'site_announcements',
'events',
]);
@@ -43,6 +44,7 @@ let mysqlPool: mysql.Pool | null = null;
let pgPool: pg.Pool | null = null;
let proxyLogBillingDetailsColumnAvailable: boolean | null = null;
let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null;
let proxyLogClientColumnsAvailable: boolean | null = null;
function resolveSqlitePath(): string {
const raw = (config.dbUrl || '').trim();
@@ -119,6 +121,13 @@ function tableColumnExists(table: string, column: string): boolean {
return rows.some((row) => row.name === column);
}
function tableIndexExists(indexName: string): boolean {
const sqlite = requireSqliteConnection();
const row = sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ? LIMIT 1")
.get(indexName) as { name?: string } | undefined;
return !!row?.name;
}
function execSqliteStatement(sqlText: string): void {
requireSqliteConnection().exec(sqlText);
}
@@ -173,6 +182,8 @@ function ensureTokenManagementSchema() {
WHERE
a.api_token IS NOT NULL
AND trim(a.api_token) <> ''
AND a.access_token IS NOT NULL
AND trim(a.access_token) <> ''
AND NOT EXISTS (
SELECT 1 FROM account_tokens AS t
WHERE t.account_id = a.id
@@ -587,6 +598,40 @@ function ensureProxyLogDownstreamApiKeyIdSchema() {
proxyLogDownstreamApiKeyIdColumnAvailable = true;
}
function ensureProxyLogClientSchema() {
if (!tableExists('proxy_logs')) {
return;
}
if (!tableColumnExists('proxy_logs', 'client_family')) {
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_family text;');
}
if (!tableColumnExists('proxy_logs', 'client_app_id')) {
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;');
}
if (!tableColumnExists('proxy_logs', 'client_app_name')) {
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_app_name text;');
}
if (!tableColumnExists('proxy_logs', 'client_confidence')) {
execSqliteLegacyCompat('ALTER TABLE proxy_logs ADD COLUMN client_confidence text;');
}
if (!tableIndexExists('proxy_logs_client_app_id_created_at_idx')) {
execSqliteLegacyCompat(`
CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx
ON proxy_logs(client_app_id, created_at);
`);
}
if (!tableIndexExists('proxy_logs_client_family_created_at_idx')) {
execSqliteLegacyCompat(`
CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx
ON proxy_logs(client_family, created_at);
`);
}
proxyLogClientColumnsAvailable = true;
}
function normalizeSchemaErrorMessage(error: unknown): string {
if (typeof error === 'object' && error && 'message' in error) {
return String((error as { message?: unknown }).message || '');
@@ -599,6 +644,14 @@ function isDuplicateColumnError(error: unknown): boolean {
return lowered.includes('duplicate column') || lowered.includes('already exists');
}
function isDuplicateIndexError(error: unknown): boolean {
const lowered = normalizeSchemaErrorMessage(error).toLowerCase();
return lowered.includes('duplicate key name')
|| lowered.includes('already exists')
|| lowered.includes('relation')
|| lowered.includes('duplicate index');
}
export async function hasProxyLogBillingDetailsColumn(): Promise<boolean> {
if (proxyLogBillingDetailsColumnAvailable !== null) {
return proxyLogBillingDetailsColumnAvailable;
@@ -731,9 +784,183 @@ export async function ensureProxyLogDownstreamApiKeyIdColumn(): Promise<boolean>
}
}
async function hasMysqlIndex(indexName: string): Promise<boolean> {
if (!mysqlPool) return false;
const [rows] = await mysqlPool.query(
'SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? LIMIT 1',
['proxy_logs', indexName],
);
return Array.isArray(rows) && rows.length > 0;
}
async function hasPostgresIndex(indexName: string): Promise<boolean> {
if (!pgPool) return false;
const result = await pgPool.query(
'SELECT 1 FROM pg_indexes WHERE schemaname = current_schema() AND tablename = $1 AND indexname = $2 LIMIT 1',
['proxy_logs', indexName],
);
return Number(result.rowCount || 0) > 0;
}
export async function hasProxyLogClientColumns(): Promise<boolean> {
if (proxyLogClientColumnsAvailable !== null) {
return proxyLogClientColumnsAvailable;
}
const requiredColumns = [
'client_family',
'client_app_id',
'client_app_name',
'client_confidence',
];
if (runtimeDbDialect === 'sqlite') {
proxyLogClientColumnsAvailable = tableExists('proxy_logs')
&& requiredColumns.every((columnName) => tableColumnExists('proxy_logs', columnName));
return proxyLogClientColumnsAvailable;
}
if (runtimeDbDialect === 'mysql') {
if (!mysqlPool) return false;
const [rows] = await mysqlPool.query(
'SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ? AND column_name IN (?, ?, ?, ?)',
['proxy_logs', ...requiredColumns],
) as [Array<{ column_name?: string }>, unknown];
const available = new Set(
Array.isArray(rows)
? rows.map((row) => String(row?.column_name || '').trim().toLowerCase()).filter(Boolean)
: [],
);
proxyLogClientColumnsAvailable = requiredColumns.every((columnName) => available.has(columnName));
return proxyLogClientColumnsAvailable;
}
if (!pgPool) return false;
const result = await pgPool.query(
'SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1 AND column_name = ANY($2::text[])',
['proxy_logs', requiredColumns],
);
const available = new Set(
result.rows.map((row) => String((row as { column_name?: string }).column_name || '').trim().toLowerCase()).filter(Boolean),
);
proxyLogClientColumnsAvailable = requiredColumns.every((columnName) => available.has(columnName));
return proxyLogClientColumnsAvailable;
}
export async function ensureProxyLogClientColumns(): Promise<boolean> {
const requiredColumns = [
{ name: 'client_family', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
{ name: 'client_app_id', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
{ name: 'client_app_name', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
{ name: 'client_confidence', sqliteType: 'text', mysqlType: 'TEXT NULL', postgresType: 'TEXT' },
];
const requiredIndexes = [
{
name: 'proxy_logs_client_app_id_created_at_idx',
sqliteSql: 'CREATE INDEX IF NOT EXISTS proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);',
mysqlSql: 'CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`(191), `created_at`(191))',
postgresSql: 'CREATE INDEX "proxy_logs_client_app_id_created_at_idx" ON "proxy_logs" ("client_app_id", "created_at")',
},
{
name: 'proxy_logs_client_family_created_at_idx',
sqliteSql: 'CREATE INDEX IF NOT EXISTS proxy_logs_client_family_created_at_idx ON proxy_logs(client_family, created_at);',
mysqlSql: 'CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`(191), `created_at`(191))',
postgresSql: 'CREATE INDEX "proxy_logs_client_family_created_at_idx" ON "proxy_logs" ("client_family", "created_at")',
},
];
if (runtimeDbDialect === 'sqlite') {
ensureProxyLogClientSchema();
proxyLogClientColumnsAvailable = tableExists('proxy_logs')
&& requiredColumns.every((column) => tableColumnExists('proxy_logs', column.name));
return proxyLogClientColumnsAvailable;
}
if (await hasProxyLogClientColumns()) {
for (const requiredIndex of requiredIndexes) {
const indexExists = runtimeDbDialect === 'mysql'
? await hasMysqlIndex(requiredIndex.name)
: await hasPostgresIndex(requiredIndex.name);
if (indexExists) continue;
try {
if (runtimeDbDialect === 'mysql') {
if (!mysqlPool) return false;
await executeLegacyCompat(
(statement) => mysqlPool!.query(statement).then(() => undefined),
requiredIndex.mysqlSql,
);
} else {
if (!pgPool) return false;
await executeLegacyCompat(
(statement) => pgPool!.query(statement).then(() => undefined),
requiredIndex.postgresSql,
);
}
} catch (error) {
if (!isDuplicateIndexError(error)) {
console.warn(`[db] failed to ensure ${requiredIndex.name}`, error);
}
}
}
return true;
}
try {
if (runtimeDbDialect === 'mysql') {
if (!mysqlPool) return false;
for (const column of requiredColumns) {
const [rows] = await mysqlPool.query('SHOW COLUMNS FROM `proxy_logs` LIKE ?', [column.name]);
if (Array.isArray(rows) && rows.length > 0) continue;
await executeLegacyCompat(
(statement) => mysqlPool!.query(statement).then(() => undefined),
`ALTER TABLE \`proxy_logs\` ADD COLUMN \`${column.name}\` ${column.mysqlType}`,
);
}
for (const requiredIndex of requiredIndexes) {
if (await hasMysqlIndex(requiredIndex.name)) continue;
await executeLegacyCompat(
(statement) => mysqlPool!.query(statement).then(() => undefined),
requiredIndex.mysqlSql,
);
}
} else {
if (!pgPool) return false;
for (const column of requiredColumns) {
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', column.name],
);
if (Number(result.rowCount || 0) > 0) continue;
await executeLegacyCompat(
(statement) => pgPool!.query(statement).then(() => undefined),
`ALTER TABLE "proxy_logs" ADD COLUMN "${column.name}" ${column.postgresType}`,
);
}
for (const requiredIndex of requiredIndexes) {
if (await hasPostgresIndex(requiredIndex.name)) continue;
await executeLegacyCompat(
(statement) => pgPool!.query(statement).then(() => undefined),
requiredIndex.postgresSql,
);
}
}
proxyLogClientColumnsAvailable = true;
return true;
} catch (error) {
if (isDuplicateColumnError(error) || isDuplicateIndexError(error)) {
proxyLogClientColumnsAvailable = await hasProxyLogClientColumns();
return proxyLogClientColumnsAvailable;
}
proxyLogClientColumnsAvailable = false;
console.warn('[db] failed to ensure proxy_logs client columns', error);
return false;
}
}
function resetSchemaCapabilityCache() {
proxyLogBillingDetailsColumnAvailable = null;
proxyLogDownstreamApiKeyIdColumnAvailable = null;
proxyLogClientColumnsAvailable = null;
}
async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) {
@@ -994,6 +1221,7 @@ function initSqliteDb() {
ensureRouteGroupingSchema();
ensureDownstreamApiKeySchema();
ensureProxyLogBillingDetailsSchema();
ensureProxyLogClientSchema();
ensureProxyVideoTaskSchema();
ensureProxyFileSchema();
+2
View File
@@ -4,6 +4,8 @@ import { classifyLegacyCompatMutation } from './legacySchemaCompat.js';
describe('legacy schema compat boundary', () => {
it('allows only explicitly registered legacy upgrade shims', () => {
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN billing_details text;')).toBe('legacy');
expect(classifyLegacyCompatMutation('ALTER TABLE proxy_logs ADD COLUMN client_app_id text;')).toBe('legacy');
expect(classifyLegacyCompatMutation('CREATE INDEX proxy_logs_client_app_id_created_at_idx ON proxy_logs(client_app_id, created_at);')).toBe('legacy');
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "use_system_proxy" = FALSE WHERE "use_system_proxy" IS NULL')).toBe('legacy');
expect(classifyLegacyCompatMutation('ALTER TABLE sites ADD COLUMN brand_new_column text;')).toBe('forbidden');
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "brand_new_column" = 1')).toBe('forbidden');
+6
View File
@@ -66,6 +66,10 @@ const LEGACY_COMPAT_COLUMNS = new Set([
'downstream_api_keys.group_name',
'downstream_api_keys.tags',
'proxy_logs.billing_details',
'proxy_logs.client_family',
'proxy_logs.client_app_id',
'proxy_logs.client_app_name',
'proxy_logs.client_confidence',
'proxy_logs.downstream_api_key_id',
]);
@@ -81,6 +85,8 @@ const LEGACY_COMPAT_INDEXES = new Set([
'downstream_api_keys_name_idx',
'downstream_api_keys_enabled_idx',
'downstream_api_keys_expires_at_idx',
'proxy_logs_client_app_id_created_at_idx',
'proxy_logs_client_family_created_at_idx',
'proxy_logs_downstream_api_key_created_at_idx',
'route_group_sources_group_source_unique',
'route_group_sources_source_route_id_idx',
+30
View File
@@ -193,6 +193,10 @@ export const proxyLogs = sqliteTable('proxy_logs', {
totalTokens: integer('total_tokens'),
estimatedCost: real('estimated_cost'),
billingDetails: text('billing_details'),
clientFamily: text('client_family'),
clientAppId: text('client_app_id'),
clientAppName: text('client_app_name'),
clientConfidence: text('client_confidence'),
errorMessage: text('error_message'),
retryCount: integer('retry_count').default(0),
createdAt: text('created_at').default(sql`(datetime('now'))`),
@@ -202,6 +206,8 @@ export const proxyLogs = sqliteTable('proxy_logs', {
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),
clientAppCreatedIdx: index('proxy_logs_client_app_id_created_at_idx').on(table.clientAppId, table.createdAt),
clientFamilyCreatedIdx: index('proxy_logs_client_family_created_at_idx').on(table.clientFamily, table.createdAt),
}));
export const proxyVideoTasks = sqliteTable('proxy_video_tasks', {
@@ -276,6 +282,30 @@ export const downstreamApiKeys = sqliteTable('downstream_api_keys', {
expiresAtIdx: index('downstream_api_keys_expires_at_idx').on(table.expiresAt),
}));
export const siteAnnouncements = sqliteTable('site_announcements', {
id: integer('id').primaryKey({ autoIncrement: true }),
siteId: integer('site_id').notNull().references(() => sites.id, { onDelete: 'cascade' }),
platform: text('platform').notNull(),
sourceKey: text('source_key').notNull(),
title: text('title').notNull(),
content: text('content').notNull(),
level: text('level').notNull().default('info'),
sourceUrl: text('source_url'),
startsAt: text('starts_at'),
endsAt: text('ends_at'),
upstreamCreatedAt: text('upstream_created_at'),
upstreamUpdatedAt: text('upstream_updated_at'),
firstSeenAt: text('first_seen_at').default(sql`(datetime('now'))`),
lastSeenAt: text('last_seen_at').default(sql`(datetime('now'))`),
readAt: text('read_at'),
dismissedAt: text('dismissed_at'),
rawPayload: text('raw_payload'),
}, (table) => ({
siteSourceKeyUnique: uniqueIndex('site_announcements_site_source_key_unique').on(table.siteId, table.sourceKey),
siteIdFirstSeenAtIdx: index('site_announcements_site_id_first_seen_at_idx').on(table.siteId, table.firstSeenAt),
readAtIdx: index('site_announcements_read_at_idx').on(table.readAt),
}));
export const events = sqliteTable('events', {
id: integer('id').primaryKey({ autoIncrement: true }),
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
@@ -103,8 +103,8 @@ describe('schema artifact generator', () => {
).toBeLessThan(
artifacts.postgresBootstrap.indexOf('CREATE TABLE IF NOT EXISTS "account_tokens"'),
);
expect(artifacts.mysqlBootstrap).toContain('`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP');
expect(artifacts.postgresBootstrap).toContain('"created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
expect(artifacts.mysqlBootstrap).toContain("`created_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))");
expect(artifacts.postgresBootstrap).toContain(`"created_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')`);
});
it('uses varchar for mysql text primary keys so bootstrap ddl stays executable', () => {
+6 -3
View File
@@ -81,7 +81,7 @@ function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaCo
case 'real':
return 'DOUBLE';
case 'datetime':
return 'DATETIME';
return 'VARCHAR(191)';
case 'json':
return 'JSON';
case 'text':
@@ -99,7 +99,7 @@ function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaCo
case 'real':
return 'DOUBLE PRECISION';
case 'datetime':
return 'TIMESTAMP';
return 'TEXT';
case 'json':
return 'JSONB';
case 'text':
@@ -117,7 +117,10 @@ function formatDefaultValue(dialect: SqlDialect, column: SchemaContractColumn):
if (dialect === 'sqlite') {
return " DEFAULT (datetime('now'))";
}
return ' DEFAULT CURRENT_TIMESTAMP';
if (dialect === 'mysql') {
return " DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))";
}
return " DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')";
}
if (column.logicalType === 'boolean') {
+8
View File
@@ -85,10 +85,18 @@ describe('database schema parity', () => {
const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8');
expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer');
expect(contract.tables.proxy_logs?.columns.client_app_id?.logicalType).toBe('text');
expect(contract.tables.proxy_logs?.columns.client_family?.logicalType).toBe('text');
expect(contract.indexes.some((index) => index.name === 'proxy_logs_downstream_api_key_created_at_idx')).toBe(true);
expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_app_id_created_at_idx')).toBe(true);
expect(contract.indexes.some((index) => index.name === 'proxy_logs_client_family_created_at_idx')).toBe(true);
expect(mysqlBootstrap).toContain('`downstream_api_key_id`');
expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`');
expect(mysqlBootstrap).toContain('`client_app_id`');
expect(mysqlBootstrap).toContain('`proxy_logs_client_app_id_created_at_idx`');
expect(postgresBootstrap).toContain('"downstream_api_key_id"');
expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"');
expect(postgresBootstrap).toContain('"client_app_id"');
expect(postgresBootstrap).toContain('"proxy_logs_client_app_id_created_at_idx"');
});
});
@@ -0,0 +1,49 @@
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { generateDialectArtifacts } from './schemaArtifactGenerator.js';
import type { SchemaContract } from './schemaContract.js';
import * as schema from './schema.js';
const dbDir = dirname(fileURLToPath(import.meta.url));
const schemaContractPath = resolve(dbDir, 'generated/schemaContract.json');
function readSchemaContract(): SchemaContract {
return JSON.parse(readFileSync(schemaContractPath, 'utf8')) as SchemaContract;
}
describe('site announcements schema', () => {
it('declares the site_announcements table in the Drizzle schema', () => {
expect(schema.siteAnnouncements).toBeDefined();
});
it('keeps site_announcements in the generated schema contract', () => {
const contract = readSchemaContract();
const table = contract.tables.site_announcements;
expect(table).toBeDefined();
expect(table?.columns.site_id?.logicalType).toBe('integer');
expect(table?.columns.source_key?.logicalType).toBe('text');
expect(table?.columns.first_seen_at?.logicalType).toBe('datetime');
expect(table?.columns.last_seen_at?.logicalType).toBe('datetime');
expect(table?.columns.read_at?.logicalType).toBe('datetime');
expect(table?.columns.raw_payload?.logicalType).toBe('text');
expect(contract.indexes.some((index) => index.name === 'site_announcements_site_id_first_seen_at_idx')).toBe(true);
expect(contract.indexes.some((index) => index.name === 'site_announcements_read_at_idx')).toBe(true);
expect(contract.uniques.some((unique) => unique.name === 'site_announcements_site_source_key_unique')).toBe(true);
});
it('emits bootstrap sql for the site_announcements table', () => {
const artifacts = generateDialectArtifacts(readSchemaContract());
expect(artifacts.mysqlBootstrap).toContain('CREATE TABLE IF NOT EXISTS `site_announcements`');
expect(artifacts.mysqlBootstrap).toContain("`first_seen_at` VARCHAR(191) DEFAULT (DATE_FORMAT(NOW(), '%Y-%m-%d %H:%i:%s'))");
expect(artifacts.mysqlBootstrap).toContain('`upstream_created_at` VARCHAR(191)');
expect(artifacts.mysqlBootstrap).toContain('`site_announcements_site_source_key_unique`');
expect(artifacts.postgresBootstrap).toContain('CREATE TABLE IF NOT EXISTS "site_announcements"');
expect(artifacts.postgresBootstrap).toContain(`"first_seen_at" TEXT DEFAULT to_char(timezone('UTC', CURRENT_TIMESTAMP), 'YYYY-MM-DD HH24:MI:SS')`);
expect(artifacts.postgresBootstrap).toContain('"upstream_created_at" TEXT');
expect(artifacts.postgresBootstrap).toContain('"site_announcements_site_source_key_unique"');
});
});
+47
View File
@@ -2,6 +2,7 @@ import Fastify from 'fastify';
import cors from '@fastify/cors';
import fastifyStatic from '@fastify/static';
import { buildFastifyOptions, config } from './config.js';
import { normalizePayloadRulesConfig } from './services/payloadRules.js';
import { authMiddleware } from './middleware/auth.js';
import { sitesRoutes } from './routes/api/sites.js';
import { accountsRoutes } from './routes/api/accounts.js';
@@ -18,14 +19,19 @@ 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 { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
import { proxyRoutes } from './routes/proxy/router.js';
import { startScheduler } from './services/checkinScheduler.js';
import { rebuildTokenRoutesFromAvailability } from './services/modelService.js';
import { startProxyFileRetentionService, stopProxyFileRetentionService } from './services/proxyFileRetentionService.js';
import { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
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 { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js';
import { reloadBackupWebdavScheduler } from './services/backupService.js';
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
import { existsSync } from 'fs';
@@ -35,6 +41,7 @@ import { normalizeLogCleanupRetentionDays } from './services/logCleanupService.j
import {
db,
ensureProxyFileCompatibilityColumns,
ensureProxyLogClientColumns,
ensureProxyLogDownstreamApiKeyIdColumn,
ensureProxyLogBillingDetailsColumn,
ensureRouteGroupingCompatibilityColumns,
@@ -139,9 +146,36 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled;
}
const codexHeaderDefaults = parseSettingFromMap<unknown>(settingsMap, 'codex_header_defaults');
if (codexHeaderDefaults && typeof codexHeaderDefaults === 'object') {
const next = codexHeaderDefaults as Record<string, unknown>;
config.codexHeaderDefaults = {
userAgent: typeof next.userAgent === 'string'
? next.userAgent.trim()
: (typeof next['user-agent'] === 'string' ? next['user-agent'].trim() : config.codexHeaderDefaults.userAgent),
betaFeatures: typeof next.betaFeatures === 'string'
? next.betaFeatures.trim()
: (typeof next['beta-features'] === 'string' ? next['beta-features'].trim() : config.codexHeaderDefaults.betaFeatures),
};
}
if (settingsMap.has('payload_rules')) {
config.payloadRules = normalizePayloadRulesConfig(parseSettingFromMap<unknown>(settingsMap, 'payload_rules'));
}
const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
const checkinScheduleMode = parseSettingFromMap<string>(settingsMap, 'checkin_schedule_mode');
if (checkinScheduleMode === 'cron' || checkinScheduleMode === 'interval') {
config.checkinScheduleMode = checkinScheduleMode;
}
const checkinIntervalHours = parseSettingFromMap<number>(settingsMap, 'checkin_interval_hours');
if (typeof checkinIntervalHours === 'number' && Number.isFinite(checkinIntervalHours) && checkinIntervalHours >= 1 && checkinIntervalHours <= 24) {
config.checkinIntervalHours = Math.trunc(checkinIntervalHours);
}
const balanceRefreshCron = parseSettingFromMap<string>(settingsMap, 'balance_refresh_cron');
if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron;
@@ -199,6 +233,12 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
const telegramChatId = parseSettingFromMap<string>(settingsMap, 'telegram_chat_id');
if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId;
const telegramUseSystemProxy = parseSettingFromMap<boolean>(settingsMap, 'telegram_use_system_proxy');
if (typeof telegramUseSystemProxy === 'boolean') config.telegramUseSystemProxy = telegramUseSystemProxy;
const telegramMessageThreadId = parseSettingFromMap<string>(settingsMap, 'telegram_message_thread_id');
if (typeof telegramMessageThreadId === 'string') config.telegramMessageThreadId = telegramMessageThreadId;
const smtpEnabled = parseSettingFromMap<boolean>(settingsMap, 'smtp_enabled');
if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled;
@@ -277,6 +317,7 @@ try {
await ensureSiteCompatibilityColumns();
await ensureRouteGroupingCompatibilityColumns();
await ensureProxyFileCompatibilityColumns();
await ensureProxyLogClientColumns();
await ensureProxyLogDownstreamApiKeyIdColumn();
const finalRows = await db.select().from(schema.settings).all();
const finalMap = toSettingsMap(finalRows);
@@ -291,6 +332,7 @@ try {
await repairStoredCreatedAtValues();
await migrateSiteApiKeysToAccounts();
await ensureDefaultSitesSeeded();
await rebuildTokenRoutesFromAvailability();
console.log('Loaded runtime settings overrides');
} catch (error) {
@@ -320,6 +362,7 @@ await app.register(settingsRoutes);
await app.register(accountTokensRoutes);
await app.register(searchRoutes);
await app.register(eventsRoutes);
await app.register(siteAnnouncementsRoutes);
await app.register(taskRoutes);
await app.register(testRoutes);
await app.register(monitorRoutes);
@@ -358,13 +401,17 @@ if (existsSync(webDir)) {
// Start scheduler
await startScheduler();
await reloadBackupWebdavScheduler();
startSiteAnnouncementPolling();
try {
await startOAuthLoopbackCallbackServers();
} catch (error) {
console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`);
}
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
startProxyFileRetentionService();
app.addHook('onClose', async () => {
stopProxyFileRetentionService();
stopProxyLogRetentionService();
await stopOAuthLoopbackCallbackServers();
});
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest';
import {
rankConversationFileEndpoints,
resolveConversationFileEndpointCapability,
summarizeConversationFileInputsInOpenAiBody,
} from './conversationFileCapabilities.js';
describe('conversationFileCapabilities', () => {
it('summarizes image, audio, document, and remote file inputs from OpenAI bodies', () => {
const summary = summarizeConversationFileInputsInOpenAiBody({
messages: [
{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } },
{ type: 'input_audio', input_audio: { data: 'UklGRg==', format: 'wav' } },
{
type: 'file',
file: {
filename: 'brief.pdf',
file_data: 'JVBERi0xLjc=',
},
},
{
type: 'input_file',
filename: 'remote.pdf',
file_url: 'https://example.com/remote.pdf',
},
],
},
],
});
expect(summary).toEqual({
hasImage: true,
hasAudio: true,
hasDocument: true,
hasRemoteDocumentUrl: true,
});
});
it('describes native remote-document support for claude messages and inline-only support for gemini chat paths', () => {
expect(resolveConversationFileEndpointCapability({
sitePlatform: 'claude',
endpoint: 'messages',
})).toMatchObject({
image: 'native',
audio: 'unsupported',
document: 'native',
preservesRemoteDocumentUrl: true,
});
expect(resolveConversationFileEndpointCapability({
sitePlatform: 'gemini-cli',
endpoint: 'chat',
})).toMatchObject({
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
});
});
it('ranks document-capable endpoints ahead of lossy fallbacks', () => {
const ranked = rankConversationFileEndpoints({
sitePlatform: 'new-api',
requestedOrder: ['chat', 'messages', 'responses'],
summary: {
hasImage: false,
hasAudio: false,
hasDocument: true,
hasRemoteDocumentUrl: false,
},
});
expect(ranked).toEqual(['responses', 'messages', 'chat']);
});
});
@@ -0,0 +1,267 @@
import { inferInputFileMimeType, normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
export type ConversationFileTransport = 'unsupported' | 'inline_only' | 'native';
export type ConversationFileEndpoint = 'chat' | 'messages' | 'responses';
export type ConversationFileInputSummary = {
hasImage: boolean;
hasAudio: boolean;
hasDocument: boolean;
hasRemoteDocumentUrl: boolean;
};
export type ConversationFileEndpointCapability = {
image: ConversationFileTransport;
audio: ConversationFileTransport;
document: ConversationFileTransport;
preservesRemoteDocumentUrl: boolean;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function classifyMimeFamily(mimeType: string | null): 'image' | 'audio' | 'document' {
const normalized = asTrimmedString(mimeType).toLowerCase();
if (normalized.startsWith('image/')) return 'image';
if (normalized.startsWith('audio/')) return 'audio';
return 'document';
}
function appendConversationFileSummary(
summary: ConversationFileInputSummary,
item: Record<string, unknown>,
): void {
const normalizedFile = normalizeInputFileBlock(item);
if (normalizedFile) {
const mimeType = inferInputFileMimeType(normalizedFile);
const family = classifyMimeFamily(mimeType);
if (family === 'image') {
summary.hasImage = true;
return;
}
if (family === 'audio') {
summary.hasAudio = true;
return;
}
summary.hasDocument = true;
if (normalizedFile.fileUrl) {
summary.hasRemoteDocumentUrl = true;
}
return;
}
const type = asTrimmedString(item.type).toLowerCase();
if (type === 'image_url' || type === 'input_image') {
summary.hasImage = true;
return;
}
if (type === 'input_audio') {
summary.hasAudio = true;
return;
}
if (Array.isArray(item.content)) {
for (const nested of item.content) {
if (isRecord(nested)) {
appendConversationFileSummary(summary, nested);
}
}
}
}
function createEmptySummary(): ConversationFileInputSummary {
return {
hasImage: false,
hasAudio: false,
hasDocument: false,
hasRemoteDocumentUrl: false,
};
}
export function summarizeConversationFileInputsInOpenAiBody(
body: Record<string, unknown>,
): ConversationFileInputSummary {
const summary = createEmptySummary();
const messages = Array.isArray(body.messages) ? body.messages : [];
for (const message of messages) {
if (!isRecord(message)) continue;
const content = message.content;
if (Array.isArray(content)) {
for (const item of content) {
if (isRecord(item)) appendConversationFileSummary(summary, item);
}
continue;
}
if (isRecord(content)) {
appendConversationFileSummary(summary, content);
}
}
return summary;
}
export function summarizeConversationFileInputsInResponsesBody(
body: Record<string, unknown>,
): ConversationFileInputSummary {
const summary = createEmptySummary();
const input = body.input;
if (Array.isArray(input)) {
for (const item of input) {
if (isRecord(item)) appendConversationFileSummary(summary, item);
}
return summary;
}
if (isRecord(input)) {
appendConversationFileSummary(summary, input);
}
return summary;
}
export function resolveConversationFileEndpointCapability(input: {
sitePlatform?: string;
endpoint: ConversationFileEndpoint;
}): ConversationFileEndpointCapability {
const platform = asTrimmedString(input.sitePlatform).toLowerCase();
const endpoint = input.endpoint;
if (platform === 'codex') {
if (endpoint === 'responses') {
return {
image: 'native',
audio: 'native',
document: 'native',
preservesRemoteDocumentUrl: true,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (platform === 'claude') {
if (endpoint === 'messages') {
return {
image: 'native',
audio: 'unsupported',
document: 'native',
preservesRemoteDocumentUrl: true,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (platform === 'gemini-cli' || platform === 'antigravity') {
if (endpoint === 'chat') {
return {
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (platform === 'gemini') {
if (endpoint === 'responses') {
return {
image: 'native',
audio: 'native',
document: 'native',
preservesRemoteDocumentUrl: true,
};
}
if (endpoint === 'chat') {
return {
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'unsupported',
audio: 'unsupported',
document: 'unsupported',
preservesRemoteDocumentUrl: false,
};
}
if (endpoint === 'responses') {
return {
image: 'native',
audio: 'native',
document: 'native',
preservesRemoteDocumentUrl: true,
};
}
if (endpoint === 'messages') {
return {
image: 'native',
audio: 'unsupported',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
return {
image: 'native',
audio: 'native',
document: 'inline_only',
preservesRemoteDocumentUrl: false,
};
}
export function rankConversationFileEndpoints(input: {
sitePlatform?: string;
requestedOrder: ConversationFileEndpoint[];
summary: ConversationFileInputSummary;
preferMessagesForClaudeModel?: boolean;
}): ConversationFileEndpoint[] {
if (!input.summary.hasDocument) {
return [...input.requestedOrder];
}
const preferredDocumentOrder = input.preferMessagesForClaudeModel === true
? ['messages', 'responses', 'chat']
: ['responses', 'messages', 'chat'];
const supportedDocumentEndpoints = preferredDocumentOrder.filter((endpoint) => {
if (!input.requestedOrder.includes(endpoint as ConversationFileEndpoint)) return false;
const capability = resolveConversationFileEndpointCapability({
sitePlatform: input.sitePlatform,
endpoint: endpoint as ConversationFileEndpoint,
});
if (capability.document === 'unsupported') return false;
if (input.summary.hasRemoteDocumentUrl && !capability.preservesRemoteDocumentUrl) return false;
return true;
}) as ConversationFileEndpoint[];
if (supportedDocumentEndpoints.length <= 0) {
return [...input.requestedOrder];
}
return [
...supportedDocumentEndpoints,
...input.requestedOrder.filter((endpoint) => !supportedDocumentEndpoints.includes(endpoint)),
];
}
@@ -1,5 +1,21 @@
import type { CliProfileDefinition, DetectCliProfileInput } from './types.js';
const CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES = [
'codex_cli_rs/',
'codex_vscode/',
'codex_app/',
'codex_chatgpt_desktop/',
'codex_atlas/',
'codex_exec/',
'codex_sdk_ts/',
'codex ',
];
const CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES = [
'codex_',
'codex ',
];
function headerValueToString(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
@@ -38,6 +54,18 @@ function hasHeaderPrefix(headers: Record<string, unknown> | undefined, prefix: s
});
}
function matchesHeaderPrefixes(value: string | null, prefixes: string[]): boolean {
const normalizedValue = value?.trim().toLowerCase() || '';
if (!normalizedValue) return false;
return prefixes.some((prefix) => {
const normalizedPrefix = prefix.trim().toLowerCase();
if (!normalizedPrefix) return false;
return normalizedValue.startsWith(normalizedPrefix)
|| normalizedValue.includes(normalizedPrefix);
});
}
function isCodexPath(path: string): boolean {
const normalizedPath = path.trim().toLowerCase();
return normalizedPath.startsWith('/v1/responses')
@@ -62,7 +90,8 @@ export function isCodexRequest(input: DetectCliProfileInput): boolean {
if (!headers) return false;
const originator = getHeaderValue(headers, 'originator');
if (originator?.toLowerCase() === 'codex_cli_rs') return true;
if (matchesHeaderPrefixes(originator, CODEX_OFFICIAL_CLIENT_ORIGINATOR_PREFIXES)) return true;
if (matchesHeaderPrefixes(getHeaderValue(headers, 'user-agent'), CODEX_OFFICIAL_CLIENT_USER_AGENT_PREFIXES)) return true;
if (getHeaderValue(headers, 'openai-beta')) return true;
if (hasHeaderPrefix(headers, 'x-stainless-')) return true;
if (getCodexSessionId(headers)) return true;
@@ -39,6 +39,34 @@ describe('detectCliProfile', () => {
});
});
it('detects broader Codex official-client headers from user-agent and originator prefixes', () => {
expect(detectCliProfile({
downstreamPath: '/v1/responses',
headers: {
'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3',
},
})).toMatchObject({
id: 'codex',
capabilities: {
supportsResponsesWebsocketIncremental: true,
echoesTurnState: true,
},
});
expect(detectCliProfile({
downstreamPath: '/v1/responses',
headers: {
originator: 'codex_exec',
},
})).toMatchObject({
id: 'codex',
capabilities: {
supportsResponsesWebsocketIncremental: true,
echoesTurnState: true,
},
});
});
it('detects Claude Code requests on the count_tokens surface and exposes token counting support', () => {
expect(detectCliProfile({
downstreamPath: '/v1/messages/count_tokens',
@@ -1,8 +1,10 @@
import { createHash, randomUUID } from 'node:crypto';
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
import { config } from '../../config.js';
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 CODEX_RESPONSES_WEBSOCKET_BETA = 'responses_websockets=2026-02-06';
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
@@ -53,6 +55,7 @@ function uuidFromSeed(seed: string): string {
function buildCodexRuntimeHeaders(input: {
baseHeaders: Record<string, string>;
providerHeaders?: Record<string, string>;
oauthProvider?: string;
explicitSessionId?: string | null;
continuityKey?: string | null;
}): Record<string, string> {
@@ -64,7 +67,21 @@ function buildCodexRuntimeHeaders(input: {
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 isCodexOauth = asTrimmedString(input.oauthProvider).toLowerCase() === 'codex';
const configuredUserAgent = isCodexOauth ? asTrimmedString(config.codexHeaderDefaults.userAgent) : '';
const websocketTransport = getInputHeader(input.baseHeaders, 'x-metapi-responses-websocket-transport') === '1';
const configuredBetaFeatures = (
isCodexOauth && websocketTransport
? asTrimmedString(config.codexHeaderDefaults.betaFeatures)
: ''
);
const userAgent = configuredUserAgent || getInputHeader(input.baseHeaders, 'user-agent') || CODEX_DEFAULT_USER_AGENT;
const codexBetaFeatures = getInputHeader(input.baseHeaders, 'x-codex-beta-features') || configuredBetaFeatures;
const codexTurnState = getInputHeader(input.baseHeaders, 'x-codex-turn-state');
const codexTurnMetadata = getInputHeader(input.baseHeaders, 'x-codex-turn-metadata');
const timingMetrics = getInputHeader(input.baseHeaders, 'x-responsesapi-include-timing-metrics');
const openAiBeta = getInputHeader(input.baseHeaders, 'openai-beta')
|| (websocketTransport ? CODEX_RESPONSES_WEBSOCKET_BETA : null);
const explicitSessionId = asTrimmedString(input.explicitSessionId);
const continuityKey = asTrimmedString(input.continuityKey);
const sessionId = (
@@ -87,6 +104,11 @@ function buildCodexRuntimeHeaders(input: {
...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}),
Originator: originator,
Version: version,
...(codexBetaFeatures ? { 'x-codex-beta-features': codexBetaFeatures } : {}),
...(codexTurnState ? { 'x-codex-turn-state': codexTurnState } : {}),
...(codexTurnMetadata ? { 'x-codex-turn-metadata': codexTurnMetadata } : {}),
...(timingMetrics ? { 'x-responsesapi-include-timing-metrics': timingMetrics } : {}),
...(openAiBeta ? { 'OpenAI-Beta': openAiBeta } : {}),
Session_id: sessionId,
...(conversationId ? { Conversation_id: conversationId } : {}),
'User-Agent': userAgent,
@@ -101,6 +123,7 @@ export const codexProviderProfile: ProviderProfile = {
const headers = buildCodexRuntimeHeaders({
baseHeaders: input.baseHeaders,
providerHeaders: input.providerHeaders,
oauthProvider: input.oauthProvider,
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
continuityKey: asTrimmedString(input.codexSessionCacheKey) || null,
});
@@ -0,0 +1,37 @@
const CODEX_RESPONSES_WEBSOCKET_BETA = 'responses_websockets=2026-02-06';
function getHeaderValue(headers: Record<string, string>, key: string): string {
const expected = key.trim().toLowerCase();
for (const [candidateKey, candidateValue] of Object.entries(headers)) {
if (candidateKey.trim().toLowerCase() !== expected) continue;
return candidateValue;
}
return '';
}
export function buildCodexWebsocketHandshakeHeaders(headers: Record<string, string>): Record<string, string> {
const next = { ...headers };
const openAiBeta = getHeaderValue(next, 'openai-beta').trim();
if (!openAiBeta) {
next['OpenAI-Beta'] = CODEX_RESPONSES_WEBSOCKET_BETA;
return next;
}
if (!openAiBeta.includes('responses_websockets=')) {
next['OpenAI-Beta'] = `${openAiBeta},${CODEX_RESPONSES_WEBSOCKET_BETA}`;
}
return next;
}
export function buildCodexWebsocketRequestBody(body: Record<string, unknown>): Record<string, unknown> {
return {
type: 'response.create',
...body,
};
}
export function toCodexWebsocketUrl(requestUrl: string): string {
const parsed = new URL(requestUrl);
if (parsed.protocol === 'https:') parsed.protocol = 'wss:';
if (parsed.protocol === 'http:') parsed.protocol = 'ws:';
return parsed.toString();
}
@@ -0,0 +1,137 @@
import { AddressInfo } from 'node:net';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { WebSocketServer } from 'ws';
describe('codexWebsocketRuntime', () => {
let upstreamServer: WebSocketServer;
let upstreamWsUrl: string;
let upstreamConnectionCount = 0;
let upstreamRequests: Record<string, unknown>[] = [];
beforeAll(async () => {
upstreamServer = new WebSocketServer({ port: 0 });
upstreamServer.on('connection', (socket) => {
upstreamConnectionCount += 1;
socket.on('message', (payload) => {
const parsed = JSON.parse(String(payload)) as Record<string, unknown>;
upstreamRequests.push(parsed);
const responseId = `resp-${upstreamRequests.length}`;
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: responseId,
object: 'response',
model: parsed.model || 'gpt-5.4',
status: 'completed',
output: [],
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
},
},
}));
});
});
await new Promise<void>((resolve) => upstreamServer.once('listening', () => resolve()));
const address = upstreamServer.address() as AddressInfo;
upstreamWsUrl = `ws://127.0.0.1:${address.port}/backend-api/codex/responses`;
});
beforeEach(() => {
upstreamConnectionCount = 0;
upstreamRequests = [];
});
afterAll(async () => {
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
});
it('reuses the same upstream websocket connection across turns for one execution session', async () => {
const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js');
const runtime = createCodexWebsocketRuntime();
const first = await runtime.sendRequest({
sessionId: 'exec-session-1',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
input: [],
},
});
const second = await runtime.sendRequest({
sessionId: 'exec-session-1',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
previous_response_id: 'resp-1',
input: [],
},
});
expect(first.events[0]).toMatchObject({
type: 'response.completed',
response: { id: 'resp-1' },
});
expect(second.events[0]).toMatchObject({
type: 'response.completed',
response: { id: 'resp-2' },
});
expect(upstreamConnectionCount).toBe(1);
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
model: 'gpt-5.4',
});
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp-1',
});
await runtime.closeSession('exec-session-1');
});
it('closes the upstream websocket when the execution session is closed explicitly', async () => {
const { createCodexWebsocketRuntime } = await import('./codexWebsocketRuntime.js');
const runtime = createCodexWebsocketRuntime();
await runtime.sendRequest({
sessionId: 'exec-session-close',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
input: [],
},
});
await runtime.closeSession('exec-session-close');
await runtime.sendRequest({
sessionId: 'exec-session-close',
requestUrl: upstreamWsUrl,
headers: {
Authorization: 'Bearer oauth-access-token',
'OpenAI-Beta': 'responses_websockets=2026-02-06',
},
body: {
model: 'gpt-5.4',
input: [],
},
});
expect(upstreamConnectionCount).toBe(2);
await runtime.closeSession('exec-session-close');
});
});
@@ -0,0 +1,284 @@
import type { IncomingMessage } from 'node:http';
import WebSocket from 'ws';
import {
buildCodexWebsocketHandshakeHeaders,
buildCodexWebsocketRequestBody,
toCodexWebsocketUrl,
} from './codexWebsocketHeaders.js';
import { createCodexWebsocketSessionStore } from './codexWebsocketSessionStore.js';
import type {
CodexWebsocketRuntimeResult,
CodexWebsocketRuntimeSendInput,
CodexWebsocketSession,
CodexWebsocketSessionStore,
} from './types.js';
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function isTerminalEvent(payload: Record<string, unknown>): boolean {
const type = asTrimmedString(payload.type);
return type === 'response.completed' || type === 'response.failed';
}
export class CodexWebsocketRuntimeError extends Error {
events: Array<Record<string, unknown>>;
status?: number;
payload?: unknown;
constructor(
message: string,
options?: {
events?: Array<Record<string, unknown>>;
status?: number;
payload?: unknown;
},
) {
super(message);
this.name = 'CodexWebsocketRuntimeError';
this.events = options?.events ?? [];
this.status = options?.status;
this.payload = options?.payload;
}
}
function tryParseJson(raw: string): unknown {
const trimmed = raw.trim();
if (!trimmed) return undefined;
try {
return JSON.parse(trimmed);
} catch {
return undefined;
}
}
async function readUnexpectedResponseBody(response: IncomingMessage): Promise<string> {
return await new Promise<string>((resolve) => {
const chunks: Buffer[] = [];
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.once('end', () => {
resolve(Buffer.concat(chunks).toString('utf8'));
});
response.once('error', () => {
resolve('');
});
});
}
async function waitForSocketOpen(socket: WebSocket): Promise<void> {
if (socket.readyState === WebSocket.OPEN) return;
if (socket.readyState !== WebSocket.CONNECTING) {
throw new Error('upstream websocket is not open');
}
await new Promise<void>((resolve, reject) => {
const cleanup = () => {
socket.off('open', onOpen);
socket.off('error', onError);
socket.off('close', onClose);
socket.off('unexpected-response', onUnexpectedResponse);
};
const onOpen = () => {
cleanup();
resolve();
};
const onError = (error: Error) => {
cleanup();
reject(error);
};
const onClose = () => {
cleanup();
reject(new Error('upstream websocket closed before opening'));
};
const onUnexpectedResponse = (_request: unknown, response: IncomingMessage) => {
void readUnexpectedResponseBody(response).then((body) => {
cleanup();
reject(new CodexWebsocketRuntimeError(
body.trim() || response.statusMessage || `upstream websocket upgrade failed with status ${response.statusCode || 502}`,
{
status: response.statusCode || 502,
payload: tryParseJson(body),
},
));
});
};
socket.once('open', onOpen);
socket.once('error', onError);
socket.once('close', onClose);
socket.once('unexpected-response', onUnexpectedResponse);
});
}
async function closeSocket(socket: WebSocket | null): Promise<void> {
if (!socket) return;
if (socket.readyState === WebSocket.CLOSED) return;
await new Promise<void>((resolve) => {
const onClose = () => resolve();
socket.once('close', onClose);
try {
socket.close();
} catch {
socket.off('close', onClose);
resolve();
}
setTimeout(() => {
socket.off('close', onClose);
resolve();
}, 200);
});
}
function clearSessionSocket(session: CodexWebsocketSession, socket: WebSocket): void {
if (session.socket !== socket) return;
session.socket = null;
session.socketUrl = null;
}
async function ensureSessionSocket(
session: CodexWebsocketSession,
input: CodexWebsocketRuntimeSendInput,
): Promise<{ socket: WebSocket; reusedSession: boolean }> {
const requestUrl = toCodexWebsocketUrl(input.requestUrl);
const existing = session.socket;
if (
existing
&& session.socketUrl === requestUrl
&& existing.readyState === WebSocket.OPEN
) {
return {
socket: existing,
reusedSession: true,
};
}
if (existing) {
await closeSocket(existing);
clearSessionSocket(session, existing);
}
const nextSocket = new WebSocket(requestUrl, {
headers: buildCodexWebsocketHandshakeHeaders(input.headers),
});
await waitForSocketOpen(nextSocket);
session.socket = nextSocket;
session.socketUrl = requestUrl;
nextSocket.on('close', () => {
clearSessionSocket(session, nextSocket);
});
nextSocket.on('error', () => {
clearSessionSocket(session, nextSocket);
});
return {
socket: nextSocket,
reusedSession: false,
};
}
async function sendSessionRequest(
session: CodexWebsocketSession,
input: CodexWebsocketRuntimeSendInput,
): Promise<CodexWebsocketRuntimeResult> {
const { socket, reusedSession } = await ensureSessionSocket(session, input);
const events: Array<Record<string, unknown>> = [];
return new Promise<CodexWebsocketRuntimeResult>((resolve, reject) => {
let settled = false;
const cleanup = () => {
socket.off('message', onMessage);
socket.off('error', onError);
socket.off('close', onClose);
};
const rejectWith = (message: string) => {
if (settled) return;
settled = true;
cleanup();
reject(new CodexWebsocketRuntimeError(message, { events: [...events] }));
};
const onMessage = (payload: WebSocket.RawData) => {
try {
const parsed = JSON.parse(String(payload));
if (!isRecord(parsed)) return;
events.push(parsed);
if (!isTerminalEvent(parsed)) return;
if (settled) return;
settled = true;
cleanup();
resolve({
events: [...events],
reusedSession,
});
} catch {
// Ignore malformed frames and wait for a terminal event.
}
};
const onError = (error: Error) => {
clearSessionSocket(session, socket);
rejectWith(error.message || 'upstream websocket error');
};
const onClose = () => {
clearSessionSocket(session, socket);
rejectWith('stream closed before response.completed');
};
socket.on('message', onMessage);
socket.once('error', onError);
socket.once('close', onClose);
socket.send(JSON.stringify(buildCodexWebsocketRequestBody(input.body)), (error) => {
if (!error) return;
clearSessionSocket(session, socket);
rejectWith(error.message || 'failed to send upstream websocket request');
});
});
}
export function createCodexWebsocketRuntime(input?: {
sessionStore?: CodexWebsocketSessionStore;
}) {
const sessionStore = input?.sessionStore || createCodexWebsocketSessionStore();
return {
async sendRequest(payload: CodexWebsocketRuntimeSendInput): Promise<CodexWebsocketRuntimeResult> {
const sessionId = payload.sessionId.trim();
if (!sessionId) {
throw new CodexWebsocketRuntimeError('missing websocket session id');
}
const session = sessionStore.getOrCreate(sessionId);
const run = session.queue
.catch(() => undefined)
.then(() => sendSessionRequest(session, payload));
session.queue = run.then(() => undefined, () => undefined);
return run;
},
async closeSession(sessionId: string): Promise<void> {
const session = sessionStore.take(sessionId);
if (!session) return;
await session.queue.catch(() => undefined);
await closeSocket(session.socket);
session.socket = null;
session.socketUrl = null;
},
async closeAllSessions(): Promise<void> {
const sessions = sessionStore.list();
for (const session of sessions) {
await this.closeSession(session.sessionId);
}
},
};
}
@@ -0,0 +1,34 @@
import type { CodexWebsocketSession, CodexWebsocketSessionStore } from './types.js';
export function createCodexWebsocketSessionStore(): CodexWebsocketSessionStore {
const sessions = new Map<string, CodexWebsocketSession>();
return {
getOrCreate(sessionId) {
const normalized = sessionId.trim();
const existing = sessions.get(normalized);
if (existing) return existing;
const created: CodexWebsocketSession = {
sessionId: normalized,
socket: null,
socketUrl: null,
queue: Promise.resolve(),
};
sessions.set(normalized, created);
return created;
},
take(sessionId) {
const normalized = sessionId.trim();
if (!normalized) return null;
const existing = sessions.get(normalized) || null;
if (existing) {
sessions.delete(normalized);
}
return existing;
},
list() {
return [...sessions.values()];
},
};
}
+26
View File
@@ -0,0 +1,26 @@
import type WebSocket from 'ws';
export type CodexWebsocketRuntimeSendInput = {
sessionId: string;
requestUrl: string;
headers: Record<string, string>;
body: Record<string, unknown>;
};
export type CodexWebsocketRuntimeResult = {
events: Array<Record<string, unknown>>;
reusedSession: boolean;
};
export type CodexWebsocketSession = {
sessionId: string;
socket: WebSocket | null;
socketUrl: string | null;
queue: Promise<unknown>;
};
export type CodexWebsocketSessionStore = {
getOrCreate(sessionId: string): CodexWebsocketSession;
take(sessionId: string): CodexWebsocketSession | null;
list(): CodexWebsocketSession[];
};
+136 -57
View File
@@ -1,7 +1,6 @@
import { TextDecoder } from 'node:util';
import type { FastifyRequest, FastifyReply } from 'fastify';
import { tokenRouter } from '../../services/tokenRouter.js';
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';
@@ -13,6 +12,8 @@ import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
import {
buildClaudeCountTokensUpstreamRequest,
buildUpstreamEndpointRequest,
recordUpstreamEndpointFailure,
recordUpstreamEndpointSuccess,
resolveUpstreamEndpointCandidates,
} from '../../routes/proxy/upstreamEndpoint.js';
import {
@@ -23,6 +24,7 @@ import {
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
@@ -30,7 +32,6 @@ import { anthropicMessagesTransformer } from '../../transformers/anthropic/messa
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
import {
ProxyInputFileResolutionError,
hasNonImageFileInputInOpenAiBody,
resolveOpenAiBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
@@ -39,14 +40,20 @@ import {
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
import { collectResponsesFinalPayloadFromSse } from '../../routes/proxy/responsesSseFinal.js';
import {
collectResponsesFinalPayloadFromSse,
collectResponsesFinalPayloadFromSseText,
createSingleChunkStreamReader,
looksLikeResponsesSseText,
} from '../../routes/proxy/responsesSseFinal.js';
import {
createGeminiCliStreamReader,
unwrapGeminiCliPayload,
} from '../../routes/proxy/geminiCliCompat.js';
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { detectCliProfile } from '../cliProfiles/registry.js';
import type { CliProfileId } from '../cliProfiles/types.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
@@ -58,25 +65,6 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
type DownstreamClientContext = {
clientKind: CliProfileId;
sessionId?: string;
traceHint?: string;
};
function detectDownstreamClientContext(input: {
downstreamPath: string;
headers?: Record<string, unknown>;
body?: unknown;
}): DownstreamClientContext {
const detected = detectCliProfile(input);
return {
clientKind: detected.id,
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
};
}
export async function handleChatSurfaceRequest(
request: FastifyRequest,
reply: FastifyReply,
@@ -117,7 +105,8 @@ export async function handleChatSurfaceRequest(
throw error;
}
}
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(resolvedOpenAiBody);
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const codexSessionCacheKey = deriveCodexSessionCacheKey({
downstreamFormat,
body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body,
@@ -125,8 +114,6 @@ export async function handleChatSurfaceRequest(
proxyToken: getProxyAuthContext(request)?.token || null,
});
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -167,9 +154,20 @@ export async function handleChatSurfaceRequest(
requestedModel,
{
hasNonImageFileInput,
conversationFileSummary,
},
),
];
const endpointRuntimeContext = {
siteId: selected.site.id,
modelName,
downstreamFormat,
requestedModelHint: requestedModel,
requestCapabilities: {
hasNonImageFileInput,
conversationFileSummary,
},
};
const buildProviderHeaders = () => (
buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
@@ -245,7 +243,7 @@ export async function handleChatSurfaceRequest(
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path);
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
if (refreshedResponse.ok) {
return {
@@ -272,6 +270,20 @@ export async function handleChatSurfaceRequest(
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
tryRecover,
onAttemptFailure: (ctx) => {
recordUpstreamEndpointFailure({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
status: ctx.response.status,
errorText: ctx.rawErrText,
});
},
onAttemptSuccess: (ctx) => {
recordUpstreamEndpointSuccess({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
});
},
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
logProxy(
@@ -290,7 +302,7 @@ export async function handleChatSurfaceRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
},
});
@@ -299,7 +311,11 @@ export async function handleChatSurfaceRequest(
const status = endpointResult.status || 502;
const errText = endpointResult.errText || 'unknown error';
const rawErrText = endpointResult.rawErrText || errText;
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status,
errorText: rawErrText,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -316,7 +332,7 @@ export async function handleChatSurfaceRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
await recordOauthQuotaResetHint({
accountId: selected.account.id,
@@ -394,6 +410,37 @@ export async function handleChatSurfaceRequest(
if (!upstreamContentType.includes('text/event-stream')) {
const fallbackText = await upstream.text();
rawText = fallbackText;
if (looksLikeResponsesSseText(fallbackText)) {
startSseResponse();
const streamResult = await streamSession.run(
createSingleChunkStreamReader(fallbackText),
reply.raw,
);
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id, modelName);
logProxy(
selected,
requestedModel,
'failed',
200,
latency,
streamResult.errorMessage,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
downstreamApiKeyId,
);
return;
}
return;
}
let fallbackData: unknown = null;
try {
fallbackData = JSON.parse(fallbackText);
@@ -407,7 +454,11 @@ export async function handleChatSurfaceRequest(
const latency = Date.now() - startTime;
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: failure.reason,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -424,7 +475,7 @@ export async function handleChatSurfaceRequest(
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
@@ -445,7 +496,11 @@ export async function handleChatSurfaceRequest(
startSseResponse();
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: 502,
errorText: streamResult.errorMessage,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -462,7 +517,7 @@ export async function handleChatSurfaceRequest(
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
return;
}
@@ -495,7 +550,11 @@ export async function handleChatSurfaceRequest(
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: 502,
errorText: streamResult.errorMessage,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -512,7 +571,7 @@ export async function handleChatSurfaceRequest(
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
return;
}
@@ -548,7 +607,7 @@ export async function handleChatSurfaceRequest(
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
@@ -566,7 +625,7 @@ export async function handleChatSurfaceRequest(
billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
return;
}
@@ -580,6 +639,9 @@ export async function handleChatSurfaceRequest(
upstreamData = collected.payload;
} else {
rawText = await upstream.text();
if (looksLikeResponsesSseText(rawText)) {
upstreamData = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload;
} else {
upstreamData = rawText;
try {
upstreamData = JSON.parse(rawText);
@@ -587,6 +649,7 @@ export async function handleChatSurfaceRequest(
upstreamData = rawText;
}
}
}
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
upstreamData = unwrapGeminiCliPayload(upstreamData);
}
@@ -595,7 +658,11 @@ export async function handleChatSurfaceRequest(
const parsedUsage = parseProxyUsage(upstreamData);
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: failure.reason,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -612,7 +679,7 @@ export async function handleChatSurfaceRequest(
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
@@ -656,7 +723,7 @@ export async function handleChatSurfaceRequest(
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
@@ -674,12 +741,15 @@ export async function handleChatSurfaceRequest(
billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
return reply.send(downstreamResponse);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
errorText: err?.message,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -696,7 +766,7 @@ export async function handleChatSurfaceRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
if (retryCount < MAX_RETRIES) {
@@ -775,8 +845,6 @@ export async function handleClaudeCountTokensSurfaceRequest(
});
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
const logDownstreamApiKeyId = downstreamApiKeyId !== null
&& await hasProxyLogDownstreamApiKeyIdColumn();
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -883,7 +951,11 @@ export async function handleClaudeCountTokensSurfaceRequest(
}
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: typeof payload === 'string' ? payload : text,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -900,7 +972,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
null,
upstreamRequest.path,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -909,7 +981,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
return reply.code(upstream.status).type(contentType).send(payload);
}
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName);
recordDownstreamCostUsage(request, 0);
logProxy(
selected,
@@ -927,11 +999,14 @@ export async function handleClaudeCountTokensSurfaceRequest(
null,
upstreamRequest.path,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
return reply.code(upstream.status).type(contentType).send(payload);
} catch (error: any) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
errorText: error?.message,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -948,7 +1023,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -994,11 +1069,11 @@ async function logProxy(
upstreamPath,
errorMessage,
});
await db.insert(schema.proxyLogs).values({
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
@@ -1008,11 +1083,15 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
billingDetails,
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
}).run();
});
} catch (error) {
console.warn('[proxy/chat] failed to write proxy log', error);
}
+84 -11
View File
@@ -16,6 +16,8 @@ import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/pro
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
import {
buildUpstreamEndpointRequest,
recordUpstreamEndpointFailure,
recordUpstreamEndpointSuccess,
resolveUpstreamEndpointCandidates,
} from '../../routes/proxy/upstreamEndpoint.js';
import {
@@ -29,6 +31,9 @@ import {
wrapGeminiCliRequest,
} from '../../routes/proxy/geminiCliCompat.js';
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
const MAX_RETRIES = 2;
const GEMINI_MODEL_PROBES = [
@@ -114,10 +119,10 @@ function resolveUpstreamPath(apiVersion: string, modelActionPath: string): strin
return `/${normalizedVersion}/${normalizedAction}`;
}
function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown }): boolean {
function hasDownstreamModelRestrictions(policy: { supportedModels?: unknown; allowedRouteIds?: unknown; denyAllWhenEmpty?: 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;
return supportedModels.length > 0 || allowedRouteIds.length > 0 || policy.denyAllWhenEmpty === true;
}
function extractGeminiListedModelName(item: unknown): string {
@@ -203,6 +208,7 @@ async function logProxy(
retryCount: number,
downstreamPath: string,
upstreamPath: string | null,
clientContext: DownstreamClientContext | null = null,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
@@ -210,11 +216,16 @@ async function logProxy(
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
upstreamPath,
errorMessage,
});
await db.insert(schema.proxyLogs).values({
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
@@ -227,11 +238,14 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost: 0,
billingDetails: null,
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
}).run();
});
} catch (error) {
console.warn('[proxy/gemini] failed to write proxy log', error);
}
@@ -285,7 +299,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
lastStatus = upstream.status;
lastText = text;
lastContentType = upstream.headers.get('content-type') || 'application/json';
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
status: upstream.status,
errorText: text,
});
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -300,7 +317,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
}
} catch (error) {
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
errorText: error instanceof Error ? error.message : 'Gemini upstream request failed',
});
lastStatus = 502;
lastContentType = 'application/json';
lastText = JSON.stringify({
@@ -355,6 +374,11 @@ export async function geminiProxyRoute(app: FastifyInstance) {
const policy = getDownstreamRoutingPolicy(request);
const downstreamPath = resolveDownstreamPath(request);
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body: request.body,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
let lastStatus = 503;
@@ -394,7 +418,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
type: 'server_error',
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
status: 500,
errorText: 'Gemini CLI OAuth project is missing',
});
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -478,7 +505,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
lastStatus = upstream.status;
lastContentType = contentType;
lastText = await upstream.text();
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
status: upstream.status,
errorText: lastText,
});
await logProxy(
selected,
requestedModel,
@@ -489,6 +519,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
retryCount,
downstreamPath,
upstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -523,6 +554,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
retryCount,
downstreamPath,
upstreamPath,
clientContext,
);
reply.raw.end();
return;
@@ -572,6 +604,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
retryCount,
downstreamPath,
upstreamPath,
clientContext,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
@@ -607,6 +640,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
retryCount,
downstreamPath,
upstreamPath,
clientContext,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
@@ -629,6 +663,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
retryCount,
downstreamPath,
upstreamPath,
clientContext,
);
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
}
@@ -651,6 +686,8 @@ export async function geminiProxyRoute(app: FastifyInstance) {
modelName: actualModel,
stream: isStreamAction,
});
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(openAiBody);
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const endpointCandidates = await resolveUpstreamEndpointCandidates(
{
site: selected.site,
@@ -659,7 +696,21 @@ export async function geminiProxyRoute(app: FastifyInstance) {
actualModel,
'openai',
requestedModel,
{
hasNonImageFileInput,
conversationFileSummary,
},
);
const endpointRuntimeContext = {
siteId: selected.site.id,
modelName: actualModel,
downstreamFormat: 'openai' as const,
requestedModelHint: requestedModel,
requestCapabilities: {
hasNonImageFileInput,
conversationFileSummary,
},
};
const buildEndpointRequest = (
endpoint: 'chat' | 'messages' | 'responses',
requestOptions: { forceNormalizeClaudeBody?: boolean } = {},
@@ -721,6 +772,20 @@ export async function geminiProxyRoute(app: FastifyInstance) {
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
tryRecover: endpointStrategy.tryRecover,
onAttemptFailure: (ctx) => {
recordUpstreamEndpointFailure({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
status: ctx.response.status,
errorText: ctx.rawErrText,
});
},
onAttemptSuccess: (ctx) => {
recordUpstreamEndpointSuccess({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
});
},
shouldDowngrade: endpointStrategy.shouldDowngrade,
});
if (!endpointResult.ok) {
@@ -732,7 +797,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
type: 'upstream_error',
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
status: endpointResult.status,
errorText: endpointResult.rawErrText || endpointResult.errText,
});
await logProxy(
selected,
requestedModel,
@@ -743,6 +811,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
retryCount,
downstreamPath,
null,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -780,6 +849,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
retryCount,
downstreamPath,
upstreamPath,
clientContext,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
@@ -798,7 +868,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
type: 'upstream_error',
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
errorText: error instanceof Error ? error.message : 'Gemini upstream request failed',
});
await logProxy(
selected,
requestedModel,
@@ -809,6 +881,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
retryCount,
downstreamPath,
upstreamPath || null,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -1,6 +1,5 @@
import { TextDecoder } from 'node:util';
import type { FastifyReply, FastifyRequest } from 'fastify';
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';
@@ -12,12 +11,15 @@ import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../se
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import {
buildUpstreamEndpointRequest,
recordUpstreamEndpointFailure,
recordUpstreamEndpointSuccess,
resolveUpstreamEndpointCandidates,
} from '../../routes/proxy/upstreamEndpoint.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
@@ -25,7 +27,6 @@ import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
import {
ProxyInputFileResolutionError,
hasNonImageFileInputInOpenAiBody,
resolveResponsesBodyInputFiles,
} from '../../services/proxyInputFileResolver.js';
import {
@@ -34,14 +35,23 @@ import {
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
import { collectResponsesFinalPayloadFromSse } from '../../routes/proxy/responsesSseFinal.js';
import {
collectResponsesFinalPayloadFromSse,
collectResponsesFinalPayloadFromSseText,
createSingleChunkStreamReader,
looksLikeResponsesSseText,
} from '../../routes/proxy/responsesSseFinal.js';
import {
createGeminiCliStreamReader,
unwrapGeminiCliPayload,
} from '../../routes/proxy/geminiCliCompat.js';
import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js';
import { detectCliProfile } from '../cliProfiles/registry.js';
import type { CliProfileId } from '../cliProfiles/types.js';
import {
summarizeConversationFileInputsInOpenAiBody,
summarizeConversationFileInputsInResponsesBody,
} from '../capabilities/conversationFileCapabilities.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
@@ -127,25 +137,6 @@ function carriesResponsesFileUrlInput(value: unknown): boolean {
type UsageSummary = ReturnType<typeof parseProxyUsage>;
type DownstreamClientContext = {
clientKind: CliProfileId;
sessionId?: string;
traceHint?: string;
};
function detectDownstreamClientContext(input: {
downstreamPath: string;
headers?: Record<string, unknown>;
body?: unknown;
}): DownstreamClientContext {
const detected = detectCliProfile(input);
return {
clientKind: detected.id,
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
};
}
function deriveCodexExplicitSessionId(body: Record<string, unknown>): string | null {
const promptCacheKey = typeof body.prompt_cache_key === 'string'
? body.prompt_cache_key.trim()
@@ -188,8 +179,6 @@ export async function handleOpenAiResponsesSurfaceRequest(
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;
@@ -243,17 +232,12 @@ export async function handleOpenAiResponsesSurfaceRequest(
isStream,
{ defaultEncryptedReasoningInclude },
);
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(openAiBody);
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
const requiresNativeResponsesFileUrl = carriesResponsesFileUrlInput(normalizedResponsesBody.input);
if (requiresNativeResponsesFileUrl && String(selected.site.platform || '').trim().toLowerCase() === 'claude') {
return reply.code(400).send({
error: {
message: 'Responses input_file.file_url requires an upstream /v1/responses endpoint; current site only supports /v1/messages.',
type: 'invalid_request_error',
},
});
}
const responsesConversationFileSummary = summarizeConversationFileInputsInResponsesBody(normalizedResponsesBody);
const requiresNativeResponsesFileUrl = responsesConversationFileSummary.hasRemoteDocumentUrl
|| carriesResponsesFileUrlInput(normalizedResponsesBody.input);
const endpointCandidates = await resolveUpstreamEndpointCandidates(
{
site: selected.site,
@@ -264,15 +248,24 @@ export async function handleOpenAiResponsesSurfaceRequest(
requestedModel,
{
hasNonImageFileInput,
conversationFileSummary,
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
},
);
if (requiresNativeResponsesFileUrl) {
endpointCandidates.splice(0, endpointCandidates.length, 'responses');
}
if (endpointCandidates.length === 0) {
endpointCandidates.push('responses', 'chat', 'messages');
}
const endpointRuntimeContext = {
siteId: selected.site.id,
modelName,
downstreamFormat: 'responses' as const,
requestedModelHint: requestedModel,
requestCapabilities: {
hasNonImageFileInput,
conversationFileSummary,
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
},
};
const buildProviderHeaders = () => (
buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
@@ -339,7 +332,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
const refreshedTargetUrl = `${selected.site.url}${refreshedRequest.path}`;
const refreshedTargetUrl = buildUpstreamUrl(selected.site.url, refreshedRequest.path);
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
if (refreshedResponse.ok) {
return {
@@ -367,6 +360,20 @@ export async function handleOpenAiResponsesSurfaceRequest(
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
tryRecover,
onAttemptFailure: (ctx) => {
recordUpstreamEndpointFailure({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
status: ctx.response.status,
errorText: ctx.rawErrText,
});
},
onAttemptSuccess: (ctx) => {
recordUpstreamEndpointSuccess({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
});
},
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
logProxy(
@@ -385,7 +392,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
},
});
@@ -394,7 +401,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
const status = endpointResult.status || 502;
const errText = endpointResult.errText || 'unknown error';
const rawErrText = endpointResult.rawErrText || errText;
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status,
errorText: rawErrText,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -411,7 +422,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
await recordOauthQuotaResetHint({
accountId: selected.account.id,
@@ -486,14 +497,14 @@ export async function handleOpenAiResponsesSurfaceRequest(
}
try {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
usageForLog.promptTokens, usageForLog.completionTokens, usageForLog.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
} catch (error) {
console.error('[responses] post-stream success logging failed:', error);
@@ -541,6 +552,39 @@ export async function handleOpenAiResponsesSurfaceRequest(
});
if (!upstreamContentType.includes('text/event-stream')) {
const rawText = await upstream.text();
if (looksLikeResponsesSseText(rawText)) {
startSseResponse();
const streamResult = await streamSession.run(
createSingleChunkStreamReader(rawText),
reply.raw,
);
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id, modelName);
logProxy(
selected,
requestedModel,
'failed',
200,
latency,
streamResult.errorMessage,
retryCount,
downstreamPath,
parsedUsage.promptTokens,
parsedUsage.completionTokens,
parsedUsage.totalTokens,
0,
null,
successfulUpstreamPath,
clientContext,
downstreamApiKeyId,
);
return;
}
await finalizeStreamSuccess(parsedUsage, latency);
return;
}
let upstreamData: unknown = rawText;
try {
upstreamData = JSON.parse(rawText);
@@ -555,7 +599,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
const latency = Date.now() - startTime;
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: failure.reason,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -572,7 +620,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
@@ -590,7 +638,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
startSseResponse();
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: 502,
errorText: streamResult.errorMessage,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -607,7 +659,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
return;
}
@@ -646,7 +698,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: 502,
errorText: streamResult.errorMessage,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -663,7 +719,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
return;
}
@@ -692,6 +748,9 @@ export async function handleOpenAiResponsesSurfaceRequest(
upstreamData = collected.payload;
} else {
rawText = await upstream.text();
if (looksLikeResponsesSseText(rawText)) {
upstreamData = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload;
} else {
upstreamData = rawText;
try {
upstreamData = JSON.parse(rawText);
@@ -699,6 +758,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
upstreamData = rawText;
}
}
}
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
upstreamData = unwrapGeminiCliPayload(upstreamData);
}
@@ -706,7 +766,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
const parsedUsage = parseProxyUsage(upstreamData);
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: failure.reason,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -723,7 +787,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
null,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -770,18 +834,21 @@ export async function handleOpenAiResponsesSurfaceRequest(
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
successfulUpstreamPath,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
return reply.send(downstreamData);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
errorText: err?.message,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -798,7 +865,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
null,
null,
clientContext,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -845,11 +912,11 @@ async function logProxy(
upstreamPath,
errorMessage,
});
await db.insert(schema.proxyLogs).values({
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
@@ -859,11 +926,15 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
billingDetails,
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
}).run();
});
} catch (error) {
console.warn('[proxy/responses] failed to write proxy log', error);
}
+22 -1
View File
@@ -10,7 +10,7 @@ vi.mock('../../services/checkinService.js', () => ({
}));
vi.mock('../../services/checkinScheduler.js', () => ({
updateCheckinCron: vi.fn(),
updateCheckinSchedule: vi.fn(),
}));
vi.mock('../../db/index.js', () => {
@@ -87,4 +87,25 @@ describe('POST /api/checkin/trigger background task dedupe', () => {
await new Promise((resolve) => setTimeout(resolve, 20));
await app.close();
});
it('accepts the legacy cron-only schedule payload', async () => {
const { checkinRoutes } = await import('./checkin.js');
const schedulerModule = await import('../../services/checkinScheduler.js');
const app = Fastify();
await app.register(checkinRoutes);
const response = await app.inject({
method: 'PUT',
url: '/api/checkin/schedule',
payload: { cron: '0 8 * * *' },
});
expect(response.statusCode).toBe(200);
expect((schedulerModule as any).updateCheckinSchedule).toHaveBeenCalledWith({
mode: 'cron',
cronExpr: '0 8 * * *',
intervalHours: undefined,
});
await app.close();
});
});
+30 -7
View File
@@ -1,9 +1,10 @@
import { FastifyInstance } from 'fastify';
import { config } from '../../config.js';
import { db, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js';
import { eq, desc } from 'drizzle-orm';
import { checkinAccount, checkinAll } from '../../services/checkinService.js';
import { updateCheckinCron } from '../../services/checkinScheduler.js';
import { updateCheckinSchedule } from '../../services/checkinScheduler.js';
import { startBackgroundTask, summarizeCheckinResults } from '../../services/backgroundTaskService.js';
import { classifyFailureReason } from '../../services/failureReasonService.js';
@@ -84,7 +85,7 @@ export async function checkinRoutes(app: FastifyInstance) {
failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`,
},
async () => {
const results = await checkinAll();
const results = await checkinAll({ scheduleMode: config.checkinScheduleMode });
return {
summary: summarizeCheckinResults(results),
total: results.length,
@@ -108,7 +109,7 @@ export async function checkinRoutes(app: FastifyInstance) {
// Trigger check-in for a specific account
app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => {
const id = parseInt(request.params.id, 10);
const result = await checkinAccount(id);
const result = await checkinAccount(id, { scheduleMode: config.checkinScheduleMode });
return result;
});
@@ -142,11 +143,33 @@ export async function checkinRoutes(app: FastifyInstance) {
});
// Update check-in schedule
app.put<{ Body: { cron: string } }>('/api/checkin/schedule', async (request) => {
app.put<{ Body: { mode?: 'cron' | 'interval'; cron?: string; intervalHours?: number } }>('/api/checkin/schedule', async (request) => {
try {
updateCheckinCron(request.body.cron);
await upsertSetting('checkin_cron', request.body.cron);
return { success: true, cron: request.body.cron };
const body = request.body || {};
const nextMode: 'cron' | 'interval' = body.mode === 'interval' ? 'interval' : 'cron';
const nextCron = typeof body.cron === 'string' ? body.cron : undefined;
const nextIntervalHours = body.intervalHours !== undefined ? Number(body.intervalHours) : undefined;
const normalizedIntervalHours = typeof nextIntervalHours === 'number' && Number.isFinite(nextIntervalHours)
? Math.trunc(nextIntervalHours)
: undefined;
updateCheckinSchedule({
mode: nextMode,
cronExpr: nextCron,
intervalHours: normalizedIntervalHours,
});
await upsertSetting('checkin_schedule_mode', nextMode);
if (nextCron !== undefined) await upsertSetting('checkin_cron', nextCron);
if (normalizedIntervalHours !== undefined) {
await upsertSetting('checkin_interval_hours', normalizedIntervalHours);
}
return {
success: true,
mode: nextMode,
cron: nextCron,
intervalHours: normalizedIntervalHours,
};
} catch (err: any) {
return { error: err.message };
}
@@ -22,6 +22,10 @@ function validateKeyShape(key: string): boolean {
function looksLikeUniqueViolation(error: unknown): boolean {
const message = (error as Error | undefined)?.message || '';
if (runtimeDbDialect === 'postgres') {
return message.includes('duplicate key value violates unique constraint') &&
message.includes('downstream_api_keys_key_unique');
}
return message.includes('UNIQUE constraint failed') && message.includes('downstream_api_keys.key');
}
@@ -0,0 +1,114 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../../db/index.js');
describe('settings backup webdav api', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-backup-webdav-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const settingsRoutesModule = await import('./settings.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
});
beforeEach(async () => {
await db.delete(schema.settings).run();
await db.delete(schema.events).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('saves and returns masked webdav config', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/backup/webdav',
payload: {
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'accounts',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
success?: boolean;
config?: {
fileUrl?: string;
username?: string;
exportType?: string;
autoSyncEnabled?: boolean;
autoSyncCron?: string;
hasPassword?: boolean;
passwordMasked?: string;
};
};
expect(body.success).toBe(true);
expect(body.config).toMatchObject({
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
exportType: 'accounts',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
hasPassword: true,
});
expect(body.config?.passwordMasked).toBeTruthy();
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'backup_webdav_config_v1')).get();
expect(saved?.value).toContain('"fileUrl":"https://dav.example.com/backups/metapi.json"');
expect(saved?.value).toContain('"password":"secret-pass"');
});
it('exports current backup to webdav through settings route', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValue(new Response(null, { status: 201 }));
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 * * * *',
}),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/settings/backup/webdav/export',
});
expect(response.statusCode).toBe(200);
const body = response.json() as { success?: boolean; fileUrl?: string };
expect(body.success).toBe(true);
expect(body.fileUrl).toBe('https://dav.example.com/backups/metapi.json');
expect(fetchSpy).toHaveBeenCalledTimes(1);
fetchSpy.mockRestore();
});
});
+81 -1
View File
@@ -44,6 +44,8 @@ describe('settings and auth events', () => {
config.proxyToken = 'sk-old-proxy-token-123';
config.systemProxyUrl = '';
config.checkinCron = '0 8 * * *';
(config as any).checkinScheduleMode = 'cron';
(config as any).checkinIntervalHours = 6;
config.balanceRefreshCron = '0 * * * *';
config.logCleanupConfigured = false;
config.logCleanupCron = '0 6 * * *';
@@ -55,6 +57,8 @@ describe('settings and auth events', () => {
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
(config as any).telegramBotToken = '';
(config as any).telegramChatId = '';
(config as any).telegramUseSystemProxy = false;
(config as any).telegramMessageThreadId = '';
});
afterAll(async () => {
@@ -85,6 +89,28 @@ describe('settings and auth events', () => {
expect(events[0].message || '').toContain('签到 Cron');
});
it('persists and returns checkin interval mode from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
checkinScheduleMode: 'interval',
checkinIntervalHours: 8,
checkinCron: '0 8 * * *',
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { checkinScheduleMode?: string; checkinIntervalHours?: number };
expect(updated.checkinScheduleMode).toBe('interval');
expect(updated.checkinIntervalHours).toBe(8);
const savedMode = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_schedule_mode')).get();
const savedInterval = await db.select().from(schema.settings).where(eq(schema.settings.key, 'checkin_interval_hours')).get();
expect(savedMode?.value).toBe(JSON.stringify('interval'));
expect(savedInterval?.value).toBe(JSON.stringify(8));
});
it('returns current recognized admin IP in runtime settings response', async () => {
const response = await app.inject({
method: 'GET',
@@ -96,8 +122,10 @@ describe('settings and auth events', () => {
});
expect(response.statusCode).toBe(200);
const body = response.json() as { currentAdminIp?: string };
const body = response.json() as { currentAdminIp?: string; serverTimeZone?: string };
expect(body.currentAdminIp).toBe('203.0.113.5');
expect(typeof body.serverTimeZone).toBe('string');
expect((body.serverTimeZone || '').length).toBeGreaterThan(0);
});
it('rejects proxy token that does not start with sk-', async () => {
@@ -202,6 +230,32 @@ describe('settings and auth events', () => {
expect(runtime.telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom');
});
it('persists and returns telegram message thread id from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
telegramMessageThreadId: '77',
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { telegramMessageThreadId?: string };
expect(updated.telegramMessageThreadId).toBe('77');
expect((config as any).telegramMessageThreadId).toBe('77');
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_message_thread_id')).get();
expect(saved?.value).toBe(JSON.stringify('77'));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { telegramMessageThreadId?: string };
expect(runtime.telegramMessageThreadId).toBe('77');
});
it('rejects invalid telegram api base url when telegram is enabled', async () => {
const response = await app.inject({
method: 'PUT',
@@ -219,6 +273,32 @@ describe('settings and auth events', () => {
expect(body.message).toContain('Telegram API Base URL');
});
it('persists and returns telegram use system proxy from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
telegramUseSystemProxy: true,
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { telegramUseSystemProxy?: boolean };
expect(updated.telegramUseSystemProxy).toBe(true);
expect((config as any).telegramUseSystemProxy).toBe(true);
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_use_system_proxy')).get();
expect(saved?.value).toBe(JSON.stringify(true));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { telegramUseSystemProxy?: boolean };
expect(runtime.telegramUseSystemProxy).toBe(true);
});
it('persists and returns routing fallback unit cost from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
@@ -0,0 +1,123 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const fetchMock = vi.fn();
vi.mock('undici', async () => {
const actual = await vi.importActual<typeof import('undici')>('undici');
return {
...actual,
fetch: (...args: unknown[]) => fetchMock(...args),
};
});
type ConfigModule = typeof import('../../config.js');
describe('settings system proxy test route', () => {
let app: FastifyInstance;
let config: ConfigModule['config'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-settings-system-proxy-test-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const configModule = await import('../../config.js');
const settingsRoutesModule = await import('./settings.js');
config = configModule.config;
app = Fastify();
await app.register(settingsRoutesModule.settingsRoutes);
});
beforeEach(() => {
fetchMock.mockReset();
config.systemProxyUrl = '';
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('tests the provided system proxy url and returns latency', async () => {
fetchMock.mockResolvedValue(new Response(null, {
status: 204,
headers: { 'content-type': 'text/plain' },
}));
const response = await app.inject({
method: 'POST',
url: '/api/settings/system-proxy/test',
payload: {
proxyUrl: 'http://127.0.0.1:7890',
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toMatchObject({
success: true,
proxyUrl: 'http://127.0.0.1:7890',
probeUrl: 'https://www.gstatic.com/generate_204',
finalUrl: 'https://www.gstatic.com/generate_204',
reachable: true,
ok: true,
statusCode: 204,
});
expect((response.json() as { latencyMs?: number }).latencyMs).toBeGreaterThanOrEqual(1);
const [url, requestInit] = fetchMock.mock.calls[0] as [string, Record<string, unknown>];
expect(url).toBe('https://www.gstatic.com/generate_204');
expect(requestInit.method).toBe('GET');
expect(requestInit.dispatcher).toBeTruthy();
});
it('uses the saved system proxy url when request body is empty', async () => {
config.systemProxyUrl = 'socks5://127.0.0.1:1080';
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
const response = await app.inject({
method: 'POST',
url: '/api/settings/system-proxy/test',
payload: {},
});
expect(response.statusCode).toBe(200);
expect((response.json() as { proxyUrl?: string }).proxyUrl).toBe('socks5://127.0.0.1:1080');
});
it('rejects missing system proxy url', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/settings/system-proxy/test',
payload: {},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { message?: string }).message).toContain('请先填写系统代理地址');
expect(fetchMock).not.toHaveBeenCalled();
});
it('returns 502 when the proxy probe request fails', async () => {
fetchMock.mockRejectedValue(new TypeError('fetch failed', {
cause: new Error('connect ECONNREFUSED 127.0.0.1:7890'),
}));
const response = await app.inject({
method: 'POST',
url: '/api/settings/system-proxy/test',
payload: {
proxyUrl: 'http://127.0.0.1:7890',
},
});
expect(response.statusCode).toBe(502);
expect((response.json() as { message?: string }).message).toContain('连接被拒绝');
expect((response.json() as { message?: string }).message).not.toContain('fetch failed');
});
});
+348 -9
View File
@@ -1,12 +1,22 @@
import { FastifyInstance } from 'fastify';
import { FastifyInstance } from 'fastify';
import cron from 'node-cron';
import { fetch } from 'undici';
import { config } from '../../config.js';
import { db, runtimeDbDialect, schema } from '../../db/index.js';
import { upsertSetting } from '../../db/upsertSetting.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { updateBalanceRefreshCron, updateCheckinCron, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { updateBalanceRefreshCron, updateCheckinSchedule, updateLogCleanupSettings } from '../../services/checkinScheduler.js';
import { sendNotification } from '../../services/notifyService.js';
import { exportBackup, importBackup, type BackupExportType } from '../../services/backupService.js';
import {
exportBackup,
exportBackupToWebdav,
getBackupWebdavConfig,
importBackup,
importBackupFromWebdav,
reloadBackupWebdavScheduler,
saveBackupWebdavConfig,
type BackupExportType,
} from '../../services/backupService.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import {
maskConnectionString,
@@ -15,9 +25,9 @@ import {
testDatabaseConnection,
type MigrationDialect,
} from '../../services/databaseMigrationService.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
import { extractClientIp, isIpAllowed } from '../../middleware/auth.js';
import { invalidateSiteProxyCache, normalizeSiteProxyUrl } from '../../services/siteProxy.js';
import { invalidateSiteProxyCache, normalizeSiteProxyUrl, withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { performFactoryReset } from '../../services/factoryResetService.js';
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
@@ -28,6 +38,8 @@ interface RuntimeSettingsBody {
proxyToken?: string;
systemProxyUrl?: string;
checkinCron?: string;
checkinScheduleMode?: 'cron' | 'interval';
checkinIntervalHours?: number;
balanceRefreshCron?: string;
logCleanupCron?: string;
logCleanupUsageLogsEnabled?: boolean;
@@ -43,6 +55,8 @@ interface RuntimeSettingsBody {
telegramApiBaseUrl?: string;
telegramBotToken?: string;
telegramChatId?: string;
telegramUseSystemProxy?: boolean;
telegramMessageThreadId?: string;
smtpEnabled?: boolean;
smtpHost?: string;
smtpPort?: number;
@@ -66,6 +80,21 @@ interface DatabaseMigrationBody {
ssl?: unknown;
}
interface SystemProxyTestBody {
proxyUrl?: unknown;
}
interface BackupWebdavConfigBody {
enabled?: unknown;
fileUrl?: unknown;
username?: unknown;
password?: unknown;
clearPassword?: unknown;
exportType?: unknown;
autoSyncEnabled?: unknown;
autoSyncCron?: unknown;
}
type RuntimeDatabaseConfig = {
dialect: MigrationDialect;
connectionString: string;
@@ -76,6 +105,8 @@ const PROXY_TOKEN_PREFIX = 'sk-';
const DB_TYPE_SETTING_KEY = 'db_type';
const DB_URL_SETTING_KEY = 'db_url';
const DB_SSL_SETTING_KEY = 'db_ssl';
const SYSTEM_PROXY_TEST_PROBE_URL = 'https://www.gstatic.com/generate_204';
const SYSTEM_PROXY_TEST_TIMEOUT_MS = 15_000;
function isValidProxyToken(value: string): boolean {
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
@@ -114,6 +145,94 @@ function toPositiveNumberOrFallback(value: unknown, fallback: number) {
return n;
}
function extractNestedErrorMessages(error: unknown): string[] {
const messages: string[] = [];
const visited = new Set<unknown>();
let current: any = error;
while (current && !visited.has(current)) {
visited.add(current);
const message = typeof current?.message === 'string' ? current.message.trim() : '';
if (message) {
messages.push(message);
}
current = current?.cause;
}
return messages;
}
function describeSystemProxyTestFailure(error: unknown): string {
const messages = extractNestedErrorMessages(error);
const detail = messages.find((message) => message && message !== 'fetch failed')
|| messages[0]
|| '未知错误';
if (/ECONNREFUSED/i.test(detail)) {
return '系统代理测试失败:连接被拒绝,请检查代理地址、端口和本地代理程序是否已启动';
}
if (/ETIMEDOUT|timed out|timeout/i.test(detail)) {
return '系统代理测试失败:连接超时,请检查代理服务或当前网络是否可用';
}
if (/ENOTFOUND|EAI_AGAIN/i.test(detail)) {
return '系统代理测试失败:域名解析失败,请检查网络或代理的 DNS 配置';
}
if (/ECONNRESET/i.test(detail)) {
return '系统代理测试失败:连接被对端重置,请检查代理链路是否稳定';
}
if (/407/.test(detail) || /proxy authentication/i.test(detail)) {
return '系统代理测试失败:代理要求认证,请检查用户名、密码或代理配置';
}
return `系统代理测试失败:${detail}`;
}
async function testSystemProxyConnectivity(proxyUrl: string) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), SYSTEM_PROXY_TEST_TIMEOUT_MS);
const startedAt = Date.now();
try {
const response = await fetch(
SYSTEM_PROXY_TEST_PROBE_URL,
withExplicitProxyRequestInit(proxyUrl, {
method: 'GET',
signal: controller.signal,
headers: {
'cache-control': 'no-cache',
'user-agent': 'metapi-system-proxy-tester/1.0',
},
}),
);
try {
await response.arrayBuffer();
} catch {
// Ignore body drain failures; reachability is determined by receiving a response.
}
return {
reachable: true,
ok: response.ok,
statusCode: response.status,
latencyMs: Math.max(1, Date.now() - startedAt),
probeUrl: SYSTEM_PROXY_TEST_PROBE_URL,
finalUrl: response.url || SYSTEM_PROXY_TEST_PROBE_URL,
};
} catch (error: any) {
if (error?.name === 'AbortError') {
throw new Error(`系统代理测试超时(${Math.round(SYSTEM_PROXY_TEST_TIMEOUT_MS / 1000)}s)`);
}
throw new Error(describeSystemProxyTestFailure(error));
} finally {
clearTimeout(timeout);
}
}
function toStringList(value: unknown): string[] {
if (Array.isArray(value)) {
return value
@@ -171,12 +290,46 @@ function normalizeTelegramApiBaseUrl(raw: string): string {
return String(raw || '').trim().replace(/\/+$/, '');
}
function normalizeTelegramMessageThreadId(raw: unknown): string {
return String(raw || '').trim();
}
function isValidTelegramMessageThreadId(raw: string): boolean {
return /^[1-9]\d*$/.test(raw);
}
function applyImportedSettingToRuntime(key: string, value: unknown) {
switch (key) {
case 'checkin_cron': {
if (typeof value !== 'string' || !value || !cron.validate(value)) return;
config.checkinCron = value;
updateCheckinCron(value);
updateCheckinSchedule({
mode: config.checkinScheduleMode,
cronExpr: config.checkinCron,
intervalHours: config.checkinIntervalHours,
});
return;
}
case 'checkin_schedule_mode': {
if (value !== 'cron' && value !== 'interval') return;
const nextMode: 'cron' | 'interval' = value;
config.checkinScheduleMode = nextMode;
updateCheckinSchedule({
mode: config.checkinScheduleMode,
cronExpr: config.checkinCron,
intervalHours: config.checkinIntervalHours,
});
return;
}
case 'checkin_interval_hours': {
const intervalHours = Number(value);
if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) return;
config.checkinIntervalHours = Math.trunc(intervalHours);
updateCheckinSchedule({
mode: config.checkinScheduleMode,
cronExpr: config.checkinCron,
intervalHours: config.checkinIntervalHours,
});
return;
}
case 'balance_refresh_cron': {
@@ -288,6 +441,15 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.telegramChatId = value.trim();
return;
}
case 'telegram_use_system_proxy': {
config.telegramUseSystemProxy = !!value;
return;
}
case 'telegram_message_thread_id': {
if (typeof value !== 'string') return;
config.telegramMessageThreadId = value.trim();
return;
}
case 'smtp_enabled': {
config.smtpEnabled = !!value;
return;
@@ -363,6 +525,8 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
function getRuntimeSettingsResponse(currentAdminIp = '') {
return {
checkinCron: config.checkinCron,
checkinScheduleMode: config.checkinScheduleMode,
checkinIntervalHours: config.checkinIntervalHours,
balanceRefreshCron: config.balanceRefreshCron,
logCleanupCron: config.logCleanupCron,
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
@@ -380,6 +544,8 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
telegramApiBaseUrl: config.telegramApiBaseUrl,
telegramBotTokenMasked: maskSecret(config.telegramBotToken),
telegramChatId: config.telegramChatId,
telegramUseSystemProxy: config.telegramUseSystemProxy,
telegramMessageThreadId: config.telegramMessageThreadId,
smtpEnabled: config.smtpEnabled,
smtpHost: config.smtpHost,
smtpPort: config.smtpPort,
@@ -391,6 +557,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
notifyCooldownSec: config.notifyCooldownSec,
adminIpAllowlist: config.adminIpAllowlist,
currentAdminIp,
serverTimeZone: getResolvedTimeZone(),
systemProxyUrl: config.systemProxyUrl,
proxyErrorKeywords: config.proxyErrorKeywords,
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
@@ -472,6 +639,43 @@ export async function settingsRoutes(app: FastifyInstance) {
return getRuntimeSettingsResponse(currentAdminIp);
});
app.post<{ Body: SystemProxyTestBody }>('/api/settings/system-proxy/test', async (request, reply) => {
const rawProxyUrl = request.body?.proxyUrl === undefined
? config.systemProxyUrl
: String(request.body.proxyUrl || '').trim();
const normalizedProxyUrl = rawProxyUrl
? normalizeSiteProxyUrl(rawProxyUrl)
: '';
if (!rawProxyUrl) {
return reply.code(400).send({
success: false,
message: '请先填写系统代理地址',
});
}
if (!normalizedProxyUrl) {
return reply.code(400).send({
success: false,
message: '系统代理地址无效,请填写合法的 http(s)/socks 代理 URL',
});
}
try {
const result = await testSystemProxyConnectivity(normalizedProxyUrl);
return {
success: true,
proxyUrl: normalizedProxyUrl,
...result,
};
} catch (error: any) {
return reply.code(502).send({
success: false,
message: error?.message || '系统代理测试失败',
});
}
});
app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
const body = request.body || {};
const changedLabels: string[] = [];
@@ -512,7 +716,9 @@ export async function settingsRoutes(app: FastifyInstance) {
const telegramTouched = body.telegramEnabled !== undefined
|| body.telegramApiBaseUrl !== undefined
|| body.telegramBotToken !== undefined
|| body.telegramChatId !== undefined;
|| body.telegramChatId !== undefined
|| body.telegramUseSystemProxy !== undefined
|| body.telegramMessageThreadId !== undefined;
const nextTelegramEnabled = body.telegramEnabled !== undefined
? !!body.telegramEnabled
: config.telegramEnabled;
@@ -525,6 +731,9 @@ export async function settingsRoutes(app: FastifyInstance) {
const nextTelegramChatId = body.telegramChatId !== undefined
? String(body.telegramChatId || '').trim()
: config.telegramChatId;
const nextTelegramMessageThreadId = body.telegramMessageThreadId !== undefined
? normalizeTelegramMessageThreadId(body.telegramMessageThreadId)
: config.telegramMessageThreadId;
if (telegramTouched && nextTelegramEnabled) {
if (!nextTelegramBotToken) {
return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' });
@@ -535,13 +744,22 @@ export async function settingsRoutes(app: FastifyInstance) {
if (!nextTelegramChatId) {
return reply.code(400).send({ success: false, message: 'Telegram Chat ID 不能为空(启用 Telegram 时)' });
}
if (nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) {
return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' });
}
if (nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
}
} else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
} else if (body.telegramMessageThreadId !== undefined && nextTelegramMessageThreadId && !isValidTelegramMessageThreadId(nextTelegramMessageThreadId)) {
return reply.code(400).send({ success: false, message: 'Telegram Topic ID 格式无效,需要正整数' });
}
const checkinScheduleTouched = body.checkinCron !== undefined
|| body.checkinScheduleMode !== undefined
|| body.checkinIntervalHours !== undefined;
if (body.checkinCron !== undefined) {
if (!cron.validate(body.checkinCron)) {
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
@@ -549,8 +767,50 @@ export async function settingsRoutes(app: FastifyInstance) {
if (body.checkinCron !== config.checkinCron) {
changedLabels.push(`签到 Cron(${config.checkinCron} -> ${body.checkinCron})`);
}
updateCheckinCron(body.checkinCron);
upsertSetting('checkin_cron', body.checkinCron);
}
if (body.checkinScheduleMode !== undefined) {
if (body.checkinScheduleMode !== 'cron' && body.checkinScheduleMode !== 'interval') {
return reply.code(400).send({ success: false, message: '签到方式无效:仅支持 cron 或 interval' });
}
if (body.checkinScheduleMode !== config.checkinScheduleMode) {
changedLabels.push('签到方式');
}
config.checkinScheduleMode = body.checkinScheduleMode;
}
if (body.checkinIntervalHours !== undefined) {
const intervalHours = Number(body.checkinIntervalHours);
if (!Number.isFinite(intervalHours) || intervalHours < 1 || intervalHours > 24) {
return reply.code(400).send({ success: false, message: '签到间隔必须是 1 到 24 的整数小时' });
}
const nextIntervalHours = Math.trunc(intervalHours);
if (nextIntervalHours !== config.checkinIntervalHours) {
changedLabels.push(`签到间隔(${config.checkinIntervalHours}h -> ${nextIntervalHours}h)`);
}
config.checkinIntervalHours = nextIntervalHours;
}
if (checkinScheduleTouched) {
const nextCheckinCron = body.checkinCron !== undefined ? body.checkinCron : config.checkinCron;
const nextCheckinScheduleMode: 'cron' | 'interval' = body.checkinScheduleMode !== undefined
? body.checkinScheduleMode
: config.checkinScheduleMode;
const nextCheckinIntervalHours = body.checkinIntervalHours !== undefined
? Math.trunc(Number(body.checkinIntervalHours))
: config.checkinIntervalHours;
updateCheckinSchedule({
mode: nextCheckinScheduleMode,
cronExpr: nextCheckinCron,
intervalHours: nextCheckinIntervalHours,
});
config.checkinCron = nextCheckinCron;
config.checkinScheduleMode = nextCheckinScheduleMode;
config.checkinIntervalHours = nextCheckinIntervalHours;
upsertSetting('checkin_cron', config.checkinCron);
upsertSetting('checkin_schedule_mode', config.checkinScheduleMode);
upsertSetting('checkin_interval_hours', config.checkinIntervalHours);
}
if (body.balanceRefreshCron !== undefined) {
@@ -768,6 +1028,23 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('telegram_chat_id', config.telegramChatId);
}
if (body.telegramUseSystemProxy !== undefined) {
if (!!body.telegramUseSystemProxy !== config.telegramUseSystemProxy) {
changedLabels.push('Telegram 使用系统代理');
}
config.telegramUseSystemProxy = !!body.telegramUseSystemProxy;
upsertSetting('telegram_use_system_proxy', config.telegramUseSystemProxy);
}
if (body.telegramMessageThreadId !== undefined) {
const nextTelegramMessageThreadId = normalizeTelegramMessageThreadId(body.telegramMessageThreadId);
if (nextTelegramMessageThreadId !== config.telegramMessageThreadId) {
changedLabels.push('Telegram Topic ID');
}
config.telegramMessageThreadId = nextTelegramMessageThreadId;
upsertSetting('telegram_message_thread_id', config.telegramMessageThreadId);
}
if (body.smtpEnabled !== undefined) {
if (!!body.smtpEnabled !== config.smtpEnabled) {
changedLabels.push('SMTP 开关');
@@ -1010,6 +1287,9 @@ export async function settingsRoutes(app: FastifyInstance) {
for (const item of result.appliedSettings) {
applyImportedSettingToRuntime(item.key, item.value);
}
if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) {
await reloadBackupWebdavScheduler();
}
return {
success: true,
message: '导入完成',
@@ -1023,6 +1303,65 @@ export async function settingsRoutes(app: FastifyInstance) {
}
});
app.get('/api/settings/backup/webdav', async () => {
return getBackupWebdavConfig();
});
app.put<{ Body: BackupWebdavConfigBody }>('/api/settings/backup/webdav', async (request, reply) => {
try {
const body = request.body || {};
const result = await saveBackupWebdavConfig({
enabled: body.enabled === undefined ? undefined : body.enabled === true,
fileUrl: body.fileUrl === undefined ? undefined : String(body.fileUrl || ''),
username: body.username === undefined ? undefined : String(body.username || ''),
password: body.password === undefined ? undefined : String(body.password),
clearPassword: body.clearPassword === true,
exportType: body.exportType === undefined ? undefined : String(body.exportType || '') as BackupExportType,
autoSyncEnabled: body.autoSyncEnabled === undefined ? undefined : body.autoSyncEnabled === true,
autoSyncCron: body.autoSyncCron === undefined ? undefined : String(body.autoSyncCron || ''),
});
return result;
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'WebDAV 配置保存失败',
});
}
});
app.post<{ Body: { type?: string } }>('/api/settings/backup/webdav/export', async (request, reply) => {
try {
const rawType = typeof request.body?.type === 'string' ? request.body.type.trim().toLowerCase() : '';
const type: BackupExportType | undefined = rawType === 'all' || rawType === 'accounts' || rawType === 'preferences'
? rawType
: undefined;
return await exportBackupToWebdav(type);
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'WebDAV 导出失败',
});
}
});
app.post('/api/settings/backup/webdav/import', async (_, reply) => {
try {
const result = await importBackupFromWebdav();
for (const item of result.appliedSettings) {
applyImportedSettingToRuntime(item.key, item.value);
}
if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) {
await reloadBackupWebdavScheduler();
}
return result;
} catch (err: any) {
return reply.code(400).send({
success: false,
message: err?.message || 'WebDAV 导入失败',
});
}
});
app.post('/api/settings/notify/test', async (_, reply) => {
try {
const result = await sendNotification(
@@ -0,0 +1,301 @@
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';
import { eq } from 'drizzle-orm';
const syncSiteAnnouncementsMock = vi.fn();
vi.mock('../../services/siteAnnouncementService.js', () => ({
syncSiteAnnouncements: (...args: unknown[]) => syncSiteAnnouncementsMock(...args),
}));
type DbModule = typeof import('../../db/index.js');
describe('site announcements routes', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let closeDbConnections: DbModule['closeDbConnections'] | undefined;
let getBackgroundTask: ((taskId: string) => { id: string; status: string } | null) | null = null;
let resetBackgroundTasks: (() => void) | null = null;
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-site-announcements-routes-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./siteAnnouncements.js');
const eventsModule = await import('./events.js');
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
db = dbModule.db;
schema = dbModule.schema;
closeDbConnections = dbModule.closeDbConnections;
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
app = Fastify();
await app.register(routesModule.siteAnnouncementsRoutes);
await app.register(eventsModule.eventsRoutes);
});
beforeEach(async () => {
syncSiteAnnouncementsMock.mockReset();
resetBackgroundTasks?.();
await db.delete(schema.siteAnnouncements).run();
await db.delete(schema.events).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
if (app) {
await app.close();
}
if (typeof closeDbConnections === 'function') {
await closeDbConnections();
}
if (dataDir) {
try {
rmSync(dataDir, { recursive: true, force: true });
} catch {}
}
delete process.env.DATA_DIR;
});
it('lists announcements with site and unread filters', async () => {
const site = await db.insert(schema.sites).values({
name: 'Sub Site',
url: 'https://sub.example.com',
platform: 'sub2api',
}).returning().get();
const otherSite = await db.insert(schema.sites).values({
name: 'Other Site',
url: 'https://other.example.com',
platform: 'new-api',
}).returning().get();
await db.insert(schema.siteAnnouncements).values([
{
siteId: site.id,
platform: 'sub2api',
sourceKey: 'announcement:11',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
firstSeenAt: '2026-03-20 10:00:00',
lastSeenAt: '2026-03-20 10:00:00',
},
{
siteId: otherSite.id,
platform: 'new-api',
sourceKey: 'announcement:12',
title: 'Other',
content: 'Other notice',
level: 'info',
firstSeenAt: '2026-03-20 09:00:00',
lastSeenAt: '2026-03-20 09:00:00',
readAt: '2026-03-20 09:05:00',
},
]).run();
const response = await app.inject({
method: 'GET',
url: `/api/site-announcements?siteId=${site.id}&read=false`,
});
expect(response.statusCode).toBe(200);
const body = response.json() as Array<{ siteId: number; sourceKey: string }>;
expect(body).toEqual([
expect.objectContaining({
siteId: site.id,
sourceKey: 'announcement:11',
}),
]);
});
it('normalizes postgres Date values for managed timestamps before filtering unread rows', async () => {
const routesModule = await import('./siteAnnouncements.js');
const rows = routesModule.buildSiteAnnouncementsResponseRows([
{
id: 1,
siteId: 7,
platform: 'sub2api',
sourceKey: 'announcement:read',
title: 'Read row',
content: 'Already read',
level: 'info',
sourceUrl: null,
startsAt: null,
endsAt: null,
upstreamCreatedAt: '2026-03-10T04:30:08.200Z',
upstreamUpdatedAt: '2026-03-10T04:30:08.200Z',
firstSeenAt: new Date('2026-03-19T22:41:26.000Z') as never,
lastSeenAt: new Date('2026-03-19T22:41:26.000Z') as never,
readAt: new Date('2026-03-19T22:45:00.000Z') as never,
dismissedAt: null,
rawPayload: null,
},
{
id: 2,
siteId: 8,
platform: 'new-api',
sourceKey: 'announcement:unread',
title: 'Unread row',
content: 'Still unread',
level: 'info',
sourceUrl: null,
startsAt: null,
endsAt: null,
upstreamCreatedAt: null,
upstreamUpdatedAt: null,
firstSeenAt: new Date('2026-03-19T22:55:27.000Z') as never,
lastSeenAt: new Date('2026-03-19T22:55:27.000Z') as never,
readAt: null,
dismissedAt: null,
rawPayload: null,
},
] as Array<typeof schema.siteAnnouncements.$inferSelect>, {
read: 'false',
timeZone: 'Asia/Shanghai',
});
expect(rows).toEqual([
expect.objectContaining({
id: 2,
firstSeenAt: '2026-03-20 06:55:27',
lastSeenAt: '2026-03-20 06:55:27',
readAt: null,
}),
]);
});
it('marks one announcement as read and then marks all as read', async () => {
const site = await db.insert(schema.sites).values({
name: 'Sub Site',
url: 'https://sub.example.com',
platform: 'sub2api',
}).returning().get();
const first = await db.insert(schema.siteAnnouncements).values({
siteId: site.id,
platform: 'sub2api',
sourceKey: 'announcement:11',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
firstSeenAt: '2026-03-20 10:00:00',
lastSeenAt: '2026-03-20 10:00:00',
}).returning().get();
await db.insert(schema.siteAnnouncements).values({
siteId: site.id,
platform: 'sub2api',
sourceKey: 'announcement:12',
title: 'Model online',
content: 'gpt-4.1 is available',
level: 'info',
firstSeenAt: '2026-03-20 11:00:00',
lastSeenAt: '2026-03-20 11:00:00',
}).run();
const singleRead = await app.inject({
method: 'POST',
url: `/api/site-announcements/${first.id}/read`,
});
expect(singleRead.statusCode).toBe(200);
const afterSingle = await db.select().from(schema.siteAnnouncements).where(eq(schema.siteAnnouncements.id, first.id)).get();
expect(afterSingle?.readAt).toBeTruthy();
const bulkRead = await app.inject({
method: 'POST',
url: '/api/site-announcements/read-all',
});
expect(bulkRead.statusCode).toBe(200);
const rows = await db.select().from(schema.siteAnnouncements).all();
expect(rows.every((row) => typeof row.readAt === 'string' && row.readAt.length > 0)).toBe(true);
});
it('clears local announcement rows without touching program events', async () => {
const site = await db.insert(schema.sites).values({
name: 'Sub Site',
url: 'https://sub.example.com',
platform: 'sub2api',
}).returning().get();
await db.insert(schema.siteAnnouncements).values({
siteId: site.id,
platform: 'sub2api',
sourceKey: 'announcement:11',
title: 'Maintenance',
content: 'Window starts at 10:00',
level: 'info',
firstSeenAt: '2026-03-20 10:00:00',
lastSeenAt: '2026-03-20 10:00:00',
}).run();
await db.insert(schema.events).values({
type: 'site_notice',
title: '站点公告:Sub Site',
message: 'Window starts at 10:00',
level: 'info',
relatedId: 123,
relatedType: 'site_announcement',
createdAt: '2026-03-20 10:00:00',
}).run();
const response = await app.inject({
method: 'DELETE',
url: '/api/site-announcements',
});
expect(response.statusCode).toBe(200);
expect(await db.select().from(schema.siteAnnouncements).all()).toEqual([]);
expect(await db.select().from(schema.events).all()).toHaveLength(1);
});
it('queues a background sync task and returns its task id', async () => {
const site = await db.insert(schema.sites).values({
name: 'Sub Site',
url: 'https://sub.example.com',
platform: 'sub2api',
}).returning().get();
syncSiteAnnouncementsMock.mockImplementation(() => new Promise(() => {}));
const response = await app.inject({
method: 'POST',
url: '/api/site-announcements/sync',
payload: { siteId: site.id },
});
expect(response.statusCode).toBe(200);
const body = response.json() as { taskId: string; queued: boolean };
expect(body.queued).toBe(true);
expect(typeof body.taskId).toBe('string');
expect(getBackgroundTask?.(body.taskId)).toBeTruthy();
});
it('returns site_notice rows through the generic events api filter', async () => {
await db.insert(schema.events).values({
type: 'site_notice',
title: '站点公告:Sub Site',
message: 'Window starts at 10:00',
level: 'info',
relatedId: 1,
relatedType: 'site',
createdAt: '2026-03-20 10:00:00',
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/events?type=site_notice',
});
expect(response.statusCode).toBe(200);
const body = response.json() as Array<{ type: string }>;
expect(body).toEqual([expect.objectContaining({ type: 'site_notice' })]);
});
});
+196
View File
@@ -0,0 +1,196 @@
import { FastifyInstance } from 'fastify';
import { and, desc, eq } from 'drizzle-orm';
import { db, schema } from '../../db/index.js';
import { startBackgroundTask } from '../../services/backgroundTaskService.js';
import { formatUtcSqlDateTime, getResolvedTimeZone } from '../../services/localTimeService.js';
import { syncSiteAnnouncements } from '../../services/siteAnnouncementService.js';
type SiteAnnouncementRow = typeof schema.siteAnnouncements.$inferSelect;
type SiteAnnouncementsResponseFilters = {
read?: string;
status?: string;
timeZone?: string;
};
function formatDateTimePartsInTimeZone(value: Date, timeZone: string): string | null {
if (Number.isNaN(value.getTime())) return null;
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
const parts = formatter.formatToParts(value);
const lookup = new Map(parts.map((part) => [part.type, part.value]));
const year = lookup.get('year');
const month = lookup.get('month');
const day = lookup.get('day');
const hour = lookup.get('hour');
const minute = lookup.get('minute');
const second = lookup.get('second');
if (!year || !month || !day || !hour || !minute || !second) return null;
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
function normalizeManagedDateTimeValue(input: unknown, timeZone = getResolvedTimeZone()): string | null {
if (input == null) return null;
if (input instanceof Date) {
return formatDateTimePartsInTimeZone(input, timeZone);
}
const raw = String(input).trim();
return raw || null;
}
function parseTimeValue(input?: unknown): number | null {
if (input instanceof Date) {
return Number.isNaN(input.getTime()) ? null : input.getTime();
}
const raw = String(input || '').trim();
if (!raw) return null;
const parsed = Date.parse(raw.includes('T') ? raw : raw.replace(' ', 'T') + 'Z');
return Number.isFinite(parsed) ? parsed : null;
}
function hasDateTimeValue(input: unknown): boolean {
if (input instanceof Date) {
return !Number.isNaN(input.getTime());
}
return typeof input === 'string' && input.trim().length > 0;
}
export function normalizeSiteAnnouncementRowForResponse(row: SiteAnnouncementRow, timeZone = getResolvedTimeZone()) {
return {
...row,
firstSeenAt: normalizeManagedDateTimeValue((row as { firstSeenAt?: unknown }).firstSeenAt, timeZone),
lastSeenAt: normalizeManagedDateTimeValue((row as { lastSeenAt?: unknown }).lastSeenAt, timeZone),
readAt: normalizeManagedDateTimeValue((row as { readAt?: unknown }).readAt, timeZone),
dismissedAt: normalizeManagedDateTimeValue((row as { dismissedAt?: unknown }).dismissedAt, timeZone),
};
}
function applyReadFilter(rows: SiteAnnouncementRow[], read?: string): SiteAnnouncementRow[] {
if (read === 'true') {
return rows.filter((row) => hasDateTimeValue((row as { readAt?: unknown }).readAt));
}
if (read === 'false') {
return rows.filter((row) => !hasDateTimeValue((row as { readAt?: unknown }).readAt));
}
return rows;
}
function applyStatusFilter(rows: SiteAnnouncementRow[], status?: string): SiteAnnouncementRow[] {
const normalized = String(status || '').trim().toLowerCase();
if (!normalized) return rows;
const now = Date.now();
if (normalized === 'dismissed') {
return rows.filter((row) => hasDateTimeValue((row as { dismissedAt?: unknown }).dismissedAt));
}
if (normalized === 'expired') {
return rows.filter((row) => !row.dismissedAt && (() => {
const endsAt = parseTimeValue((row as { endsAt?: unknown }).endsAt);
return endsAt !== null && endsAt < now;
})());
}
if (normalized === 'active') {
return rows.filter((row) => !row.dismissedAt && (() => {
const endsAt = parseTimeValue((row as { endsAt?: unknown }).endsAt);
return endsAt === null || endsAt >= now;
})());
}
return rows;
}
export function buildSiteAnnouncementsResponseRows(
rows: SiteAnnouncementRow[],
options?: SiteAnnouncementsResponseFilters,
) {
const normalizedRows = rows.map((row) => normalizeSiteAnnouncementRowForResponse(row, options?.timeZone));
return applyStatusFilter(applyReadFilter(normalizedRows, options?.read), options?.status);
}
export async function siteAnnouncementsRoutes(app: FastifyInstance) {
app.get<{
Querystring: {
limit?: string;
offset?: string;
siteId?: string;
platform?: string;
read?: string;
status?: string;
};
}>('/api/site-announcements', async (request) => {
const limit = Math.max(1, Math.min(500, Number.parseInt(request.query.limit || '50', 10)));
const offset = Math.max(0, Number.parseInt(request.query.offset || '0', 10));
const filters: any[] = [];
const siteId = Number.parseInt(String(request.query.siteId || ''), 10);
if (Number.isFinite(siteId) && siteId > 0) {
filters.push(eq(schema.siteAnnouncements.siteId, siteId));
}
const platform = String(request.query.platform || '').trim();
if (platform) {
filters.push(eq(schema.siteAnnouncements.platform, platform));
}
const base = db.select().from(schema.siteAnnouncements);
const rows = filters.length > 0
? await base.where(and(...filters)).orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all()
: await base.orderBy(desc(schema.siteAnnouncements.firstSeenAt)).all();
const filtered = buildSiteAnnouncementsResponseRows(rows, {
read: request.query.read,
status: request.query.status,
});
return filtered.slice(offset, offset + limit);
});
app.post<{ Params: { id: string } }>('/api/site-announcements/:id/read', async (request) => {
const id = Number.parseInt(request.params.id, 10);
const readAt = formatUtcSqlDateTime(new Date());
await db.update(schema.siteAnnouncements)
.set({ readAt })
.where(eq(schema.siteAnnouncements.id, id))
.run();
return { success: true };
});
app.post('/api/site-announcements/read-all', async () => {
const readAt = formatUtcSqlDateTime(new Date());
await db.update(schema.siteAnnouncements)
.set({ readAt })
.run();
return { success: true };
});
app.delete('/api/site-announcements', async () => {
await db.delete(schema.siteAnnouncements).run();
return { success: true };
});
app.post<{ Body: { siteId?: number | string | null } }>('/api/site-announcements/sync', async (request) => {
const parsedSiteId = Number.parseInt(String(request.body?.siteId ?? ''), 10);
const siteId = Number.isFinite(parsedSiteId) && parsedSiteId > 0 ? parsedSiteId : null;
const { task, reused } = startBackgroundTask(
{
type: 'site-announcements-sync',
title: siteId ? `同步站点公告 #${siteId}` : '同步站点公告',
dedupeKey: siteId ? `site-announcements:${siteId}` : 'site-announcements:all',
notifyOnSuccess: false,
notifyOnFailure: false,
},
() => syncSiteAnnouncements(siteId ? { siteId } : undefined),
);
return {
success: true,
queued: true,
reused,
taskId: task.id,
};
});
}
+26 -4
View File
@@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs';
type DbModule = typeof import('../../db/index.js');
describe('sites system proxy settings', () => {
describe('sites proxy settings', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
@@ -36,7 +36,7 @@ describe('sites system proxy settings', () => {
delete process.env.DATA_DIR;
});
it('stores useSystemProxy, external checkin url, and custom headers when creating a site', async () => {
it('stores proxy settings, external checkin url, and custom headers when creating a site', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
@@ -44,6 +44,7 @@ describe('sites system proxy settings', () => {
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
proxyUrl: 'socks5://127.0.0.1:1080',
useSystemProxy: true,
customHeaders: JSON.stringify({
'cf-access-client-id': 'site-client-id',
@@ -56,11 +57,13 @@ describe('sites system proxy settings', () => {
expect(response.statusCode).toBe(200);
const payload = response.json() as {
proxyUrl?: string | null;
useSystemProxy?: boolean;
customHeaders?: string | null;
externalCheckinUrl?: string | null;
globalWeight?: number;
};
expect(payload.proxyUrl).toBe('socks5://127.0.0.1:1080');
expect(payload.useSystemProxy).toBe(true);
expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}');
expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare');
@@ -83,6 +86,22 @@ describe('sites system proxy settings', () => {
expect((response.json() as { error?: string }).error).toContain('Invalid useSystemProxy');
});
it('rejects invalid proxy url', async () => {
const response = await app.inject({
method: 'POST',
url: '/api/sites',
payload: {
name: 'proxy-site',
url: 'https://proxy-site.example.com',
platform: 'new-api',
proxyUrl: 'not-a-proxy',
},
});
expect(response.statusCode).toBe(400);
expect((response.json() as { error?: string }).error).toContain('Invalid proxyUrl');
});
it('rejects invalid site global weight', async () => {
const response = await app.inject({
method: 'POST',
@@ -115,7 +134,7 @@ describe('sites system proxy settings', () => {
expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl');
});
it('updates useSystemProxy for an existing site', async () => {
it('updates per-site proxy settings for an existing site', async () => {
const created = await app.inject({
method: 'POST',
url: '/api/sites',
@@ -133,12 +152,15 @@ describe('sites system proxy settings', () => {
method: 'PUT',
url: `/api/sites/${site.id}`,
payload: {
proxyUrl: 'http://127.0.0.1:8080',
useSystemProxy: true,
},
});
expect(response.statusCode).toBe(200);
expect((response.json() as { useSystemProxy?: boolean }).useSystemProxy).toBe(true);
const payload = response.json() as { proxyUrl?: string | null; useSystemProxy?: boolean };
expect(payload.proxyUrl).toBe('http://127.0.0.1:8080');
expect(payload.useSystemProxy).toBe(true);
});
it('rejects invalid custom headers json', async () => {
@@ -0,0 +1,128 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import {
buildStoredSub2ApiSubscriptionSummary,
mergeAccountExtraConfig,
} from '../../services/accountExtraConfig.js';
type DbModule = typeof import('../../db/index.js');
describe('sites route subscription summary aggregation', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-subscription-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./sites.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.sitesRoutes);
});
beforeEach(async () => {
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('aggregates stored sub2api subscription summaries into /api/sites', async () => {
const site = await db.insert(schema.sites).values({
name: 'sub2api-site',
url: 'https://sub2api.example.com',
platform: 'sub2api',
}).returning().get();
await db.insert(schema.accounts).values([
{
siteId: site.id,
username: 'user-a',
accessToken: 'token-a',
balance: 10,
status: 'active',
extraConfig: mergeAccountExtraConfig(null, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
activeCount: 1,
totalUsedUsd: 3,
subscriptions: [
{
id: 11,
groupName: 'Pro',
expiresAt: '2026-04-10T00:00:00.000Z',
monthlyUsedUsd: 3,
monthlyLimitUsd: 20,
},
],
}, 1760000000000),
}),
},
{
siteId: site.id,
username: 'user-b',
accessToken: 'token-b',
balance: 5,
status: 'active',
extraConfig: mergeAccountExtraConfig(null, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
activeCount: 1,
totalUsedUsd: 2,
subscriptions: [
{
id: 12,
groupName: 'Lite',
expiresAt: '2026-03-25T00:00:00.000Z',
monthlyUsedUsd: 2,
monthlyLimitUsd: 10,
},
],
}, 1760000002000),
}),
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/sites',
});
expect(response.statusCode).toBe(200);
const body = response.json() as Array<{
totalBalance: number;
subscriptionSummary: {
activeCount: number;
totalUsedUsd: number;
totalMonthlyLimitUsd: number | null;
totalRemainingUsd: number | null;
nextExpiresAt: string | null;
planNames: string[];
updatedAt: number | null;
} | null;
}>;
expect(body).toHaveLength(1);
expect(body[0]?.totalBalance).toBe(15);
expect(body[0]?.subscriptionSummary).toEqual({
activeCount: 2,
totalUsedUsd: 5,
totalMonthlyLimitUsd: 30,
totalRemainingUsd: 25,
nextExpiresAt: '2026-03-25T00:00:00.000Z',
planNames: ['Pro', 'Lite'],
updatedAt: 1760000002000,
});
});
});
+84 -10
View File
@@ -1,11 +1,12 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq, sql } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
import { invalidateSiteProxyCache, parseSiteProxyUrlInput } from '../../services/siteProxy.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return null;
@@ -75,6 +76,65 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
}
type SiteSubscriptionAggregate = {
activeCount: number;
totalUsedUsd: number;
totalMonthlyLimitUsd: number | null;
totalRemainingUsd: number | null;
nextExpiresAt: string | null;
planNames: string[];
updatedAt: number | null;
};
function roundMetric(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
function pickEarlierIsoDate(current?: string | null, next?: string | null): string | null {
if (!current) return next || null;
if (!next) return current;
const currentMs = Date.parse(current);
const nextMs = Date.parse(next);
if (!Number.isFinite(currentMs)) return next;
if (!Number.isFinite(nextMs)) return current;
return nextMs < currentMs ? next : current;
}
function aggregateSiteSubscription(
current: SiteSubscriptionAggregate | undefined,
extraConfig?: string | null,
): SiteSubscriptionAggregate | undefined {
const stored = getSub2ApiSubscriptionFromExtraConfig(extraConfig);
if (!stored) return current;
const planNames = new Set(current?.planNames || []);
let totalMonthlyLimitUsd = current?.totalMonthlyLimitUsd ?? null;
let nextExpiresAt = current?.nextExpiresAt ?? null;
for (const item of stored.subscriptions) {
if (item.groupName) planNames.add(item.groupName);
if (typeof item.monthlyLimitUsd === 'number' && Number.isFinite(item.monthlyLimitUsd)) {
totalMonthlyLimitUsd = roundMetric((totalMonthlyLimitUsd ?? 0) + item.monthlyLimitUsd);
}
nextExpiresAt = pickEarlierIsoDate(nextExpiresAt, item.expiresAt);
}
const totalUsedUsd = roundMetric((current?.totalUsedUsd || 0) + stored.totalUsedUsd);
const totalRemainingUsd = totalMonthlyLimitUsd == null
? null
: roundMetric(Math.max(0, totalMonthlyLimitUsd - totalUsedUsd));
return {
activeCount: (current?.activeCount || 0) + stored.activeCount,
totalUsedUsd,
totalMonthlyLimitUsd,
totalRemainingUsd,
nextExpiresAt,
planNames: Array.from(planNames),
updatedAt: Math.max(current?.updatedAt || 0, stored.updatedAt || 0) || null,
};
}
export async function sitesRoutes(app: FastifyInstance) {
function invalidateSiteCaches() {
invalidateSiteProxyCache();
@@ -137,21 +197,23 @@ export async function sitesRoutes(app: FastifyInstance) {
// List all sites
app.get('/api/sites', async () => {
const siteRows = await db.select().from(schema.sites).all();
const accountBalanceRows = await db.select({
const accountRows = await db.select({
siteId: schema.accounts.siteId,
totalBalance: sql<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
}).from(schema.accounts)
.groupBy(schema.accounts.siteId)
.all();
balance: schema.accounts.balance,
extraConfig: schema.accounts.extraConfig,
}).from(schema.accounts).all();
const totalBalanceBySiteId: Record<number, number> = {};
for (const row of accountBalanceRows) {
totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0);
const subscriptionBySiteId: Record<number, SiteSubscriptionAggregate | undefined> = {};
for (const row of accountRows) {
totalBalanceBySiteId[row.siteId] = roundMetric((totalBalanceBySiteId[row.siteId] || 0) + Number(row.balance || 0));
subscriptionBySiteId[row.siteId] = aggregateSiteSubscription(subscriptionBySiteId[row.siteId], row.extraConfig);
}
return siteRows.map((site) => ({
...site,
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
subscriptionSummary: subscriptionBySiteId[site.id] || null,
}));
});
@@ -160,6 +222,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name: string;
url: string;
platform?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean;
customHeaders?: string | null;
externalCheckinUrl?: string | null;
@@ -168,7 +231,7 @@ export async function sitesRoutes(app: FastifyInstance) {
sortOrder?: number;
globalWeight?: number;
} }>('/api/sites', async (request, reply) => {
const { name, url, platform, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
const { name, url, platform, proxyUrl, useSystemProxy, customHeaders, externalCheckinUrl, status, isPinned, sortOrder, globalWeight } = request.body;
const normalizedStatus = normalizeSiteStatus(status);
if (status !== undefined && !normalizedStatus) {
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
@@ -177,6 +240,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
}
const normalizedProxyUrl = parseSiteProxyUrlInput(proxyUrl);
if (!normalizedProxyUrl.valid) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
}
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) {
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
@@ -213,6 +280,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name,
url: url.replace(/\/+$/, ''),
platform: detectedPlatform,
proxyUrl: normalizedProxyUrl.proxyUrl,
useSystemProxy: normalizedUseSystemProxy ?? false,
customHeaders: normalizedCustomHeaders.customHeaders,
externalCheckinUrl: normalizedExternalCheckinUrl.url,
@@ -238,6 +306,7 @@ export async function sitesRoutes(app: FastifyInstance) {
name?: string;
url?: string;
platform?: string;
proxyUrl?: string | null;
useSystemProxy?: boolean;
customHeaders?: string | null;
externalCheckinUrl?: string | null;
@@ -266,6 +335,10 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
}
const normalizedProxyUrl = parseSiteProxyUrlInput(body.proxyUrl);
if (!normalizedProxyUrl.valid) {
return reply.code(400).send({ error: 'Invalid proxyUrl. Expected a valid http(s)/socks proxy URL.' });
}
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl);
if (!normalizedExternalCheckinUrl.valid) {
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
@@ -290,6 +363,7 @@ export async function sitesRoutes(app: FastifyInstance) {
if (body.name !== undefined) updates.name = body.name;
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
if (body.platform !== undefined) updates.platform = body.platform;
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
@@ -75,6 +75,10 @@ describe('stats proxy logs routes', () => {
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
clientFamily: 'generic',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
@@ -88,6 +92,7 @@ describe('stats proxy logs routes', () => {
modelRequested: 'gpt-4o-mini',
modelActual: 'gpt-4o-mini',
status: 'failed',
clientFamily: 'codex',
promptTokens: 8,
completionTokens: 2,
totalTokens: 10,
@@ -139,6 +144,10 @@ describe('stats proxy logs routes', () => {
totalCost: number;
totalTokensAll: number;
};
clientOptions: Array<{
value: string;
label: string;
}>;
};
expect(body.page).toBe(2);
@@ -150,7 +159,14 @@ describe('stats proxy logs routes', () => {
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]?.clientFamily).toBe('codex');
expect(body.items[0]?.clientAppId).toBe(null);
expect(body.items[0]?.clientAppName).toBe(null);
expect(body.items[0]?.clientConfidence).toBe(null);
expect(body.items[0]).not.toHaveProperty('billingDetails');
expect(body.clientOptions).toEqual([
{ value: 'family:codex', label: '协议 · Codex' },
]);
expect(body.summary).toEqual({
totalCount: 3,
successCount: 1,
@@ -188,6 +204,10 @@ describe('stats proxy logs routes', () => {
modelRequested: 'gpt-5',
modelActual: 'gpt-5',
status: 'success',
clientFamily: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
promptTokens: 100,
completionTokens: 20,
totalTokens: 120,
@@ -214,6 +234,10 @@ describe('stats proxy logs routes', () => {
downstreamKeyName: string | null;
downstreamKeyGroupName: string | null;
downstreamKeyTags: string[];
clientFamily: string | null;
clientAppId: string | null;
clientAppName: string | null;
clientConfidence: string | null;
billingDetails: Record<string, unknown> | null;
};
@@ -223,6 +247,10 @@ describe('stats proxy logs routes', () => {
expect(body.downstreamKeyName).toBe('detail-key');
expect(body.downstreamKeyGroupName).toBe('测试项目');
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
expect(body.clientFamily).toBe('codex');
expect(body.clientAppId).toBe('cherry_studio');
expect(body.clientAppName).toBe('Cherry Studio');
expect(body.clientConfidence).toBe('exact');
expect(body.billingDetails).toMatchObject({
breakdown: { totalCost: 0.12 },
usage: { promptTokens: 100, completionTokens: 20 },
@@ -392,4 +420,118 @@ describe('stats proxy logs routes', () => {
totalTokensAll: 30,
});
});
it('filters proxy logs by app id while keeping client options scoped only by the other filters', async () => {
const site = await db.insert(schema.sites).values({
name: 'client-filter-site',
url: 'https://client-filter.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'client-filter-user',
accessToken: 'client-filter-token',
status: 'active',
}).returning().get();
await db.insert(schema.proxyLogs).values([
{
accountId: account.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'success',
clientFamily: 'generic',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
totalTokens: 12,
estimatedCost: 0.12,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:00:00.000Z')),
},
{
accountId: account.id,
modelRequested: 'gpt-4.1',
modelActual: 'gpt-4.1',
status: 'failed',
clientFamily: 'codex',
totalTokens: 22,
estimatedCost: 0.22,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T11:05:00.000Z')),
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/stats/proxy-logs?client=app%3Acherry_studio',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
total: number;
items: Array<Record<string, unknown>>;
clientOptions: Array<{
value: string;
label: string;
}>;
};
expect(body.total).toBe(1);
expect(body.items[0]?.clientAppId).toBe('cherry_studio');
expect(body.clientOptions).toEqual([
{ value: 'app:cherry_studio', label: '应用 · Cherry Studio' },
{ value: 'family:codex', label: '协议 · Codex' },
]);
});
it('falls back to legacy client prefixes for old logs without inferring an app fingerprint', async () => {
const site = await db.insert(schema.sites).values({
name: 'legacy-site',
url: 'https://legacy.example.com',
platform: 'new-api',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'legacy-user',
accessToken: 'legacy-token',
status: 'active',
}).returning().get();
const inserted = await db.insert(schema.proxyLogs).values({
accountId: account.id,
modelRequested: 'gpt-4o',
modelActual: 'gpt-4o',
status: 'failed',
errorMessage: '[client:codex] [session:turn-123] [downstream:/v1/responses] upstream error',
totalTokens: 9,
estimatedCost: 0.09,
createdAt: formatUtcSqlDateTime(new Date('2026-03-09T12:00:00.000Z')),
}).run();
const logId = Number(inserted.lastInsertRowid || 0);
const listResponse = await app.inject({
method: 'GET',
url: '/api/stats/proxy-logs',
});
const detailResponse = await app.inject({
method: 'GET',
url: `/api/stats/proxy-logs/${logId}`,
});
expect(listResponse.statusCode).toBe(200);
expect(detailResponse.statusCode).toBe(200);
const listBody = listResponse.json() as {
items: Array<Record<string, unknown>>;
};
const detailBody = detailResponse.json() as Record<string, unknown>;
expect(listBody.items[0]?.clientFamily).toBe('codex');
expect(listBody.items[0]?.clientAppId).toBe(null);
expect(listBody.items[0]?.clientAppName).toBe(null);
expect(detailBody.clientFamily).toBe('codex');
expect(detailBody.clientAppId).toBe(null);
expect(detailBody.clientAppName).toBe(null);
});
});
+154 -2
View File
@@ -17,6 +17,7 @@ import {
parseProxyLogBillingDetails,
withProxyLogSelectFields,
} from '../../services/proxyLogStore.js';
import { parseProxyLogMessageMeta } from '../proxy/logPathMeta.js';
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
import {
@@ -85,6 +86,22 @@ function proxyCostSqlExpression() {
}
type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
type ProxyLogClientFilter = {
kind: 'app' | 'family';
value: string;
} | null;
type ProxyLogClientOption = {
value: string;
label: string;
};
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
codex: 'Codex',
claude_code: 'Claude Code',
gemini_cli: 'Gemini CLI',
generic: '通用',
};
function normalizeProxyLogPageSize(raw?: string): number {
const parsed = Number.parseInt(raw || '50', 10);
@@ -115,6 +132,20 @@ function normalizeProxyLogSiteId(raw?: string): number | null {
return parsed;
}
function normalizeProxyLogClientFilter(raw?: string): ProxyLogClientFilter {
const text = (raw || '').trim();
if (!text) return null;
const separatorIndex = text.indexOf(':');
if (separatorIndex <= 0) return null;
const kind = text.slice(0, separatorIndex).trim().toLowerCase();
const value = text.slice(separatorIndex + 1).trim().toLowerCase();
if (!value) return null;
if (kind === 'app' || kind === 'family') {
return { kind, value };
}
return null;
}
function normalizeProxyLogTimeBoundary(raw?: string): string | null {
const text = (raw || '').trim();
if (!text) return null;
@@ -166,9 +197,18 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
return null;
}
function buildProxyLogClientCondition(client: ProxyLogClientFilter) {
if (!client) return null;
if (client.kind === 'app') {
return eq(schema.proxyLogs.clientAppId, client.value);
}
return eq(schema.proxyLogs.clientFamily, client.value);
}
function buildProxyLogWhereClause(params: {
status?: ProxyLogStatusFilter;
search?: string;
client?: ProxyLogClientFilter;
siteId?: number | null;
fromUtc?: string | null;
toUtc?: string | null;
@@ -176,6 +216,7 @@ function buildProxyLogWhereClause(params: {
const conditions = [
params.status ? buildProxyLogStatusCondition(params.status) : null,
params.search ? buildProxyLogSearchCondition(params.search) : null,
params.client ? buildProxyLogClientCondition(params.client) : null,
params.siteId ? eq(schema.sites.id, params.siteId) : null,
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : null,
@@ -189,6 +230,83 @@ function toRoundedMicroNumber(value: number | null | undefined): number {
return Math.round(Number(value || 0) * 1_000_000) / 1_000_000;
}
function normalizeNullableText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed || null;
}
function normalizeClientConfidence(value: unknown): string | null {
const normalized = normalizeNullableText(value)?.toLowerCase() || null;
if (normalized === 'exact' || normalized === 'heuristic' || normalized === 'unknown') {
return normalized;
}
return null;
}
function displayProxyLogClientFamily(value: string | null): string | null {
if (!value) return null;
return PROXY_LOG_CLIENT_FAMILY_LABELS[value] || value;
}
function resolveProxyLogClientMeta(proxyLog: Record<string, unknown>) {
const clientFamily = normalizeNullableText(proxyLog.clientFamily)?.toLowerCase() || null;
const clientAppId = normalizeNullableText(proxyLog.clientAppId)?.toLowerCase() || null;
const clientAppName = normalizeNullableText(proxyLog.clientAppName) || null;
const clientConfidence = normalizeClientConfidence(proxyLog.clientConfidence);
if (clientFamily || clientAppId || clientAppName || clientConfidence) {
return {
clientFamily,
clientAppId,
clientAppName,
clientConfidence,
};
}
const legacyMeta = parseProxyLogMessageMeta(typeof proxyLog.errorMessage === 'string' ? proxyLog.errorMessage : '');
return {
clientFamily: normalizeNullableText(legacyMeta.clientKind)?.toLowerCase() || null,
clientAppId: null,
clientAppName: null,
clientConfidence: null,
};
}
function buildProxyLogClientOptions(rows: Array<{
clientFamily?: string | null;
clientAppId?: string | null;
clientAppName?: string | null;
}>): ProxyLogClientOption[] {
const appOptions = new Map<string, ProxyLogClientOption>();
const familyOptions = new Map<string, ProxyLogClientOption>();
for (const row of rows) {
const clientAppId = normalizeNullableText(row.clientAppId)?.toLowerCase() || null;
const clientAppName = normalizeNullableText(row.clientAppName) || null;
const clientFamily = normalizeNullableText(row.clientFamily)?.toLowerCase() || null;
if (clientAppId && clientAppName && !appOptions.has(clientAppId)) {
appOptions.set(clientAppId, {
value: `app:${clientAppId}`,
label: `应用 · ${clientAppName}`,
});
}
if (clientFamily && clientFamily !== 'generic' && !familyOptions.has(clientFamily)) {
familyOptions.set(clientFamily, {
value: `family:${clientFamily}`,
label: `协议 · ${displayProxyLogClientFamily(clientFamily) || clientFamily}`,
});
}
}
return [
...Array.from(appOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
...Array.from(familyOptions.values()).sort((left, right) => left.label.localeCompare(right.label, 'zh-CN')),
];
}
const SITE_AVAILABILITY_BUCKET_COUNT = 24;
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
@@ -351,11 +469,16 @@ function mapProxyLogRow(
},
options?: { includeBillingDetails?: boolean },
) {
const clientMeta = resolveProxyLogClientMeta(row.proxy_logs);
return {
...row.proxy_logs,
...(options?.includeBillingDetails
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
: {}),
clientFamily: clientMeta.clientFamily,
clientAppId: clientMeta.clientAppId,
clientAppName: clientMeta.clientAppName,
clientConfidence: clientMeta.clientConfidence,
username: row.accounts?.username || null,
siteId: row.sites?.id || null,
siteName: row.sites?.name || null,
@@ -547,6 +670,7 @@ export async function statsRoutes(app: FastifyInstance) {
offset?: string;
status?: string;
search?: string;
client?: string;
siteId?: string;
from?: string;
to?: string;
@@ -555,11 +679,13 @@ export async function statsRoutes(app: FastifyInstance) {
const offset = normalizeProxyLogOffset(request.query.offset);
const status = normalizeProxyLogStatusFilter(request.query.status);
const search = normalizeProxyLogSearch(request.query.search);
const client = normalizeProxyLogClientFilter(request.query.client);
const siteId = normalizeProxyLogSiteId(request.query.siteId);
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
const listWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
const summaryWhere = buildProxyLogWhereClause({ search, siteId, fromUtc, toUtc });
const listWhere = buildProxyLogWhereClause({ status, search, client, siteId, fromUtc, toUtc });
const summaryWhere = buildProxyLogWhereClause({ search, client, siteId, fromUtc, toUtc });
const clientOptionsWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
const listRows = await withProxyLogSelectFields(({ fields }) => {
let query = db.select({
@@ -604,6 +730,31 @@ export async function statsRoutes(app: FastifyInstance) {
}
const totalRow = await totalQuery.get();
const clientOptionRows = await withProxyLogSelectFields(({ fields, includeClientFields }) => {
if (!includeClientFields) {
return Promise.resolve([]);
}
let query = db.select({
clientFamily: fields.clientFamily!,
clientAppId: fields.clientAppId!,
clientAppName: fields.clientAppName!,
}).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));
if (clientOptionsWhere) {
query = query.where(clientOptionsWhere) as typeof query;
}
return query.all();
}, { includeBillingDetails: false, includeClientFields: true }) as Array<{
clientFamily?: string | null;
clientAppId?: string | null;
clientAppName?: string | null;
}>;
let summaryQuery = db.select({
totalCount: sql<number>`count(*)`,
successCount: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
@@ -624,6 +775,7 @@ export async function statsRoutes(app: FastifyInstance) {
total: Number(totalRow?.total || 0),
page: Math.floor(offset / limit) + 1,
pageSize: limit,
clientOptions: buildProxyLogClientOptions(clientOptionRows),
summary: {
totalCount: Number(summaryRow?.totalCount || 0),
successCount: Number(summaryRow?.successCount || 0),
@@ -14,6 +14,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const refreshOauthAccessTokenSingleflightMock = vi.fn();
const dbInsertMock = vi.fn((_arg?: any) => ({
values: () => ({
run: () => undefined,
@@ -60,10 +61,16 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
@@ -105,6 +112,7 @@ describe('chat proxy codex oauth compatibility', () => {
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
refreshOauthAccessTokenSingleflightMock.mockReset();
dbInsertMock.mockClear();
selectChannelMock.mockReturnValue({
@@ -128,6 +136,11 @@ describe('chat proxy codex oauth compatibility', () => {
actualModel: 'gpt-5.4',
});
selectNextChannelMock.mockReturnValue(null);
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
accessToken: 'fresh-access-token',
accountId: 33,
accountKey: 'chatgpt-account-123',
});
});
afterAll(async () => {
@@ -245,4 +258,107 @@ describe('chat proxy codex oauth compatibility', () => {
expect(response.body).not.toContain('event: response.created');
expect(response.body).not.toContain('"type":"response.output_text.delta"');
});
it('still translates raw codex SSE into OpenAI chat completion chunks when upstream mislabels the content-type', async () => {
fetchMock.mockResolvedValue(new Response([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_openai_stream_header_miss","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_header_miss","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_header_miss","delta":"pong from codex"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_openai_stream_header_miss","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n',
'data: [DONE]\n\n',
].join(''), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
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"');
});
it('retries oauth chat requests with a normalized upstream URL after refresh', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' },
account: {
id: 33,
username: 'oauth-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'oauth-user@example.com',
planType: 'plus',
},
}),
},
tokenName: 'default',
tokenValue: 'expired-access-token',
actualModel: 'gpt-4o-mini',
});
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: 'chatcmpl_refreshed',
object: 'chat.completion',
created: 1706000000,
model: 'gpt-4o-mini',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'ok after refresh' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/chat/completions',
payload: {
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'hello oauth' }],
},
});
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://gateway.example.com/v1/responses');
expect(secondUrl).toBe('https://gateway.example.com/v1/responses');
expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token');
expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token');
expect(response.json()?.choices?.[0]?.message?.content).toBe('ok after refresh');
});
});
@@ -68,6 +68,8 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
+223 -7
View File
@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
@@ -68,6 +69,9 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
@@ -99,6 +103,7 @@ describe('chat proxy stream behavior', () => {
fetchModelPricingCatalogMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
resetUpstreamEndpointRuntimeState();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
@@ -110,6 +115,17 @@ describe('chat proxy stream behavior', () => {
});
selectNextChannelMock.mockReturnValue(null);
fetchModelPricingCatalogMock.mockResolvedValue(null);
(config as any).codexHeaderDefaults = {
userAgent: '',
betaFeatures: '',
};
(config as any).payloadRules = {
default: [],
defaultRaw: [],
override: [],
overrideRaw: [],
filter: [],
};
config.proxyEmptyContentFailEnabled = false;
config.proxyErrorKeywords = [];
});
@@ -2016,6 +2032,87 @@ describe('chat proxy stream behavior', () => {
expect(targetUrl).toContain('/v1/messages');
});
it('does not stick generic /v1/responses traffic to /v1/messages after a fallback success', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'Gateway time-out', type: 'upstream_error' },
}), {
status: 504,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'Bad gateway', type: 'upstream_error' },
}), {
status: 502,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'msg_fallback_1',
type: 'message',
model: 'upstream-gpt',
content: [{ type: 'text', text: 'ok via messages fallback' }],
stop_reason: 'end_turn',
usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'resp_recovered_1',
object: 'response',
model: 'upstream-gpt',
status: 'completed',
output_text: 'ok via recovered responses',
usage: { input_tokens: 6, output_tokens: 2, total_tokens: 8 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const firstResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello',
},
});
expect(firstResponse.statusCode).toBe(200);
expect(firstResponse.json().output_text).toContain('ok via messages fallback');
const secondResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.4',
input: 'hello again',
},
});
expect(secondResponse.statusCode).toBe(200);
expect(secondResponse.json().output_text).toContain('ok via recovered responses');
expect(fetchMock).toHaveBeenCalledTimes(4);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
const [thirdUrl] = fetchMock.mock.calls[2] as [string, any];
const [fourthUrl] = fetchMock.mock.calls[3] as [string, any];
expect(firstUrl).toContain('/v1/responses');
expect(secondUrl).toContain('/v1/chat/completions');
expect(thirdUrl).toContain('/v1/messages');
expect(fourthUrl).toContain('/v1/responses');
});
it('prefers native /v1/responses for claude-family /v1/responses requests that explicitly ask for encrypted reasoning', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
@@ -2193,7 +2290,7 @@ describe('chat proxy stream behavior', () => {
});
});
it('returns clear 400 when input_file file_url is sent to a claude-only upstream without native responses support', async () => {
it('converts input_file file_url into Claude document url blocks for claude-only upstreams', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'claude-site', url: 'https://upstream.example.com', platform: 'claude' },
@@ -2202,6 +2299,18 @@ describe('chat proxy stream behavior', () => {
tokenValue: 'sk-demo',
actualModel: 'upstream-claude',
});
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_file_url_1',
type: 'message',
role: 'assistant',
model: 'upstream-claude',
content: [{ type: 'text', text: 'hello from claude messages' }],
stop_reason: 'end_turn',
usage: { input_tokens: 7, output_tokens: 3 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
@@ -2225,12 +2334,119 @@ describe('chat proxy stream behavior', () => {
},
});
expect(response.statusCode).toBe(400);
const body = response.json();
expect(body?.error?.type).toBe('invalid_request_error');
expect(body?.error?.message).toContain('input_file.file_url');
expect(body?.error?.message).toContain('/v1/responses');
expect(fetchMock).not.toHaveBeenCalled();
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
expect(targetUrl).toContain('/v1/messages');
const forwardedBody = JSON.parse(options.body);
expect(forwardedBody.messages[0].content[1]).toMatchObject({
type: 'document',
title: 'remote.pdf',
source: {
type: 'url',
url: 'https://example.com/remote.pdf',
},
});
});
it('does not let remote document url success poison later inline document endpoint preference', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
fetchMock.mockImplementation(async (target: unknown) => {
const url = String(target);
if (url.includes('/v1/responses')) {
return new Response(JSON.stringify({
id: 'resp_file_url_runtime_1',
object: 'response',
model: 'upstream-gpt',
output_text: 'hello from responses upstream',
output: [
{
id: 'msg_file_url_runtime_1',
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'hello from responses upstream' }],
},
],
status: 'completed',
usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url.includes('/v1/messages')) {
return new Response(JSON.stringify({
id: 'msg_inline_runtime_1',
type: 'message',
role: 'assistant',
model: 'upstream-gpt',
content: [{ type: 'text', text: 'hello from messages upstream' }],
stop_reason: 'end_turn',
usage: { input_tokens: 7, output_tokens: 3 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected target url: ${url}`);
});
const remoteResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'claude-haiku-4-5-20251001',
input: [
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'read this remote file' },
{
type: 'input_file',
filename: 'remote.pdf',
file_url: 'https://example.com/remote.pdf',
},
],
},
],
},
});
expect(remoteResponse.statusCode).toBe(200);
expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/v1/responses');
const inlineResponse = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'claude-haiku-4-5-20251001',
input: [
{
type: 'message',
role: 'user',
content: [
{ type: 'input_text', text: 'read this inline file' },
{
type: 'input_file',
filename: 'brief.pdf',
file_data: 'data:application/pdf;base64,JVBERi0xLjQK',
},
],
},
],
},
});
expect(inlineResponse.statusCode).toBe(200);
expect(String(fetchMock.mock.calls[1]?.[0])).toContain('/v1/messages');
});
it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without injecting a generic default include', async () => {
+113 -20
View File
@@ -1,6 +1,5 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
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,6 +16,8 @@ import { detectProxyFailure } from './proxyFailureJudge.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
@@ -30,8 +31,12 @@ 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 downstreamPath = '/v1/completions';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const isStream = body.stream === true;
const excludeChannelIds: number[] = [];
@@ -75,8 +80,28 @@ 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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: errText,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
errText,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
await reportTokenExpired({
@@ -174,11 +199,24 @@ export async function completionsProxyRoute(app: FastifyInstance) {
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
downstreamApiKeyId,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
clientContext,
downstreamPath,
);
return;
}
@@ -195,7 +233,11 @@ export async function completionsProxyRoute(app: FastifyInstance) {
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
const errText = failure.reason;
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: errText,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
@@ -204,7 +246,14 @@ export async function completionsProxyRoute(app: FastifyInstance) {
latency,
errText,
retryCount,
logDownstreamApiKeyId ? downstreamApiKeyId : null,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (shouldRetryProxyRequest(failure.status, errText) && retryCount < MAX_RETRIES) {
@@ -245,16 +294,49 @@ export async function completionsProxyRoute(app: FastifyInstance) {
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
downstreamApiKeyId,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
clientContext,
downstreamPath,
);
return reply.send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -285,18 +367,25 @@ async function logProxy(
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
clientContext: DownstreamClientContext | null = null,
downstreamPath = '/v1/completions',
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/completions',
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
errorMessage,
});
await db.insert(schema.proxyLogs).values({
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
@@ -306,11 +395,15 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
billingDetails,
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
}).run();
});
} catch (error) {
console.warn('[proxy/completions] failed to write proxy log', error);
}
@@ -71,6 +71,9 @@ vi.mock('../../db/index.js', () => ({
schema: {
proxyLogs: {},
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
}));
describe('downstream client context route logging', () => {
@@ -33,6 +33,16 @@ describe('isCodexResponsesSurface', () => {
})).toBe(true);
});
it('detects broader Codex official-client family headers from user-agent and originator prefixes', () => {
expect(isCodexResponsesSurface({
'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3',
})).toBe(true);
expect(isCodexResponsesSurface({
originator: 'codex_vscode',
})).toBe(true);
});
it('returns false for generic responses clients', () => {
expect(isCodexResponsesSurface({
'content-type': 'application/json',
@@ -50,6 +60,9 @@ describe('detectDownstreamClientContext', () => {
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'codex_cli_rs',
clientAppName: 'Codex CLI',
clientConfidence: 'exact',
sessionId: 'codex-session-123',
traceHint: 'codex-session-123',
});
@@ -63,6 +76,37 @@ describe('detectDownstreamClientContext', () => {
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'codex',
clientAppName: 'Codex',
clientConfidence: 'heuristic',
});
});
it('recognizes broader Codex official-client user-agent families without requiring stainless headers', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses',
headers: {
'user-agent': 'Mozilla/5.0 codex_chatgpt_desktop/1.2.3',
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'codex_chatgpt_desktop',
clientAppName: 'Codex Desktop',
clientConfidence: 'exact',
});
});
it('recognizes broader Codex official-client originator prefixes', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses',
headers: {
originator: 'codex_exec',
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'codex_exec',
clientAppName: 'Codex Exec',
clientConfidence: 'exact',
});
});
@@ -79,6 +123,9 @@ describe('detectDownstreamClientContext', () => {
body,
})).toEqual({
clientKind: 'claude_code',
clientAppId: 'claude_code',
clientAppName: 'Claude Code',
clientConfidence: 'exact',
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
});
@@ -128,6 +175,54 @@ describe('detectDownstreamClientContext', () => {
},
})).toEqual({
clientKind: 'gemini_cli',
clientAppId: 'gemini_cli',
clientAppName: 'Gemini CLI',
clientConfidence: 'exact',
});
});
it('recognizes app fingerprints alongside a generic protocol family', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/chat/completions',
headers: {
'x-title': 'Cherry Studio',
'http-referer': 'https://cherry-ai.com',
},
})).toEqual({
clientKind: 'generic',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
});
it('keeps protocol family detection when an app fingerprint also matches', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses',
headers: {
originator: 'codex_cli_rs',
'x-title': 'Cherry Studio',
'http-referer': 'https://cherry-ai.com',
},
})).toEqual({
clientKind: 'codex',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'exact',
});
});
it('marks weak app-only matches as heuristic instead of upgrading protocol behavior', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/chat/completions',
headers: {
'user-agent': 'CherryStudio/1.2.3',
},
})).toEqual({
clientKind: 'generic',
clientAppId: 'cherry_studio',
clientAppName: 'Cherry Studio',
clientConfidence: 'heuristic',
});
});
});
@@ -6,13 +6,269 @@ import { isCodexResponsesSurface as isCodexResponsesSurfaceViaProfile } from '..
import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js';
export type DownstreamClientKind = CliProfileId;
export type DownstreamClientConfidence = 'exact' | 'heuristic';
export type DownstreamClientContext = {
clientKind: DownstreamClientKind;
sessionId?: string;
traceHint?: string;
clientAppId?: string;
clientAppName?: string;
clientConfidence?: DownstreamClientConfidence;
};
type NormalizedClientHeaders = Record<string, string[]>;
type DownstreamClientBodySummary = {
topLevelKeys: string[];
metadataUserId: string | null;
};
type DownstreamClientFingerprintInput = {
downstreamPath: string;
headers: NormalizedClientHeaders;
bodySummary: DownstreamClientBodySummary;
};
type DownstreamClientFingerprintRule = {
id: string;
name: string;
priority: number;
match(input: DownstreamClientFingerprintInput): DownstreamClientConfidence | null;
};
type DownstreamProtocolClientApp = {
clientAppId: string;
clientAppName: string;
clientConfidence: DownstreamClientConfidence;
};
type HeaderPrefixMatcherRule = {
id: string;
name: string;
userAgentPrefixes?: string[];
originatorPrefixes?: string[];
};
const codexOfficialClientAppRules: HeaderPrefixMatcherRule[] = [
{
id: 'codex_cli_rs',
name: 'Codex CLI',
userAgentPrefixes: ['codex_cli_rs/'],
originatorPrefixes: ['codex_cli_rs'],
},
{
id: 'codex_vscode',
name: 'Codex VSCode',
userAgentPrefixes: ['codex_vscode/'],
originatorPrefixes: ['codex_vscode'],
},
{
id: 'codex_app',
name: 'Codex App',
userAgentPrefixes: ['codex_app/'],
originatorPrefixes: ['codex_app'],
},
{
id: 'codex_chatgpt_desktop',
name: 'Codex Desktop',
userAgentPrefixes: ['codex_chatgpt_desktop/', 'codex desktop/'],
originatorPrefixes: ['codex_chatgpt_desktop', 'codex desktop'],
},
{
id: 'codex_atlas',
name: 'Codex Atlas',
userAgentPrefixes: ['codex_atlas/'],
originatorPrefixes: ['codex_atlas'],
},
{
id: 'codex_exec',
name: 'Codex Exec',
userAgentPrefixes: ['codex_exec/'],
originatorPrefixes: ['codex_exec'],
},
{
id: 'codex_sdk_ts',
name: 'Codex SDK TS',
userAgentPrefixes: ['codex_sdk_ts/'],
originatorPrefixes: ['codex_sdk_ts'],
},
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function normalizeHeaderValues(value: unknown): string[] {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed ? [trimmed] : [];
}
if (!Array.isArray(value)) return [];
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter((item) => item.length > 0);
}
function normalizeHeaders(headers?: Record<string, unknown>): NormalizedClientHeaders {
if (!headers) return {};
const normalized: NormalizedClientHeaders = {};
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.trim().toLowerCase();
if (!key) continue;
const values = normalizeHeaderValues(rawValue);
if (values.length === 0) continue;
normalized[key] = normalized[key]
? [...normalized[key], ...values]
: values;
}
return normalized;
}
function headerEquals(headers: NormalizedClientHeaders, key: string, expected: string): boolean {
const normalizedExpected = expected.trim().toLowerCase();
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase() === normalizedExpected);
}
function headerIncludes(headers: NormalizedClientHeaders, key: string, expectedFragment: string): boolean {
const normalizedExpected = expectedFragment.trim().toLowerCase();
return (headers[key.trim().toLowerCase()] || []).some((value) => value.trim().toLowerCase().includes(normalizedExpected));
}
function headerMatchesPrefixes(headers: NormalizedClientHeaders, key: string, prefixes: string[]): boolean {
return prefixes.some((prefix) => headerIncludes(headers, key, prefix));
}
function buildBodySummary(body: unknown): DownstreamClientBodySummary {
if (!isRecord(body)) {
return {
topLevelKeys: [],
metadataUserId: null,
};
}
const metadataUserId = isRecord(body.metadata) && typeof body.metadata.user_id === 'string'
? body.metadata.user_id.trim() || null
: null;
return {
topLevelKeys: Object.keys(body).sort((left, right) => left.localeCompare(right)),
metadataUserId,
};
}
const appFingerprintRegistry: DownstreamClientFingerprintRule[] = [
{
id: 'cherry_studio',
name: 'Cherry Studio',
priority: 100,
match(input) {
const hasTitle = headerEquals(input.headers, 'x-title', 'Cherry Studio');
const hasReferer = headerEquals(input.headers, 'http-referer', 'https://cherry-ai.com')
|| headerEquals(input.headers, 'referer', 'https://cherry-ai.com');
if (hasTitle && hasReferer) {
return 'exact';
}
const weakSignals = [
headerIncludes(input.headers, 'user-agent', 'cherrystudio'),
headerIncludes(input.headers, 'x-title', 'cherry studio'),
headerIncludes(input.headers, 'http-referer', 'cherry-ai.com'),
headerIncludes(input.headers, 'referer', 'cherry-ai.com'),
];
return weakSignals.some(Boolean) ? 'heuristic' : null;
},
},
];
function detectDownstreamClientFingerprint(input: {
downstreamPath: string;
headers?: Record<string, unknown>;
body?: unknown;
}) {
const fingerprintInput: DownstreamClientFingerprintInput = {
downstreamPath: input.downstreamPath,
headers: normalizeHeaders(input.headers),
bodySummary: buildBodySummary(input.body),
};
let matchedRule: DownstreamClientFingerprintRule | null = null;
let matchedConfidence: DownstreamClientConfidence | null = null;
for (const rule of appFingerprintRegistry) {
const confidence = rule.match(fingerprintInput);
if (!confidence) continue;
if (!matchedRule || rule.priority > matchedRule.priority) {
matchedRule = rule;
matchedConfidence = confidence;
}
}
if (!matchedRule || !matchedConfidence) {
return null;
}
return {
clientAppId: matchedRule.id,
clientAppName: matchedRule.name,
clientConfidence: matchedConfidence,
};
}
function detectCodexOfficialClientApp(headers: NormalizedClientHeaders): DownstreamProtocolClientApp | null {
for (const rule of codexOfficialClientAppRules) {
const matchesOriginator = Array.isArray(rule.originatorPrefixes)
&& headerMatchesPrefixes(headers, 'originator', rule.originatorPrefixes);
const matchesUserAgent = Array.isArray(rule.userAgentPrefixes)
&& headerMatchesPrefixes(headers, 'user-agent', rule.userAgentPrefixes);
if (!matchesOriginator && !matchesUserAgent) continue;
return {
clientAppId: rule.id,
clientAppName: rule.name,
clientConfidence: 'exact',
};
}
return null;
}
function detectProtocolClientApp(input: {
clientKind: DownstreamClientKind;
headers?: Record<string, unknown>;
}): DownstreamProtocolClientApp | null {
switch (input.clientKind) {
case 'claude_code':
return {
clientAppId: 'claude_code',
clientAppName: 'Claude Code',
clientConfidence: 'exact',
};
case 'gemini_cli':
return {
clientAppId: 'gemini_cli',
clientAppName: 'Gemini CLI',
clientConfidence: 'exact',
};
case 'codex': {
const headers = normalizeHeaders(input.headers);
return detectCodexOfficialClientApp(headers) || {
clientAppId: 'codex',
clientAppName: 'Codex',
clientConfidence: 'heuristic',
};
}
default:
return null;
}
}
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
return isCodexResponsesSurfaceViaProfile(headers);
}
@@ -27,9 +283,15 @@ export function detectDownstreamClientContext(input: {
body?: unknown;
}): DownstreamClientContext {
const detected = detectCliProfile(input);
const fingerprint = detectDownstreamClientFingerprint(input);
const protocolClientApp = fingerprint ? null : detectProtocolClientApp({
clientKind: detected.id,
headers: input.headers,
});
return {
clientKind: detected.id,
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
...(fingerprint || protocolClientApp || {}),
};
}
+3 -1
View File
@@ -4,7 +4,9 @@ import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy {
return getProxyAuthContext(request)?.policy || EMPTY_DOWNSTREAM_ROUTING_POLICY;
const authContext = getProxyAuthContext(request);
if (!authContext) return EMPTY_DOWNSTREAM_ROUTING_POLICY;
return authContext.policy;
}
export async function ensureModelAllowedForDownstreamKey(
+73 -16
View File
@@ -1,6 +1,5 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
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';
@@ -16,6 +15,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
@@ -29,8 +30,12 @@ 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 downstreamPath = '/v1/embeddings';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -71,8 +76,28 @@ 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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
@@ -97,8 +122,8 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
let data: any = {};
try { data = JSON.parse(text); } catch { data = {}; }
const parsedUsage = parseProxyUsage(data);
const latency = Date.now() - startTime;
const parsedUsage = parseProxyUsage(data);
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
site: selected.site,
account: selected.account,
@@ -121,16 +146,37 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
);
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -159,18 +205,25 @@ async function logProxy(
totalTokens = 0,
estimatedCost = 0,
billingDetails: unknown = null,
clientContext: DownstreamClientContext | null = null,
downstreamPath = '/v1/embeddings',
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/embeddings',
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
errorMessage,
});
await db.insert(schema.proxyLogs).values({
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
@@ -180,11 +233,15 @@ async function logProxy(
completionTokens,
totalTokens,
estimatedCost,
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
billingDetails,
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
}).run();
});
} catch (error) {
console.warn('[proxy/embeddings] failed to write proxy log', error);
}
+35 -16
View File
@@ -73,6 +73,7 @@ describe('executeEndpointFlow', () => {
expect(result.ok).toBe(true);
expect(dispatchRequest).toHaveBeenCalledTimes(1);
expect(dispatchRequest.mock.calls[0]?.[1]).toBe('https://example.com/v1/responses');
expect(fetchMock).not.toHaveBeenCalled();
});
@@ -155,6 +156,40 @@ describe('executeEndpointFlow', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('emits attempt callbacks for failed and successful endpoint probes', async () => {
fetchMock
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'unsupported endpoint', type: 'invalid_request_error' },
}), {
status: 404,
headers: { 'content-type': 'application/json' },
})))
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
const onAttemptFailure = vi.fn();
const onAttemptSuccess = vi.fn();
const result = await executeEndpointFlow({
siteUrl: 'https://example.com',
endpointCandidates: ['responses', 'chat'],
buildRequest: (endpoint) => endpoint === 'responses'
? requestFor('/v1/responses')
: { ...requestFor('/v1/chat/completions'), endpoint },
shouldDowngrade: () => true,
onAttemptFailure,
onAttemptSuccess,
});
expect(result.ok).toBe(true);
expect(onAttemptFailure).toHaveBeenCalledTimes(1);
expect(onAttemptFailure.mock.calls[0]?.[0]?.request?.path).toBe('/v1/responses');
expect(onAttemptSuccess).toHaveBeenCalledTimes(1);
expect(onAttemptSuccess.mock.calls[0]?.[0]?.request?.path).toBe('/v1/chat/completions');
});
it('accepts recovered response from tryRecover hook', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'upstream_error', type: 'upstream_error' },
@@ -185,22 +220,6 @@ 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' },
+29 -11
View File
@@ -27,6 +27,14 @@ export type EndpointAttemptContext = {
rawErrText: string;
};
export type EndpointAttemptSuccessContext = {
endpointIndex: number;
endpointCount: number;
request: BuiltEndpointRequest;
targetUrl: string;
response: Awaited<ReturnType<typeof fetch>>;
};
export type EndpointRecoverResult = {
upstream: Awaited<ReturnType<typeof fetch>>;
upstreamPath: string;
@@ -51,7 +59,6 @@ export function withUpstreamPath(path: string, message: string): string {
type ExecuteEndpointFlowInput = {
siteUrl: string;
proxyUrl?: string | null;
endpointCandidates: UpstreamEndpoint[];
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
dispatchRequest?: (
@@ -61,14 +68,10 @@ type ExecuteEndpointFlowInput = {
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
onAttemptFailure?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
};
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<EndpointFlowResult> {
const endpointCount = input.endpointCandidates.length;
if (endpointCount <= 0) {
@@ -86,10 +89,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
const request = input.buildRequest(endpoint, endpointIndex);
const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path);
const targetUrl = input.proxyUrl
? buildAbsoluteUrl(input.proxyUrl, request.path)
: defaultTarget;
const targetUrl = buildUpstreamUrl(input.siteUrl, request.path);
let response = input.dispatchRequest
? await input.dispatchRequest(request, targetUrl)
@@ -100,6 +100,13 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
}));
if (response.ok) {
await input.onAttemptSuccess?.({
endpointIndex,
endpointCount,
request,
targetUrl,
response,
});
return {
ok: true,
upstream: response,
@@ -120,6 +127,13 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
if (input.tryRecover) {
const recovered = await input.tryRecover(baseContext);
if (recovered?.upstream?.ok) {
await input.onAttemptSuccess?.({
endpointIndex,
endpointCount,
request: baseContext.request,
targetUrl: baseContext.targetUrl,
response: recovered.upstream,
});
return {
ok: true,
upstream: recovered.upstream,
@@ -135,6 +149,10 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
baseContext.request.path,
summarizeUpstreamError(response.status, rawErrText),
);
await input.onAttemptFailure?.({
...baseContext,
errText,
});
const isLastEndpoint = endpointIndex >= endpointCount - 1;
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
+114 -6
View File
@@ -2,6 +2,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
const fetchMock = vi.fn();
const fetchModelPricingCatalogMock = vi.fn();
@@ -74,6 +75,9 @@ vi.mock('../../db/index.js', () => ({
select: (..._args: unknown[]) => createDbSelectChain(),
insert: (arg: unknown) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
modelAvailability: {
@@ -185,6 +189,7 @@ describe('gemini native proxy routes', () => {
selectNextChannelMock.mockReturnValue(null);
recordSuccessMock.mockResolvedValue(undefined);
recordFailureMock.mockResolvedValue(undefined);
resetUpstreamEndpointRuntimeState();
explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 });
isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true);
});
@@ -258,7 +263,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 500,
errorText: JSON.stringify({ error: { message: 'first channel failed' } }),
}));
const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(firstUrl).toContain('key=gemini-key');
@@ -681,6 +689,92 @@ describe('gemini native proxy routes', () => {
});
});
it('routes Gemini native document requests to responses endpoints on openai-compatible upstreams', 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.mockImplementation(async (target: unknown) => {
const url = String(target);
if (url === 'https://api.openai.com/v1/responses') {
return new Response(JSON.stringify({
id: 'resp-openai-file-1',
object: 'response',
model: 'gpt-4.1',
output: [
{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'document summary from responses' }],
},
],
usage: {
input_tokens: 9,
output_tokens: 4,
total_tokens: 13,
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === 'https://api.openai.com/v1/chat/completions') {
return new Response(JSON.stringify({
id: 'chatcmpl-openai-file-1',
object: 'chat.completion',
created: 1_742_160_002,
model: 'gpt-4.1',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: 'document summary from chat',
},
finish_reason: 'stop',
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected target url: ${url}`);
});
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: 'summarize this pdf' },
{
fileData: {
fileUri: 'https://example.com/brief.pdf',
mimeType: 'application/pdf',
},
},
],
},
],
},
});
expect(response.statusCode).toBe(200);
const [targetUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(targetUrl).toBe('https://api.openai.com/v1/responses');
});
it('routes Gemini native generateContent requests to antigravity upstreams through the internal content endpoint', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 43, routeId: 22 },
@@ -1414,7 +1508,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 400,
errorText: JSON.stringify({ error: { message: 'bad request on first channel' } }),
}));
const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(firstUrl).toContain('key=gemini-key');
@@ -1464,7 +1561,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 403,
errorText: JSON.stringify({ error: { message: 'forbidden on first channel' } }),
}));
});
it('falls back to the next channel when first Gemini channel returns 500 before any bytes are written', async () => {
@@ -1507,7 +1607,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 500,
errorText: 'upstream crash',
}));
});
it('falls back to the next channel when first Gemini channel throws before any bytes are written', async () => {
@@ -1547,7 +1650,9 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
errorText: 'socket hang up',
}));
});
it('falls back to the next channel for SSE requests before any bytes are written', async () => {
@@ -1593,7 +1698,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 500,
errorText: JSON.stringify({ error: { message: 'upstream unavailable' } }),
}));
expect(response.body).toContain('hello from fallback sse');
});
@@ -58,6 +58,9 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
+103 -20
View File
@@ -1,6 +1,5 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
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';
@@ -15,6 +14,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
@@ -27,8 +28,12 @@ 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 downstreamPath = '/v1/images/generations';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -70,8 +75,24 @@ 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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -103,13 +124,29 @@ export async function imagesProxyRoute(app: FastifyInstance) {
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -137,8 +174,12 @@ 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 downstreamPath = '/v1/images/edits';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body: jsonBody || Object.fromEntries(multipartForm?.entries?.() || []),
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -192,8 +233,24 @@ export async function imagesProxyRoute(app: FastifyInstance) {
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, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -225,13 +282,29 @@ export async function imagesProxyRoute(app: FastifyInstance) {
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, estimatedCost, '/v1/images/edits');
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
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, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -268,18 +341,24 @@ async function logProxy(
downstreamApiKeyId: number | null = null,
estimatedCost = 0,
downstreamPath = '/v1/images/generations',
clientContext: DownstreamClientContext | null = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
errorMessage,
});
await db.insert(schema.proxyLogs).values({
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
@@ -289,10 +368,14 @@ async function logProxy(
completionTokens: 0,
totalTokens: 0,
estimatedCost,
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
errorMessage: normalizedErrorMessage,
retryCount,
createdAt,
}).run();
});
} catch (error) {
console.warn('[proxy/images] failed to write proxy log', error);
}
+3 -3
View File
@@ -7,7 +7,7 @@ type ComposeProxyLogMessageArgs = {
errorMessage?: string | null;
};
type ParsedPathMeta = {
export type ParsedProxyLogMessageMeta = {
clientKind: string | null;
sessionId: string | null;
downstreamPath: string | null;
@@ -15,7 +15,7 @@ type ParsedPathMeta = {
messageText: string;
};
function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta {
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
@@ -42,7 +42,7 @@ export function composeProxyLogMessage({
errorMessage,
}: ComposeProxyLogMessageArgs): string | null {
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
const parsed = parseExistingPathMeta(rawMessage);
const parsed = parseProxyLogMessageMeta(rawMessage);
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
+117
View File
@@ -8,6 +8,7 @@ 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');
type ConfigModule = typeof import('../../config.js');
describe('/v1/models route', () => {
let db: DbModule['db'];
@@ -15,6 +16,7 @@ describe('/v1/models route', () => {
let proxyRoutes: ProxyRouterModule['proxyRoutes'];
let tokensRoutes: TokensRoutesModule['tokensRoutes'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let config: ConfigModule['config'];
let app: FastifyInstance;
let dataDir = '';
@@ -27,12 +29,15 @@ describe('/v1/models route', () => {
const proxyRouterModule = await import('./router.js');
const tokenRouterModule = await import('../../services/tokenRouter.js');
const tokensRoutesModule = await import('../api/tokens.js');
const configModule = await import('../../config.js');
db = dbModule.db;
schema = dbModule.schema;
proxyRoutes = proxyRouterModule.proxyRoutes;
tokensRoutes = tokensRoutesModule.tokensRoutes;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
config = configModule.config;
config.proxyToken = 'sk-global-proxy-token';
app = Fastify();
await app.register(tokensRoutes);
@@ -108,6 +113,7 @@ describe('/v1/models route', () => {
name: 'managed-key',
key: 'sk-managed-models',
enabled: true,
supportedModels: JSON.stringify(['routable-model']),
}).run();
const response = await app.inject({
@@ -129,6 +135,64 @@ describe('/v1/models route', () => {
expect(ids).not.toContain('orphan-model');
});
it('keeps global proxy token unrestricted when no managed key matches', async () => {
const site = await db.insert(schema.sites).values({
name: 'global-site',
url: 'https://global.example.com',
platform: 'openai',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: 'global-access-token',
status: 'active',
}).returning().get();
const token = await db.insert(schema.accountTokens).values({
accountId: account.id,
name: 'default',
token: 'global-api-token',
enabled: true,
isDefault: true,
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'global-routable-model',
available: true,
}).run();
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'global-routable-model',
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: account.id,
tokenId: token.id,
sourceModel: 'global-routable-model',
enabled: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/v1/models',
headers: {
authorization: 'Bearer sk-global-proxy-token',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
object: 'list';
data: Array<{ id: string }>;
};
expect(body.data.map((item) => item.id)).toContain('global-routable-model');
});
it('returns only whitelist models for managed key with supportedModels policy', async () => {
const site = await db.insert(schema.sites).values({
name: 'test-site',
@@ -290,6 +354,58 @@ describe('/v1/models route', () => {
expect(ids).not.toContain('claude-sonnet-4-5');
});
it('returns no models for managed key with empty model and group selections', async () => {
const site = await db.insert(schema.sites).values({
name: 'deny-all-site',
url: 'https://deny-all.example.com',
platform: 'openai',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
accessToken: 'deny-all-access-token',
status: 'active',
}).returning().get();
await db.insert(schema.modelAvailability).values([
{
accountId: account.id,
modelName: 'gpt-4o-mini',
available: true,
},
{
accountId: account.id,
modelName: 'claude-opus-4-6',
available: true,
},
]).run();
await db.insert(schema.downstreamApiKeys).values({
name: 'managed-key-deny-all',
key: 'sk-managed-deny-all',
enabled: true,
supportedModels: JSON.stringify([]),
allowedRouteIds: JSON.stringify([]),
}).run();
const response = await app.inject({
method: 'GET',
url: '/v1/models',
headers: {
authorization: 'Bearer sk-managed-deny-all',
},
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
object: 'list';
data: Array<{ id: string }>;
};
expect(body.data).toEqual([]);
});
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',
@@ -461,6 +577,7 @@ describe('/v1/models route', () => {
name: 'search-key',
key: 'sk-search-key',
enabled: true,
supportedModels: JSON.stringify(['gpt-4.1']),
}).run();
const response = await app.inject({
@@ -1,5 +1,6 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
@@ -17,6 +18,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
const refreshOauthAccessTokenSingleflightMock = vi.fn();
const recordOauthQuotaResetHintMock = vi.fn();
const insertedProxyLogs: Record<string, unknown>[] = [];
const originalProxyEmptyContentFailEnabled = config.proxyEmptyContentFailEnabled;
const dbInsertMock = vi.fn((_arg?: any) => ({
values: (values: Record<string, unknown>) => {
insertedProxyLogs.push(values);
@@ -78,6 +80,8 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
@@ -109,6 +113,7 @@ describe('responses proxy codex oauth refresh', () => {
});
beforeEach(() => {
config.proxyEmptyContentFailEnabled = false;
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
@@ -152,6 +157,7 @@ describe('responses proxy codex oauth refresh', () => {
});
afterAll(async () => {
config.proxyEmptyContentFailEnabled = originalProxyEmptyContentFailEnabled;
await app.close();
});
@@ -208,6 +214,69 @@ describe('responses proxy codex oauth refresh', () => {
expect(response.json()?.output_text).toContain('ok after codex token refresh');
});
it('retries oauth responses requests with a normalized upstream URL after refresh', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { name: 'openai-site', url: 'https://gateway.example.com/v1/', platform: 'openai' },
account: {
id: 33,
username: 'oauth-user@example.com',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'oauth-user@example.com',
planType: 'plus',
},
}),
},
tokenName: 'default',
tokenValue: 'expired-access-token',
actualModel: 'gpt-4.1-mini',
});
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_openai_refreshed',
object: 'response',
model: 'gpt-4.1-mini',
status: 'completed',
output_text: 'ok after 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',
payload: {
model: 'gpt-4.1-mini',
input: 'hello oauth',
},
});
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://gateway.example.com/v1/responses');
expect(secondUrl).toBe('https://gateway.example.com/v1/responses');
expect(firstOptions.headers.Authorization).toBe('Bearer expired-access-token');
expect(secondOptions.headers.Authorization).toBe('Bearer fresh-access-token');
expect(response.json()?.output_text).toBe('ok after 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',
@@ -501,6 +570,39 @@ describe('responses proxy codex oauth refresh', () => {
expect(String(insertedProxyLogs.at(-1)?.errorMessage || '')).toContain('stream closed before response.completed');
});
it('does not record success when a native responses stream completes with empty content and empty usage while empty-content failure is enabled', async () => {
config.proxyEmptyContentFailEnabled = true;
fetchMock.mockResolvedValue(createSseResponse([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_empty","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_empty","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":0,"output_tokens":0,"total_tokens":0}}}\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('event: response.failed');
expect(response.body).not.toContain('event: 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('empty content');
});
it('does not retry or mark failure after converting a non-stream upstream payload into SSE when post-stream usage accounting fails', async () => {
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed'));
fetchMock.mockResolvedValue(new Response(JSON.stringify({
@@ -578,4 +680,39 @@ describe('responses proxy codex oauth refresh', () => {
expect(recordFailureMock).not.toHaveBeenCalled();
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
});
it('replays raw codex SSE when upstream mislabels a streaming responses body as application/json', async () => {
fetchMock.mockResolvedValue(new Response([
'event: response.created\n',
'data: {"type":"response.created","response":{"id":"resp_codex_stream_header_miss","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_header_miss","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_header_miss","delta":"pong"}\n\n',
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_codex_stream_header_miss","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
'data: [DONE]\n\n',
].join(''), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
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('event: response.output_text.delta');
expect(response.body).toContain('"delta":"pong"');
expect(response.body).not.toContain('"output_text":"event: response.created');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(recordFailureMock).not.toHaveBeenCalled();
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
});
});
@@ -67,6 +67,9 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { createServer, type Server } from 'node:http';
import { AddressInfo } from 'node:net';
import WebSocket from 'ws';
import WebSocket, { WebSocketServer } from 'ws';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
@@ -72,6 +73,8 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
@@ -175,6 +178,14 @@ function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 100
describe('responses websocket transport', () => {
let app: FastifyInstance;
let baseUrl: string;
let upstreamServer: WebSocketServer;
let upstreamSiteUrl: string;
let upstreamConnectionCount: number;
let upstreamUpgradeHeaders: Record<string, string>;
let upstreamRequests: Record<string, unknown>[];
let upstreamMessageHandler: (socket: WebSocket, parsed: Record<string, unknown>, requestIndex: number) => void;
let rejectedUpgradeServer: Server;
let rejectedUpgradeSiteUrl: string;
beforeAll(async () => {
const { responsesProxyRoute } = await import('./responses.js');
@@ -183,6 +194,39 @@ describe('responses websocket transport', () => {
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}`;
upstreamServer = new WebSocketServer({ port: 0 });
upstreamServer.on('connection', (socket, request) => {
upstreamConnectionCount += 1;
upstreamUpgradeHeaders = Object.fromEntries(
Object.entries(request.headers)
.map(([key, value]) => [key, Array.isArray(value) ? value[0] || '' : value || '']),
);
socket.on('message', (payload) => {
const parsed = JSON.parse(String(payload)) as Record<string, unknown>;
upstreamRequests.push(parsed);
upstreamMessageHandler(socket, parsed, upstreamRequests.length);
});
});
await new Promise<void>((resolve) => upstreamServer.once('listening', () => resolve()));
const upstreamAddress = upstreamServer.address() as AddressInfo;
upstreamSiteUrl = `http://127.0.0.1:${upstreamAddress.port}/backend-api/codex`;
rejectedUpgradeServer = createServer();
rejectedUpgradeServer.on('upgrade', (_request, socket) => {
socket.write(
'HTTP/1.1 426 Upgrade Required\r\n'
+ 'Content-Type: text/plain\r\n'
+ 'Content-Length: 16\r\n'
+ 'Connection: close\r\n'
+ '\r\n'
+ 'Upgrade Required',
);
socket.destroy();
});
await new Promise<void>((resolve) => rejectedUpgradeServer.listen(0, '127.0.0.1', () => resolve()));
const rejectedAddress = rejectedUpgradeServer.address() as AddressInfo;
rejectedUpgradeSiteUrl = `http://127.0.0.1:${rejectedAddress.port}/backend-api/codex`;
});
beforeEach(() => {
@@ -202,24 +246,90 @@ describe('responses websocket transport', () => {
selectChannelMock.mockReturnValue(selectedChannel);
selectNextChannelMock.mockReturnValue(null);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamConnectionCount = 0;
upstreamUpgradeHeaders = {};
upstreamRequests = [];
upstreamMessageHandler = (socket, parsed, requestIndex) => {
const responseId = `resp_upstream_${requestIndex}`;
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: responseId,
object: 'response',
model: parsed.model || 'gpt-5.4',
status: 'completed',
output: [],
usage: {
input_tokens: 1,
output_tokens: 1,
total_tokens: 2,
},
},
}));
};
});
afterAll(async () => {
await new Promise<void>((resolve) => rejectedUpgradeServer.close(() => resolve()));
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
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 selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamMessageHandler = (socket) => {
socket.send(JSON.stringify({
type: 'response.created',
response: {
id: 'resp_ws',
model: 'gpt-5.4',
created_at: 1706000000,
status: 'in_progress',
output: [],
},
}));
socket.send(JSON.stringify({
type: 'response.output_item.added',
output_index: 0,
item: {
id: 'msg_ws',
type: 'message',
role: 'assistant',
status: 'in_progress',
content: [],
},
}));
socket.send(JSON.stringify({
type: 'response.output_text.delta',
output_index: 0,
item_id: 'msg_ws',
delta: 'pong',
}));
socket.send(JSON.stringify({
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,
},
},
}));
};
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
@@ -247,7 +357,8 @@ describe('responses websocket transport', () => {
'response.completed',
]);
expect(messages[3]?.response?.output?.[0]?.content?.[0]?.text).toBe('pong');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(upstreamConnectionCount).toBe(1);
});
it('echoes x-codex-turn-state on websocket upgrade responses', async () => {
@@ -266,6 +377,89 @@ describe('responses websocket transport', () => {
expect(upgrade.headers['x-codex-turn-state']).toBe('turn-state-123');
});
it('reuses one upstream codex websocket session across sequential websocket turns', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
const socket = new WebSocket(`${baseUrl}/v1/responses`, {
headers: {
'x-codex-turn-state': 'turn-state-123',
'x-codex-beta-features': 'feature-a,feature-b',
},
});
await waitForSocketOpen(socket);
const firstMessagesPromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [],
}));
const firstMessages = await firstMessagesPromise;
const secondMessagesPromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
previous_response_id: firstMessages[0]?.response?.id,
input: [],
}));
const secondMessages = await secondMessagesPromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(firstMessages[0]?.type).toBe('response.completed');
expect(secondMessages[0]?.type).toBe('response.completed');
expect(upstreamConnectionCount).toBe(1);
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
model: 'gpt-5.4',
});
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: firstMessages[0]?.response?.id,
});
expect(upstreamUpgradeHeaders['x-codex-turn-state']).toBe('turn-state-123');
expect(upstreamUpgradeHeaders['x-codex-beta-features']).toBe('feature-a,feature-b');
expect(upstreamUpgradeHeaders['openai-beta']).toContain('responses_websockets=');
});
it('falls back to the HTTP responses executor when the upstream codex websocket upgrade returns 426', async () => {
const selectedChannel = createSelectedChannel({
siteUrl: rejectedUpgradeSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
fetchMock.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_http_fallback","model":"gpt-5.4","status":"completed","output":[],"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 messagesPromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
input: [],
}));
const messages = await messagesPromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
expect(messages[0]?.response?.id).toBe('resp_http_fallback');
});
it('merges follow-up response.create payloads when the selected upstream does not support incremental mode', async () => {
const selectedChannel = createSelectedChannel({
sitePlatform: 'openai',
@@ -364,21 +558,56 @@ describe('responses websocket transport', () => {
});
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 selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamMessageHandler = (socket, _parsed, requestIndex) => {
if (requestIndex === 1) {
socket.send(JSON.stringify({
type: 'response.completed',
response: {
id: 'resp_ws_1',
model: 'gpt-5.4',
status: 'completed',
output: [{
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,
},
},
}));
return;
}
socket.send(JSON.stringify({
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,
},
},
}));
};
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
@@ -415,12 +644,92 @@ describe('responses websocket transport', () => {
await secondResponsePromise;
socket.close();
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(upstreamConnectionCount).toBe(1);
expect(upstreamRequests).toHaveLength(2);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
model: 'gpt-5.4',
instructions: 'be helpful',
});
expect(upstreamRequests[1]).toMatchObject({
type: 'response.create',
previous_response_id: 'resp_ws_1',
model: 'gpt-5.4',
instructions: 'be helpful',
input: [
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
],
});
});
it('disables codex websocket incremental transport when the selected account marks websockets as disabled', async () => {
const selectedChannel = createSelectedChannel({
extraConfig: JSON.stringify({
credentialMode: 'session',
websockets: false,
oauth: {
provider: 'codex',
accountId: 'chatgpt-account-123',
email: 'codex-user@example.com',
},
}),
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
fetchMock
.mockResolvedValueOnce(createSseResponse([
'event: response.completed\n',
'data: {"type":"response.completed","response":{"id":"resp_http_1","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"first"}]}],"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_http_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"second"}]}],"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: [],
}));
const firstMessages = await firstResponsePromise;
const secondResponsePromise = waitForSocketMessages(socket, 1);
socket.send(JSON.stringify({
type: 'response.create',
model: 'gpt-5.4',
previous_response_id: 'resp_http_1',
input: [
{
id: 'tool_out_1',
type: 'function_call_output',
call_id: 'call_1',
output: 'tool result',
},
],
}));
await secondResponsePromise;
socket.close();
expect(firstMessages[0]?.type).toBe('response.completed');
expect(upstreamConnectionCount).toBe(0);
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.previous_response_id).toBeUndefined();
expect(secondBody.input).toEqual([
{
id: 'tool_out_1',
@@ -490,11 +799,11 @@ describe('responses websocket transport', () => {
});
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 selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
@@ -510,23 +819,42 @@ describe('responses websocket transport', () => {
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);
expect(fetchMock).toHaveBeenCalledTimes(0);
expect(upstreamRequests[0]).toMatchObject({
type: 'response.create',
generate: 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 selectedChannel = createSelectedChannel({
siteUrl: upstreamSiteUrl,
});
selectChannelMock.mockReturnValue(selectedChannel);
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
upstreamMessageHandler = (socket) => {
socket.send(JSON.stringify({
type: 'response.created',
response: {
id: 'resp_incomplete',
model: 'gpt-5.4',
created_at: 1706000000,
status: 'in_progress',
output: [],
},
}));
socket.send(JSON.stringify({
type: 'response.output_text.delta',
output_index: 0,
item_id: 'msg_ws',
delta: 'partial',
}));
socket.close();
};
const socket = new WebSocket(`${baseUrl}/v1/responses`);
await waitForSocketOpen(socket);
const messagesPromise = waitForSocketMessages(socket, 6, 400);
const messagesPromise = waitForSocketMessages(socket, 3, 400);
socket.send(JSON.stringify({
type: 'response.create',
@@ -546,12 +874,8 @@ describe('responses websocket transport', () => {
expect(messages.map((message) => message.type)).toEqual([
'response.created',
'response.output_text.delta',
'response.output_text.done',
'response.content_part.done',
'response.output_item.done',
'response.failed',
'error',
]);
expect(messages[5]?.response?.status).toBe('failed');
expect(messages[5]?.error?.message).toContain('stream closed before response.completed');
expect(messages[2]?.error?.message).toContain('stream closed before response.completed');
});
});
@@ -20,4 +20,56 @@ describe('collectResponsesFinalPayloadFromSse', () => {
.rejects
.toThrow('quota exceeded');
});
it('prefers aggregated stream content when response.completed only carries an empty output array', async () => {
const upstream = {
async text() {
return [
'event: response.created',
'data: {"type":"response.created","response":{"id":"resp_empty_completed","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
'',
'event: response.output_item.added',
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_empty_completed","type":"message","role":"assistant","status":"in_progress","content":[]}}',
'',
'event: response.output_text.delta',
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_empty_completed","delta":"pong"}',
'',
'event: response.completed',
'data: {"type":"response.completed","response":{"id":"resp_empty_completed","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
'',
'data: [DONE]',
'',
].join('\n');
},
};
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
.resolves
.toMatchObject({
payload: {
id: 'resp_empty_completed',
status: 'completed',
output: [
{
id: 'msg_empty_completed',
type: 'message',
role: 'assistant',
status: 'completed',
content: [
{
type: 'output_text',
text: 'pong',
},
],
},
],
output_text: 'pong',
usage: {
input_tokens: 3,
output_tokens: 1,
total_tokens: 4,
},
},
});
});
});
+149 -10
View File
@@ -24,6 +24,36 @@ function getResponsesFailureMessage(payload: Record<string, unknown>): string {
return 'upstream stream failed';
}
function hasMeaningfulMessageContent(content: unknown): boolean {
if (!Array.isArray(content)) return false;
return content.some((part) => {
if (!isRecord(part)) return false;
const partType = typeof part.type === 'string' ? part.type.trim().toLowerCase() : '';
if (partType === 'output_text' || partType === 'text') {
return typeof part.text === 'string' && part.text.length > 0;
}
return true;
});
}
function hasMeaningfulResponsesOutput(output: unknown): boolean {
if (!Array.isArray(output)) return false;
return output.some((item) => {
if (!isRecord(item)) return false;
const itemType = typeof item.type === 'string' ? item.type.trim().toLowerCase() : '';
if (itemType === 'message') {
return hasMeaningfulMessageContent(item.content);
}
if (itemType === 'reasoning') {
return (
(Array.isArray(item.summary) && item.summary.length > 0)
|| (typeof item.encrypted_content === 'string' && item.encrypted_content.trim().length > 0)
);
}
return itemType.length > 0;
});
}
function hasCompleteFinalResponsesPayload(payload: Record<string, unknown>): boolean {
return (
payload.object === 'response.compaction'
@@ -32,11 +62,101 @@ function hasCompleteFinalResponsesPayload(payload: Record<string, unknown>): boo
);
}
export async function collectResponsesFinalPayloadFromSse(
upstream: { text(): Promise<string> },
function hasMeaningfulFinalResponsesPayload(payload: Record<string, unknown>): boolean {
if (payload.object === 'response.compaction') {
return Array.isArray(payload.output) && payload.output.length > 0;
}
if (typeof payload.output_text === 'string' && payload.output_text.length > 0) {
return true;
}
return hasMeaningfulResponsesOutput(payload.output);
}
function rememberStreamResponseEnvelope(
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
payload: Record<string, unknown>,
): void {
const responsePayload = isRecord(payload.response) ? payload.response : payload;
if (typeof responsePayload.id === 'string' && responsePayload.id.trim().length > 0) {
streamContext.id = responsePayload.id;
}
if (typeof responsePayload.model === 'string' && responsePayload.model.trim().length > 0) {
streamContext.model = responsePayload.model;
}
const createdAt = (
typeof responsePayload.created_at === 'number' && Number.isFinite(responsePayload.created_at)
? responsePayload.created_at
: (typeof responsePayload.created === 'number' && Number.isFinite(responsePayload.created)
? responsePayload.created
: null)
);
if (createdAt !== null) {
streamContext.created = createdAt;
}
}
function materializeCompletedPayloadFromAggregate(
aggregateState: ReturnType<typeof openAiResponsesTransformer.aggregator.createState>,
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
usage: ReturnType<typeof parseProxyUsage>,
): Record<string, unknown> | null {
const lines = openAiResponsesTransformer.aggregator.complete(
aggregateState,
streamContext,
usage,
);
const { events } = openAiResponsesTransformer.pullSseEvents(lines.join(''));
for (const event of events) {
if (event.data === '[DONE]') continue;
const payload = parseResponsesSsePayload(event.data);
if (!payload) continue;
if (event.event === 'response.completed' && isRecord(payload.response)) {
return payload.response;
}
if (payload.type === 'response.completed' && hasCompleteFinalResponsesPayload(payload)) {
return payload;
}
}
return null;
}
export function looksLikeResponsesSseText(rawText: string): boolean {
const { events, rest } = openAiResponsesTransformer.pullSseEvents(rawText);
if (events.length === 0 || rest.trim().length > 0) return false;
return events.some((event) => {
if (event.data === '[DONE]') return true;
if (event.event === 'error' || event.event.startsWith('response.')) return true;
const payload = parseResponsesSsePayload(event.data);
const payloadType = typeof payload?.type === 'string' ? payload.type : '';
return payloadType === 'error' || payloadType.startsWith('response.');
});
}
export function createSingleChunkStreamReader(rawText: string): {
read(): Promise<{ done: boolean; value?: Uint8Array }>;
cancel(reason?: unknown): Promise<unknown>;
releaseLock(): void;
} {
const chunk = Buffer.from(rawText, 'utf8');
let done = false;
return {
async read() {
if (done) return { done: true };
done = true;
return { done: false, value: chunk };
},
async cancel() {
done = true;
return undefined;
},
releaseLock() {},
};
}
export function collectResponsesFinalPayloadFromSseText(
rawText: string,
modelName: string,
): Promise<{ payload: Record<string, unknown>; rawText: string }> {
const rawText = await upstream.text();
): { payload: Record<string, unknown>; rawText: string } {
const { events } = openAiResponsesTransformer.pullSseEvents(rawText);
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
const aggregateState = openAiResponsesTransformer.aggregator.createState(modelName);
@@ -92,12 +212,17 @@ export async function collectResponsesFinalPayloadFromSse(
const payloadType = typeof payload.type === 'string' ? payload.type : '';
const eventType = payloadType || event.event;
rememberStreamResponseEnvelope(streamContext, payload);
usage = mergeProxyUsage(usage, parseProxyUsage(payload));
captureCompletedPayloadFromEvent(eventType, payload);
if (completedPayload) {
continue;
}
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(payload, streamContext, modelName);
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(
payload,
streamContext,
modelName,
);
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.serialize({
state: aggregateState,
streamContext,
@@ -106,12 +231,19 @@ export async function collectResponsesFinalPayloadFromSse(
}));
}
if (
completedPayload
&& !hasMeaningfulFinalResponsesPayload(completedPayload)
&& hasMeaningfulResponsesOutput(aggregateState.outputItems)
) {
completedPayload = materializeCompletedPayloadFromAggregate(aggregateState, streamContext, usage);
}
if (!completedPayload) {
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.complete(
aggregateState,
streamContext,
usage,
));
const materialized = materializeCompletedPayloadFromAggregate(aggregateState, streamContext, usage);
if (materialized) {
completedPayload = materialized;
}
}
if (completedPayload) {
@@ -123,3 +255,10 @@ export async function collectResponsesFinalPayloadFromSse(
throw new Error('stream disconnected before response.completed');
}
export async function collectResponsesFinalPayloadFromSse(
upstream: { text(): Promise<string> },
modelName: string,
): Promise<{ payload: Record<string, unknown>; rawText: string }> {
return collectResponsesFinalPayloadFromSseText(await upstream.text(), modelName);
}
+253 -50
View File
@@ -2,13 +2,19 @@ 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 { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } from '../../proxy-core/runtime/codexWebsocketRuntime.js';
import { tokenRouter } from '../../services/tokenRouter.js';
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js';
const installedApps = new WeakSet<FastifyInstance>();
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';
const codexWebsocketRuntime = createCodexWebsocketRuntime();
type SelectedChannel = NonNullable<Awaited<ReturnType<typeof tokenRouter.selectChannel>>>;
type NormalizedResponsesWebsocketRequest =
| {
@@ -42,6 +48,93 @@ function headerValueToTrimmedString(value: unknown): string {
return '';
}
function toBooleanLike(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') return true;
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') return false;
}
return null;
}
function parseExtraConfigRecord(extraConfig: unknown): Record<string, unknown> | null {
if (typeof extraConfig !== 'string') return null;
try {
const parsed = JSON.parse(extraConfig);
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function readNestedRecord(value: unknown, key: string): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const nested = value[key];
return isRecord(nested) ? nested : null;
}
function selectedChannelModelMatches(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
if (!selectedChannel) return false;
const selectedModel = asTrimmedString(selectedChannel.actualModel).toLowerCase();
const normalizedRequestModel = asTrimmedString(requestModel).toLowerCase();
if (!selectedModel || !normalizedRequestModel) return true;
return selectedModel === normalizedRequestModel;
}
function selectedChannelSupportsCodexWebsocketTransport(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
if (!selectedChannel) return false;
const platform = asTrimmedString(selectedChannel.site?.platform).toLowerCase();
if (platform !== 'codex') return false;
if (!selectedChannelModelMatches(selectedChannel, requestModel)) return false;
const extraConfig = parseExtraConfigRecord(selectedChannel.account.extraConfig);
const oauth = readNestedRecord(extraConfig, 'oauth');
const providerData = readNestedRecord(oauth, 'providerData');
const candidateFlags = [
extraConfig?.websockets,
readNestedRecord(extraConfig, 'attributes')?.websockets,
readNestedRecord(extraConfig, 'metadata')?.websockets,
providerData?.websockets,
readNestedRecord(providerData, 'attributes')?.websockets,
readNestedRecord(providerData, 'metadata')?.websockets,
];
for (const candidate of candidateFlags) {
const parsed = toBooleanLike(candidate);
if (parsed !== null) return parsed;
}
return true;
}
function selectedChannelSupportsIncrementalInput(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
return selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel);
}
function shouldReuseSelectedChannel(
selectedChannel: SelectedChannel | null,
requestModel: string,
): boolean {
if (!selectedChannel) return false;
const selectedModel = asTrimmedString(selectedChannel.actualModel).toLowerCase();
const normalizedRequestModel = asTrimmedString(requestModel).toLowerCase();
if (!selectedModel || !normalizedRequestModel) return true;
return selectedModel === normalizedRequestModel;
}
function deriveCodexExplicitSessionId(body: Record<string, unknown>, sessionId: string): string {
const promptCacheKey = asTrimmedString(body.prompt_cache_key);
return promptCacheKey || sessionId;
}
function parseJsonObject(raw: RawData): Record<string, unknown> | null {
try {
const parsed = JSON.parse(String(raw));
@@ -232,6 +325,77 @@ function collectResponsesOutput(payloads: unknown[]): unknown[] {
.map(([, value]) => value);
}
async function forwardResponsesRequestViaHttp(input: {
app: FastifyInstance;
socket: WebSocket;
request: IncomingMessage;
payload: Record<string, unknown>;
preserveIncrementalMode: boolean;
}): Promise<unknown[] | null> {
const response = await input.app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
...buildInjectHeaders(input.request),
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
...(input.preserveIncrementalMode ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
},
payload: input.payload,
});
if (response.statusCode < 200 || response.statusCode >= 300) {
let payload: unknown = null;
try {
payload = JSON.parse(response.body);
} catch {
payload = null;
}
writeResponsesWebsocketError(
input.socket,
response.statusCode,
response.statusMessage || 'Upstream error',
payload,
);
return null;
}
const contentType = String(response.headers['content-type'] || '').toLowerCase();
if (!contentType.includes('text/event-stream')) {
try {
const payload = JSON.parse(response.body);
input.socket.send(JSON.stringify(payload));
return isRecord(payload?.response) && Array.isArray(payload.response.output)
? cloneJsonObject(payload.response.output)
: [];
} catch {
writeResponsesWebsocketError(input.socket, 502, 'Unexpected non-JSON websocket proxy response');
return null;
}
}
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;
}
input.socket.send(JSON.stringify(payload));
} catch {
// Ignore malformed SSE frames; the HTTP route already normalizes them.
}
}
if (!sawTerminalPayload) {
writeResponsesWebsocketError(input.socket, 408, 'stream closed before response.completed');
}
return collectResponsesOutput(forwardedPayloads);
}
function buildInjectHeaders(request: IncomingMessage): Record<string, string | string[]> {
const headers: Record<string, string | string[]> = {};
for (const [rawKey, rawValue] of Object.entries(request.headers)) {
@@ -262,7 +426,7 @@ async function supportsResponsesWebsocketIncrementalInput(
try {
const selected = await tokenRouter.previewSelectedChannel(requestModel);
return asTrimmedString(selected?.site?.platform).toLowerCase() === 'codex';
return selectedChannelSupportsIncrementalInput(selected, requestModel);
} catch {
return false;
}
@@ -273,8 +437,16 @@ async function handleResponsesWebsocketConnection(
socket: WebSocket,
request: IncomingMessage,
) {
const websocketSessionId = headerValueToTrimmedString(request.headers['session_id'])
|| headerValueToTrimmedString(request.headers['session-id'])
|| randomUUID();
let lastRequest: Record<string, unknown> | null = null;
let lastResponseOutput: unknown[] = [];
let selectedChannel: SelectedChannel | null = null;
socket.once('close', () => {
void codexWebsocketRuntime.closeSession(websocketSessionId);
});
socket.on('message', async (raw) => {
const parsed = parseJsonObject(raw);
@@ -283,7 +455,9 @@ async function handleResponsesWebsocketConnection(
return;
}
const supportsIncrementalInput = await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest);
const requestModel = asTrimmedString(parsed.model) || asTrimmedString(lastRequest?.model);
const supportsIncrementalInput = selectedChannelSupportsIncrementalInput(selectedChannel, requestModel)
|| await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest);
const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally(
parsed,
lastRequest,
@@ -309,67 +483,95 @@ async function handleResponsesWebsocketConnection(
return;
}
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
...buildInjectHeaders(request),
if (!shouldReuseSelectedChannel(selectedChannel, requestModel)) {
selectedChannel = requestModel
? await tokenRouter.selectChannel(requestModel)
: null;
}
const codexWebsocketChannel = selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel)
? selectedChannel
: null;
if (codexWebsocketChannel) {
const downstreamHeaders: Record<string, unknown> = {
...(request.headers as Record<string, unknown>),
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
},
payload: normalized.request,
};
const providerHeaders = buildOauthProviderHeaders({
extraConfig: typeof codexWebsocketChannel.account.extraConfig === 'string'
? codexWebsocketChannel.account.extraConfig
: null,
downstreamHeaders,
});
const prepared = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: asTrimmedString(codexWebsocketChannel.actualModel) || requestModel,
stream: true,
tokenValue: codexWebsocketChannel.tokenValue,
sitePlatform: codexWebsocketChannel.site.platform,
siteUrl: codexWebsocketChannel.site.url,
openaiBody: normalized.request,
downstreamFormat: 'responses',
responsesOriginalBody: normalized.request,
downstreamHeaders,
providerHeaders,
codexExplicitSessionId: deriveCodexExplicitSessionId(normalized.request, websocketSessionId),
});
const requestUrl = `${codexWebsocketChannel.site.url.replace(/\/+$/, '')}${prepared.path}`;
if (response.statusCode < 200 || response.statusCode >= 300) {
let payload: unknown = null;
try {
payload = JSON.parse(response.body);
} catch {
payload = null;
const runtimeResult = await codexWebsocketRuntime.sendRequest({
sessionId: websocketSessionId,
requestUrl,
headers: prepared.headers,
body: prepared.body,
});
lastResponseOutput = collectResponsesOutput(runtimeResult.events);
for (const payload of runtimeResult.events) {
socket.send(JSON.stringify(payload));
}
} catch (error) {
const runtimeError = error instanceof CodexWebsocketRuntimeError
? error
: new CodexWebsocketRuntimeError('upstream websocket request failed');
if (runtimeError.status === 426) {
const fallbackOutput = await forwardResponsesRequestViaHttp({
app,
socket,
request,
payload: normalized.request,
preserveIncrementalMode: false,
});
if (fallbackOutput) {
lastResponseOutput = fallbackOutput;
}
return;
}
lastResponseOutput = collectResponsesOutput(runtimeError.events);
for (const payload of runtimeError.events) {
socket.send(JSON.stringify(payload));
}
writeResponsesWebsocketError(
socket,
response.statusCode,
response.statusMessage || 'Upstream error',
payload,
runtimeError.status || 408,
runtimeError.message,
runtimeError.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');
const forwardedOutput = await forwardResponsesRequestViaHttp({
app,
socket,
request,
payload: normalized.request,
preserveIncrementalMode: supportsIncrementalInput,
});
if (forwardedOutput) {
lastResponseOutput = forwardedOutput;
}
});
}
@@ -394,6 +596,7 @@ export function ensureResponsesWebsocketTransport(app: FastifyInstance) {
});
app.addHook('onClose', async () => {
await codexWebsocketRuntime.closeAllSessions();
await new Promise<void>((resolve) => {
websocketServer.close(() => resolve());
});
+3
View File
@@ -54,6 +54,9 @@ vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
hasProxyLogBillingDetailsColumn: async () => false,
hasProxyLogClientColumns: async () => false,
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
schema: {
proxyLogs: {},
},
+59 -13
View File
@@ -1,6 +1,5 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { fetch } from 'undici';
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 +12,8 @@ import { composeProxyLogMessage } from './logPathMeta.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { getProxyAuthContext } from '../../middleware/auth.js';
import { buildUpstreamUrl } from './upstreamUrl.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
const MAX_RETRIES = 2;
const DEFAULT_SEARCH_MODEL = '__search';
@@ -55,8 +56,12 @@ 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 downstreamPath = '/v1/search';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -101,8 +106,23 @@ 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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
clientContext,
downstreamPath,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -126,13 +146,28 @@ export async function searchProxyRoute(app: FastifyInstance) {
try { data = JSON.parse(text); } catch { data = { data: [] }; }
const latency = Date.now() - startTime;
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
tokenRouter.recordSuccess(selected.channel.id, latency, 0, selected.actualModel);
recordDownstreamCostUsage(request, 0);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
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, logDownstreamApiKeyId ? downstreamApiKeyId : null);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: error?.message || 'network error',
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
error?.message || 'network error',
retryCount,
downstreamApiKeyId,
clientContext,
downstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -158,14 +193,16 @@ async function logProxy(
errorMessage: string | null,
retryCount: number,
downstreamApiKeyId: number | null = null,
clientContext: DownstreamClientContext | null = null,
downstreamPath = '/v1/search',
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
await db.insert(schema.proxyLogs).values({
await insertProxyLog({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
downstreamApiKeyId,
modelRequested,
modelActual: selected.actualModel,
status,
@@ -176,12 +213,21 @@ async function logProxy(
totalTokens: 0,
estimatedCost: 0,
errorMessage: composeProxyLogMessage({
downstreamPath: '/v1/search',
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
errorMessage,
}),
clientFamily: clientContext?.clientKind || null,
clientAppId: clientContext?.clientAppId || null,
clientAppName: clientContext?.clientAppName || null,
clientConfidence: clientContext?.clientConfidence || null,
retryCount,
createdAt,
}).run();
});
} catch (error) {
console.warn('[proxy/search] failed to write proxy log', error);
}
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown): Promise<any> => null);
@@ -11,6 +12,9 @@ import {
buildUpstreamEndpointRequest,
isUnsupportedMediaTypeError,
isEndpointDowngradeError,
recordUpstreamEndpointFailure,
recordUpstreamEndpointSuccess,
resetUpstreamEndpointRuntimeState,
resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js';
@@ -32,6 +36,18 @@ describe('resolveUpstreamEndpointCandidates', () => {
beforeEach(() => {
fetchModelPricingCatalogMock.mockReset();
fetchModelPricingCatalogMock.mockResolvedValue(null);
resetUpstreamEndpointRuntimeState();
(config as any).codexHeaderDefaults = {
userAgent: '',
betaFeatures: '',
};
(config as any).payloadRules = {
default: [],
defaultRaw: [],
override: [],
overrideRaw: [],
filter: [],
};
});
it('uses downstream-aligned endpoint priority for unknown platforms', async () => {
@@ -180,6 +196,129 @@ describe('resolveUpstreamEndpointCandidates', () => {
expect(order).toEqual(['responses', 'messages', 'chat']);
});
it('remembers the last successful endpoint per site capability profile', async () => {
recordUpstreamEndpointSuccess({
siteId: baseContext.site.id,
endpoint: 'responses',
downstreamFormat: 'openai',
modelName: 'gpt-5.3',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
);
expect(order).toEqual(['responses', 'chat', 'messages']);
});
it('keeps remote-document-url requests on a separate runtime preference bucket from inline document requests', async () => {
recordUpstreamEndpointSuccess({
siteId: baseContext.site.id,
endpoint: 'chat',
downstreamFormat: 'openai',
modelName: 'gpt-5.3',
requestCapabilities: {
hasNonImageFileInput: true,
conversationFileSummary: {
hasImage: false,
hasAudio: false,
hasDocument: true,
hasRemoteDocumentUrl: false,
},
},
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
undefined,
{
hasNonImageFileInput: true,
conversationFileSummary: {
hasImage: false,
hasAudio: false,
hasDocument: true,
hasRemoteDocumentUrl: true,
},
},
);
expect(order).toEqual(['responses', 'messages', 'chat']);
});
it('does not remember messages fallback success for generic /v1/responses requests', async () => {
recordUpstreamEndpointSuccess({
siteId: baseContext.site.id,
endpoint: 'messages',
downstreamFormat: 'responses',
modelName: 'gpt-5.3',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'responses',
);
expect(order).toEqual(['responses', 'chat', 'messages']);
});
it('does not block generic /v1/responses endpoints on transient upstream errors', async () => {
recordUpstreamEndpointFailure({
siteId: baseContext.site.id,
endpoint: 'responses',
downstreamFormat: 'responses',
modelName: 'gpt-5.3',
status: 504,
errorText: '{"error":{"message":"Gateway time-out","type":"upstream_error"}}',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'responses',
);
expect(order).toEqual(['responses', 'chat', 'messages']);
});
it('learns a better endpoint from explicit upstream protocol errors', async () => {
recordUpstreamEndpointFailure({
siteId: baseContext.site.id,
endpoint: 'chat',
downstreamFormat: 'openai',
modelName: 'gpt-5.3',
status: 400,
errorText: 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
);
expect(order).toEqual(['responses', 'messages']);
});
it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
@@ -468,6 +607,136 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.body.prompt_cache_key).toBe('codex-cache-123');
});
it('applies configured codex header defaults with CLIProxyAPI-compatible precedence', () => {
(config as any).codexHeaderDefaults = {
userAgent: 'codex-config-ua/1.0',
betaFeatures: 'multi_agent',
};
const websocketRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: true,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello codex' }],
},
downstreamFormat: 'openai',
downstreamHeaders: {
'x-metapi-responses-websocket-transport': '1',
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(websocketRequest.headers['User-Agent']).toBe('codex-config-ua/1.0');
expect(websocketRequest.headers['x-codex-beta-features']).toBe('multi_agent');
const clientHeaderRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: true,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello again codex' }],
},
downstreamFormat: 'openai',
downstreamHeaders: {
'x-metapi-responses-websocket-transport': '1',
'user-agent': 'client-ua/2.0',
'x-codex-beta-features': 'client-beta',
},
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(clientHeaderRequest.headers['User-Agent']).toBe('codex-config-ua/1.0');
expect(clientHeaderRequest.headers['x-codex-beta-features']).toBe('client-beta');
const httpRequest = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'plain http codex' }],
},
downstreamFormat: 'openai',
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(httpRequest.headers['x-codex-beta-features']).toBeUndefined();
});
it('applies configured payload rules before preparing codex responses requests', () => {
(config as any).payloadRules = {
default: [
{
models: [{ name: 'gpt-*', protocol: 'codex' }],
params: {
'reasoning.effort': 'high',
},
},
],
defaultRaw: [],
override: [
{
models: [{ name: 'gpt-5.4', protocol: 'codex' }],
params: {
'text.verbosity': 'low',
},
},
],
overrideRaw: [],
filter: [
{
models: [{ name: 'gpt-5.4', protocol: 'codex' }],
params: ['safety_identifier'],
},
],
};
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'gpt-5.4',
stream: false,
tokenValue: 'oauth-access-token',
oauthProvider: 'codex',
sitePlatform: 'codex',
siteUrl: 'https://chatgpt.com/backend-api/codex',
openaiBody: {
model: 'gpt-5.4',
messages: [{ role: 'user', content: 'hello codex' }],
verbosity: 'high',
safety_identifier: 'drop-me',
},
downstreamFormat: 'openai',
providerHeaders: {
Originator: 'codex_cli_rs',
},
} as any);
expect(request.body.reasoning).toEqual({ effort: 'high' });
expect(request.body.text).toEqual({ verbosity: 'low' });
expect(request.body.safety_identifier).toBeUndefined();
});
it('builds gemini-cli native requests with project envelope and bearer headers', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
@@ -850,7 +1119,7 @@ describe('buildUpstreamEndpointRequest', () => {
]);
});
it('applies global responses standardization and drops non-standard fields', () => {
it('preserves unknown native responses fields while still normalizing known compatibility fields', () => {
const request = buildUpstreamEndpointRequest({
endpoint: 'responses',
modelName: 'upstream-gpt',
@@ -865,13 +1134,13 @@ describe('buildUpstreamEndpointRequest', () => {
input: 'hello',
metadata: { trace: 'abc123' },
max_completion_tokens: 512,
custom_vendor_flag: 'drop-me',
custom_vendor_flag: 'keep-me',
},
});
expect(request.path).toBe('/v1/responses');
expect(request.body.metadata).toEqual({ trace: 'abc123' });
expect(request.body.custom_vendor_flag).toBeUndefined();
expect(request.body.custom_vendor_flag).toBe('keep-me');
expect(request.body.max_completion_tokens).toBeUndefined();
expect(request.body.max_output_tokens).toBe(512);
expect(request.body.input).toEqual([
+330 -32
View File
@@ -1,6 +1,12 @@
import { createHash, randomUUID } from 'node:crypto';
import {
rankConversationFileEndpoints,
type ConversationFileInputSummary,
} from '../../proxy-core/capabilities/conversationFileCapabilities.js';
import { resolveProviderProfile } from '../../proxy-core/providers/registry.js';
import { config } from '../../config.js';
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
import { applyPayloadRules } from '../../services/payloadRules.js';
import type { DownstreamFormat } from '../../transformers/shared/normalized.js';
import {
convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaTransformer,
@@ -13,7 +19,7 @@ import {
import {
buildGeminiGenerateContentRequestFromOpenAi,
} from './geminiCliCompat.js';
export {
import {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
@@ -21,10 +27,31 @@ export {
promoteResponsesCandidateAfterLegacyChatError,
shouldPreferResponsesAfterLegacyChatError,
} from '../../transformers/shared/endpointCompatibility.js';
export {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
promoteResponsesCandidateAfterLegacyChatError,
shouldPreferResponsesAfterLegacyChatError,
};
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
export type EndpointPreference = DownstreamFormat | 'responses';
type EndpointCapabilityProfile = {
preferMessagesForClaudeModel: boolean;
hasNonImageFileInput: boolean;
hasRemoteDocumentUrl: boolean;
wantsNativeResponsesReasoning: boolean;
};
type EndpointRuntimeState = {
preferredEndpoint: UpstreamEndpoint | null;
preferredUpdatedAtMs: number;
blockedUntilMsByEndpoint: Partial<Record<UpstreamEndpoint, number>>;
};
type ChannelContext = {
site: {
id: number;
@@ -39,6 +66,10 @@ type ChannelContext = {
};
};
const ENDPOINT_RUNTIME_PREFERRED_TTL_MS = 24 * 60 * 60 * 1000;
const ENDPOINT_RUNTIME_BLOCK_TTL_MS = 6 * 60 * 60 * 1000;
const endpointRuntimeStates = new Map<string, EndpointRuntimeState>();
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -47,6 +78,20 @@ function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function resolveRequestedModelForPayloadRules(input: {
modelName: string;
openaiBody: Record<string, unknown>;
claudeOriginalBody?: Record<string, unknown>;
responsesOriginalBody?: Record<string, unknown>;
}): string {
return (
asTrimmedString(input.responsesOriginalBody?.model)
|| asTrimmedString(input.claudeOriginalBody?.model)
|| asTrimmedString(input.openaiBody.model)
|| asTrimmedString(input.modelName)
);
}
function normalizePlatformName(platform: unknown): string {
return asTrimmedString(platform).toLowerCase();
}
@@ -557,6 +602,225 @@ function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
return Array.from(normalized);
}
function buildEndpointCapabilityProfile(input?: {
modelName?: string;
requestedModelHint?: string;
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
};
}): EndpointCapabilityProfile {
return {
preferMessagesForClaudeModel: (
isClaudeFamilyModel(asTrimmedString(input?.modelName))
|| isClaudeFamilyModel(asTrimmedString(input?.requestedModelHint))
),
hasNonImageFileInput: (
input?.requestCapabilities?.conversationFileSummary?.hasDocument === true
|| input?.requestCapabilities?.hasNonImageFileInput === true
),
hasRemoteDocumentUrl: (
input?.requestCapabilities?.conversationFileSummary?.hasRemoteDocumentUrl === true
),
wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true,
};
}
function buildEndpointRuntimeStateKey(input: {
siteId: number;
downstreamFormat: EndpointPreference;
capabilityProfile: EndpointCapabilityProfile;
}): string {
const capabilityProfile = input.capabilityProfile;
return [
String(input.siteId),
input.downstreamFormat,
capabilityProfile.preferMessagesForClaudeModel ? 'claude' : 'generic',
capabilityProfile.hasNonImageFileInput ? 'files' : 'nofiles',
capabilityProfile.hasRemoteDocumentUrl ? 'remoteurl' : 'noremoteurl',
capabilityProfile.wantsNativeResponsesReasoning ? 'reasoning' : 'noreasoning',
].join(':');
}
function getOrCreateEndpointRuntimeState(key: string, nowMs = Date.now()): EndpointRuntimeState {
const existing = endpointRuntimeStates.get(key);
if (existing) return existing;
const initial: EndpointRuntimeState = {
preferredEndpoint: null,
preferredUpdatedAtMs: nowMs,
blockedUntilMsByEndpoint: {},
};
endpointRuntimeStates.set(key, initial);
return initial;
}
function maybeDeleteEndpointRuntimeState(key: string, nowMs = Date.now()): void {
const state = endpointRuntimeStates.get(key);
if (!state) return;
const hasActiveBlock = Object.values(state.blockedUntilMsByEndpoint).some((untilMs) => (
typeof untilMs === 'number' && untilMs > nowMs
));
const preferredFresh = (
!!state.preferredEndpoint
&& (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs
);
if (!hasActiveBlock && !preferredFresh) {
endpointRuntimeStates.delete(key);
}
}
function applyEndpointRuntimePreference(
candidates: UpstreamEndpoint[],
key: string,
nowMs = Date.now(),
): UpstreamEndpoint[] {
const state = endpointRuntimeStates.get(key);
if (!state || candidates.length <= 1) return candidates;
const blocked = new Set<UpstreamEndpoint>();
for (const endpoint of candidates) {
const untilMs = state.blockedUntilMsByEndpoint[endpoint];
if (typeof untilMs === 'number' && untilMs > nowMs) {
blocked.add(endpoint);
}
}
let next = candidates.filter((endpoint) => !blocked.has(endpoint));
if (next.length === 0) {
next = [...candidates];
}
const preferredFresh = (
!!state.preferredEndpoint
&& (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs
);
if (preferredFresh && state.preferredEndpoint && next.includes(state.preferredEndpoint)) {
next = [
state.preferredEndpoint,
...next.filter((endpoint) => endpoint !== state.preferredEndpoint),
];
}
maybeDeleteEndpointRuntimeState(key, nowMs);
return next;
}
function inferSuggestedEndpointFromError(errorText?: string | null): UpstreamEndpoint | null {
const text = (errorText || '').toLowerCase();
if (!text) return null;
if (text.includes('/v1/responses')) return 'responses';
if (text.includes('/v1/messages')) return 'messages';
if (text.includes('/v1/chat/completions')) return 'chat';
return null;
}
function shouldBlockEndpointByError(status: number, errorText?: string | null): boolean {
if (isEndpointDispatchDeniedError(status, errorText)) return true;
if (status === 404 || status === 405 || status === 415 || status === 501) return true;
if (isUnsupportedMediaTypeError(status, errorText)) return true;
const text = (errorText || '').toLowerCase();
return (
text.includes('convert_request_failed')
|| text.includes('endpoint_not_found')
|| text.includes('unknown_endpoint')
|| text.includes('unsupported_endpoint')
|| text.includes('unsupported_path')
|| text.includes('not_found_error')
|| text.includes('unsupported legacy protocol')
|| text.includes('please use /v1/')
|| text.includes('does not allow /v1/')
|| text.includes('unknown endpoint')
|| text.includes('unsupported endpoint')
|| text.includes('unsupported path')
|| text.includes('unrecognized request url')
|| text.includes('no route matched')
|| text.includes('does not exist')
);
}
function shouldRememberSuccessfulEndpoint(input: {
endpoint: UpstreamEndpoint;
downstreamFormat: EndpointPreference;
}): boolean {
if (input.downstreamFormat !== 'responses') return true;
return input.endpoint === 'responses';
}
export function resetUpstreamEndpointRuntimeState(): void {
endpointRuntimeStates.clear();
}
export function recordUpstreamEndpointSuccess(input: {
siteId: number;
endpoint: UpstreamEndpoint;
downstreamFormat: EndpointPreference;
modelName?: string;
requestedModelHint?: string;
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
};
}): void {
if (!shouldRememberSuccessfulEndpoint(input)) return;
const nowMs = Date.now();
const key = buildEndpointRuntimeStateKey({
siteId: input.siteId,
downstreamFormat: input.downstreamFormat,
capabilityProfile: buildEndpointCapabilityProfile({
modelName: input.modelName,
requestedModelHint: input.requestedModelHint,
requestCapabilities: input.requestCapabilities,
}),
});
const state = getOrCreateEndpointRuntimeState(key, nowMs);
state.preferredEndpoint = input.endpoint;
state.preferredUpdatedAtMs = nowMs;
delete state.blockedUntilMsByEndpoint[input.endpoint];
}
export function recordUpstreamEndpointFailure(input: {
siteId: number;
endpoint: UpstreamEndpoint;
downstreamFormat: EndpointPreference;
status: number;
errorText?: string | null;
modelName?: string;
requestedModelHint?: string;
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
};
}): void {
if (!shouldBlockEndpointByError(input.status, input.errorText)) return;
const nowMs = Date.now();
const key = buildEndpointRuntimeStateKey({
siteId: input.siteId,
downstreamFormat: input.downstreamFormat,
capabilityProfile: buildEndpointCapabilityProfile({
modelName: input.modelName,
requestedModelHint: input.requestedModelHint,
requestCapabilities: input.requestCapabilities,
}),
});
const state = getOrCreateEndpointRuntimeState(key, nowMs);
state.blockedUntilMsByEndpoint[input.endpoint] = nowMs + ENDPOINT_RUNTIME_BLOCK_TTL_MS;
const suggestedEndpoint = inferSuggestedEndpointFromError(input.errorText);
if (suggestedEndpoint && suggestedEndpoint !== input.endpoint) {
state.preferredEndpoint = suggestedEndpoint;
state.preferredUpdatedAtMs = nowMs;
delete state.blockedUntilMsByEndpoint[suggestedEndpoint];
}
}
function preferredEndpointOrder(
downstreamFormat: EndpointPreference,
sitePlatform?: string,
@@ -626,27 +890,44 @@ export async function resolveUpstreamEndpointCandidates(
requestedModelHint?: string,
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
},
): Promise<UpstreamEndpoint[]> {
const sitePlatform = normalizePlatformName(context.site.platform);
const preferMessagesForClaudeModel = (
isClaudeFamilyModel(modelName)
|| isClaudeFamilyModel(asTrimmedString(requestedModelHint))
const capabilityProfile = buildEndpointCapabilityProfile({
modelName,
requestedModelHint,
requestCapabilities,
});
const preferMessagesForClaudeModel = capabilityProfile.preferMessagesForClaudeModel;
const hasNonImageFileInput = capabilityProfile.hasNonImageFileInput;
const wantsNativeResponsesReasoning = capabilityProfile.wantsNativeResponsesReasoning;
const runtimeStateKey = buildEndpointRuntimeStateKey({
siteId: context.site.id,
downstreamFormat,
capabilityProfile,
});
const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => (
applyEndpointRuntimePreference(candidates, runtimeStateKey)
);
const hasNonImageFileInput = requestCapabilities?.hasNonImageFileInput === true;
const wantsNativeResponsesReasoning = requestCapabilities?.wantsNativeResponsesReasoning === true;
const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? {
hasImage: false,
hasAudio: false,
hasDocument: hasNonImageFileInput,
hasRemoteDocumentUrl: false,
};
if (sitePlatform === 'anyrouter') {
// anyrouter deployments are effectively anthropic-protocol first.
if (hasNonImageFileInput) {
return downstreamFormat === 'responses'
return applyRuntimePreference(downstreamFormat === 'responses'
? ['responses', 'messages', 'chat']
: ['messages', 'responses', 'chat'];
: ['messages', 'responses', 'chat']);
}
if (downstreamFormat === 'responses') {
return ['responses', 'messages', 'chat'];
return applyRuntimePreference(['responses', 'messages', 'chat']);
}
return ['messages', 'chat', 'responses'];
return applyRuntimePreference(['messages', 'chat', 'responses']);
}
const preferred = preferredEndpointOrder(
@@ -659,8 +940,14 @@ 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[];
return rankConversationFileEndpoints({
sitePlatform,
requestedOrder: preferMessagesForClaudeModel
? ['messages', 'responses', 'chat']
: ['responses', 'messages', 'chat'],
summary: conversationFileSummary,
preferMessagesForClaudeModel,
});
})()
: preferred;
const prioritizedPreferredEndpoints: UpstreamEndpoint[] = (
@@ -699,20 +986,20 @@ export async function resolveUpstreamEndpointCandidates(
});
if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) {
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
const matched = catalog.models.find((item) =>
asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(),
);
if (!matched) return prioritizedPreferredEndpoints;
if (!matched) return applyRuntimePreference(prioritizedPreferredEndpoints);
const shouldIgnoreCatalogOrderingForClaudeMessages = (
preferMessagesForClaudeModel
&& (downstreamFormat !== 'responses' || sitePlatform !== 'openai')
);
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
@@ -732,7 +1019,7 @@ export async function resolveUpstreamEndpointCandidates(
if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) {
// Generic labels like openai/anthropic are too coarse for Claude models;
// keep messages-first order in this case.
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
const supported = new Set<UpstreamEndpoint>();
@@ -743,19 +1030,19 @@ export async function resolveUpstreamEndpointCandidates(
}
}
if (supported.size === 0) return prioritizedPreferredEndpoints;
if (supported.size === 0) return applyRuntimePreference(prioritizedPreferredEndpoints);
const firstSupported = prioritizedPreferredEndpoints.find((endpoint) => supported.has(endpoint));
if (!firstSupported) return prioritizedPreferredEndpoints;
if (!firstSupported) return applyRuntimePreference(prioritizedPreferredEndpoints);
// Catalog metadata can be incomplete/inaccurate, so only use it to pick
// the first attempt. Keep downstream-driven fallback order unchanged.
return [
return applyRuntimePreference([
firstSupported,
...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported),
];
]);
} catch {
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
}
@@ -885,6 +1172,16 @@ export function buildUpstreamEndpointRequest(input: {
stream: input.stream,
oauthProjectId: asTrimmedString(input.oauthProjectId) || null,
};
const requestedModelForPayloadRules = resolveRequestedModelForPayloadRules(input);
const applyConfiguredPayloadRules = <T extends Record<string, unknown>>(body: T): T => (
applyPayloadRules({
rules: config.payloadRules,
payload: body,
modelName: input.modelName,
requestedModel: requestedModelForPayloadRules,
protocol: sitePlatform,
}) as T
);
if (isInternalGeminiUpstream) {
const instructions = (
@@ -898,6 +1195,7 @@ export function buildUpstreamEndpointRequest(input: {
modelName: input.modelName,
instructions,
});
const configuredGeminiRequest = applyConfiguredPayloadRules(geminiRequest);
if (!providerProfile) {
throw new Error(`missing provider profile for platform: ${sitePlatform}`);
}
@@ -911,7 +1209,7 @@ export function buildUpstreamEndpointRequest(input: {
sitePlatform,
baseHeaders: commonHeaders,
providerHeaders: input.providerHeaders,
body: geminiRequest,
body: configuredGeminiRequest,
action: input.stream ? 'streamGenerateContent' : 'generateContent',
});
}
@@ -952,6 +1250,7 @@ export function buildUpstreamEndpointRequest(input: {
?? sanitizeAnthropicMessagesBody(
convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream),
);
const configuredClaudeBody = applyConfiguredPayloadRules(sanitizedBody);
if (providerProfile?.id === 'claude') {
return providerProfile.prepareRequest({
@@ -964,7 +1263,7 @@ export function buildUpstreamEndpointRequest(input: {
sitePlatform,
baseHeaders: commonHeaders,
claudeHeaders,
body: sanitizedBody,
body: configuredClaudeBody,
});
}
@@ -980,7 +1279,7 @@ export function buildUpstreamEndpointRequest(input: {
return {
path: resolveEndpointPath('messages'),
headers,
body: sanitizedBody,
body: configuredClaudeBody,
runtime,
};
}
@@ -1015,6 +1314,7 @@ export function buildUpstreamEndpointRequest(input: {
),
sitePlatform,
);
const configuredResponsesBody = applyConfiguredPayloadRules(body);
if (providerProfile?.id === 'codex') {
return providerProfile.prepareRequest({
@@ -1032,7 +1332,7 @@ export function buildUpstreamEndpointRequest(input: {
providerHeaders: input.providerHeaders,
codexSessionCacheKey: input.codexSessionCacheKey,
codexExplicitSessionId: input.codexExplicitSessionId,
body,
body: configuredResponsesBody,
});
}
@@ -1055,15 +1355,15 @@ export function buildUpstreamEndpointRequest(input: {
: null;
const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex'
&& !!codexSessionId
&& !asTrimmedString((body as Record<string, unknown>).prompt_cache_key)
&& !asTrimmedString((configuredResponsesBody as Record<string, unknown>).prompt_cache_key)
&& !asTrimmedString(input.codexExplicitSessionId)
&& !!asTrimmedString(input.codexSessionCacheKey);
const runtimeBody = shouldInjectDerivedPromptCacheKey
? {
...body,
...configuredResponsesBody,
prompt_cache_key: codexSessionId,
}
: body;
: configuredResponsesBody;
return {
path: resolveEndpointPath('responses'),
@@ -1077,11 +1377,11 @@ export function buildUpstreamEndpointRequest(input: {
return {
path: resolveEndpointPath('chat'),
headers,
body: {
body: applyConfiguredPayloadRules({
...openaiBody,
model: input.modelName,
stream: input.stream,
},
}),
runtime,
};
}
@@ -1149,5 +1449,3 @@ export function buildClaudeCountTokensUpstreamRequest(input: {
},
};
}
+3 -3
View File
@@ -102,7 +102,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
const upstream = await fetch(targetUrl, requestInit);
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, selected.actualModel);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -155,11 +155,11 @@ export async function videosProxyRoute(app: FastifyInstance) {
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId));
} catch (error: any) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, selected.actualModel);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
+22 -2
View File
@@ -1,7 +1,27 @@
import { describe, expect, it, vi } from 'vitest';
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { runSqliteMigrations } from './db/migrate.js';
import {
__runtimeDatabaseBootstrapTestUtils,
ensureRuntimeDatabaseReady,
runSqliteRuntimeMigrations,
} from './runtimeDatabaseBootstrap.js';
vi.mock('./db/migrate.js', () => ({
runSqliteMigrations: vi.fn(),
}));
describe('runtimeDatabaseBootstrap', () => {
beforeEach(() => {
__runtimeDatabaseBootstrapTestUtils.resetSqliteMigrationsBootstrapped();
vi.clearAllMocks();
});
it('runs sqlite migrations on the first runtime bootstrap call', async () => {
await runSqliteRuntimeMigrations();
expect(runSqliteMigrations).toHaveBeenCalledTimes(1);
});
it('runs sqlite runtime migrations when dialect is sqlite', async () => {
const runSqliteRuntimeMigrations = vi.fn(async () => {});
const ensureExternalRuntimeSchema = vi.fn(async () => {});
+3 -4
View File
@@ -7,12 +7,11 @@ let sqliteMigrationsBootstrapped = false;
export async function runSqliteRuntimeMigrations(): Promise<void> {
const migrateModule = await import('./db/migrate.js');
if (sqliteMigrationsBootstrapped) {
migrateModule.runSqliteMigrations();
return;
}
if (!sqliteMigrationsBootstrapped) {
sqliteMigrationsBootstrapped = true;
}
migrateModule.runSqliteMigrations();
}
type EnsureRuntimeDatabaseReadyInput = {
dialect: RuntimeSchemaDialect;
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
buildStoredSub2ApiSubscriptionSummary,
getCredentialModeFromExtraConfig,
getPlatformUserIdFromExtraConfig,
getProxyUrlFromExtraConfig,
getSub2ApiAuthFromExtraConfig,
getSub2ApiSubscriptionFromExtraConfig,
guessPlatformUserIdFromUsername,
mergeAccountExtraConfig,
normalizeCredentialMode,
@@ -120,4 +122,37 @@ describe('accountExtraConfig', () => {
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe(true);
});
it('parses stored sub2api subscription summary from extra config', () => {
const extraConfig = mergeAccountExtraConfig(null, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
activeCount: 1,
totalUsedUsd: 3.5,
subscriptions: [
{
id: 7,
groupName: 'Pro',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.5,
monthlyLimitUsd: 20,
},
],
}, 1760000000000),
});
expect(getSub2ApiSubscriptionFromExtraConfig(extraConfig)).toEqual({
activeCount: 1,
totalUsedUsd: 3.5,
subscriptions: [
{
id: 7,
groupName: 'Pro',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.5,
monthlyLimitUsd: 20,
},
],
updatedAt: 1760000000000,
});
});
});
+144
View File
@@ -1,3 +1,5 @@
import type { SubscriptionPlanSummary, SubscriptionSummary } from './platforms/base.js';
type AutoReloginConfig = {
username?: unknown;
passwordCipher?: unknown;
@@ -9,6 +11,13 @@ type Sub2ApiAuthConfig = {
tokenExpiresAt?: unknown;
};
type Sub2ApiSubscriptionConfig = {
updatedAt?: unknown;
activeCount?: unknown;
totalUsedUsd?: unknown;
subscriptions?: unknown;
};
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
@@ -26,6 +35,7 @@ type AccountExtraConfig = {
};
autoRelogin?: AutoReloginConfig;
sub2apiAuth?: Sub2ApiAuthConfig;
sub2apiSubscription?: Sub2ApiSubscriptionConfig;
[key: string]: unknown;
};
@@ -64,6 +74,39 @@ function normalizeTimestampMs(raw: unknown): number | undefined {
return undefined;
}
function normalizeNonNegativeNumber(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
return Math.round(raw * 1_000_000) / 1_000_000;
}
if (typeof raw === 'string') {
const parsed = Number(raw.trim());
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.round(parsed * 1_000_000) / 1_000_000;
}
}
return undefined;
}
function normalizeIsoDateTime(raw: unknown): string | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
return new Date(ms).toISOString();
}
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
if (Number.isFinite(numeric) && numeric > 0) {
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
return new Date(ms).toISOString();
}
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
return undefined;
}
export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined {
if (typeof raw !== 'string') return undefined;
const normalized = raw.trim().toLowerCase();
@@ -134,6 +177,10 @@ export type ManagedSub2ApiAuth = {
tokenExpiresAt?: number;
};
export type StoredSub2ApiSubscriptionSummary = SubscriptionSummary & {
updatedAt: number;
};
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
const parsed = parseExtraConfig(extraConfig);
const raw = parsed.sub2apiAuth;
@@ -146,6 +193,103 @@ export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): Mana
: { refreshToken };
}
function normalizeSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const item = raw as Record<string, unknown>;
const normalized: SubscriptionPlanSummary = {};
const id = normalizeUserId(item.id);
if (id) normalized.id = id;
const groupId = normalizeUserId(item.groupId ?? item.group_id);
if (groupId) normalized.groupId = groupId;
const groupName = normalizeNonEmptyString(item.groupName ?? item.group_name);
if (groupName) normalized.groupName = groupName;
const status = normalizeNonEmptyString(item.status);
if (status) normalized.status = status;
const expiresAt = normalizeIsoDateTime(
item.expiresAt
?? item.expires_at
?? item.expiredAt
?? item.expired_at
?? item.endAt
?? item.end_at,
);
if (expiresAt) normalized.expiresAt = expiresAt;
const dailyUsedUsd = normalizeNonNegativeNumber(item.dailyUsedUsd ?? item.daily_used_usd);
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
const dailyLimitUsd = normalizeNonNegativeNumber(item.dailyLimitUsd ?? item.daily_limit_usd);
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
const weeklyUsedUsd = normalizeNonNegativeNumber(item.weeklyUsedUsd ?? item.weekly_used_usd);
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
const weeklyLimitUsd = normalizeNonNegativeNumber(item.weeklyLimitUsd ?? item.weekly_limit_usd);
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
const monthlyUsedUsd = normalizeNonNegativeNumber(item.monthlyUsedUsd ?? item.monthly_used_usd);
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
const monthlyLimitUsd = normalizeNonNegativeNumber(item.monthlyLimitUsd ?? item.monthly_limit_usd);
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
return Object.keys(normalized).length > 0 ? normalized : null;
}
function normalizeSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => normalizeSubscriptionItem(item))
.filter((item): item is SubscriptionPlanSummary => !!item);
}
export function normalizeSub2ApiSubscriptionSummary(
raw: unknown,
): StoredSub2ApiSubscriptionSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const body = raw as Record<string, unknown>;
const subscriptions = normalizeSubscriptionItems(body.subscriptions);
const activeCount = normalizeNonNegativeNumber(body.activeCount ?? body.active_count);
const totalUsedUsd = normalizeNonNegativeNumber(body.totalUsedUsd ?? body.total_used_usd);
const updatedAt = normalizeTimestampMs(body.updatedAt ?? body.updated_at);
return {
activeCount: Math.trunc(activeCount ?? subscriptions.length),
totalUsedUsd: totalUsedUsd ?? 0,
subscriptions,
updatedAt: updatedAt ?? Date.now(),
};
}
export function buildStoredSub2ApiSubscriptionSummary(
summary: SubscriptionSummary,
updatedAt = Date.now(),
): StoredSub2ApiSubscriptionSummary {
return normalizeSub2ApiSubscriptionSummary({
...summary,
updatedAt,
}) || {
activeCount: Math.max(0, Math.trunc(summary.activeCount || 0)),
totalUsedUsd: normalizeNonNegativeNumber(summary.totalUsedUsd) ?? 0,
subscriptions: normalizeSubscriptionItems(summary.subscriptions),
updatedAt,
};
}
export function getSub2ApiSubscriptionFromExtraConfig(
extraConfig?: string | null,
): StoredSub2ApiSubscriptionSummary | null {
const parsed = parseExtraConfig(extraConfig);
return normalizeSub2ApiSubscriptionSummary(parsed.sub2apiSubscription);
}
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
const text = (username || '').trim();
if (!text) return undefined;
+250
View File
@@ -596,4 +596,254 @@ describe('backupService', () => {
expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com');
expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id');
});
it('exports configured backup payload to webdav and records sync state', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValue(new Response(null, { status: 201 }));
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi-preferences.json',
username: 'alice',
password: 'secret-pass',
exportType: 'preferences',
autoSyncEnabled: false,
autoSyncCron: '0 * * * *',
}),
}).run();
await db.insert(schema.settings).values({
key: 'ui_locale',
value: JSON.stringify('zh-CN'),
}).run();
const result = await (backupService as any).exportBackupToWebdav();
expect(result).toMatchObject({
success: true,
fileUrl: 'https://dav.example.com/backups/metapi-preferences.json',
exportType: 'preferences',
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
const [targetUrl, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
expect(targetUrl).toBe('https://dav.example.com/backups/metapi-preferences.json');
expect(init.method).toBe('PUT');
expect(init.headers).toMatchObject({
Authorization: `Basic ${Buffer.from('alice:secret-pass').toString('base64')}`,
'Content-Type': 'application/json',
});
const payload = JSON.parse(String(init.body));
expect(payload.type).toBe('preferences');
expect(payload.preferences.settings).toEqual(
expect.arrayContaining([
expect.objectContaining({ key: 'ui_locale', value: 'zh-CN' }),
]),
);
const syncState = await db.select().from(schema.settings).where(eq(schema.settings.key, 'backup_webdav_state_v1')).get();
expect(syncState?.value).toContain('"lastSyncAt"');
expect(syncState?.value).toContain('"lastError":null');
fetchSpy.mockRestore();
});
it('imports backup payload from webdav into local data', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const remotePayload = {
version: '2.0',
timestamp: Date.now(),
accounts: {
sites: [
{
id: 1,
name: 'remote-site',
url: 'https://remote.example.com',
platform: 'new-api',
externalCheckinUrl: null,
proxyUrl: null,
useSystemProxy: false,
customHeaders: null,
status: 'active',
isPinned: false,
sortOrder: 0,
globalWeight: 1,
apiKey: null,
createdAt: '2026-03-20T00:00:00.000Z',
updatedAt: '2026-03-20T00:00:00.000Z',
},
],
accounts: [
{
id: 1,
siteId: 1,
username: 'remote-user',
accessToken: 'remote-session',
apiToken: null,
oauthProvider: null,
oauthAccountKey: null,
oauthProjectId: null,
balance: 0,
balanceUsed: 0,
quota: 0,
unitCost: null,
valueScore: 0,
status: 'active',
isPinned: false,
sortOrder: 0,
checkinEnabled: false,
lastCheckinAt: null,
lastBalanceRefresh: null,
extraConfig: null,
createdAt: '2026-03-20T00:00:00.000Z',
updatedAt: '2026-03-20T00:00:00.000Z',
},
],
accountTokens: [],
tokenRoutes: [],
routeChannels: [],
routeGroupSources: [],
},
};
fetchSpy.mockResolvedValue(new Response(JSON.stringify(remotePayload), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi-all.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 * * * *',
}),
}).run();
const result = await (backupService as any).importBackupFromWebdav();
expect(result.success).toBe(true);
expect(result.sections.accounts).toBe(true);
const sites = await db.select().from(schema.sites).all();
const accounts = await db.select().from(schema.accounts).all();
expect(sites).toHaveLength(1);
expect(accounts).toHaveLength(1);
expect(accounts[0].username).toBe('remote-user');
expect(fetchSpy).toHaveBeenCalledWith(
'https://dav.example.com/backups/metapi-all.json',
expect.objectContaining({
method: 'GET',
}),
);
fetchSpy.mockRestore();
});
it('times out stalled webdav export requests', async () => {
vi.useFakeTimers();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation((_, init) => {
const signal = init?.signal as AbortSignal | undefined;
if (!signal) {
return Promise.reject(new Error('missing abort signal')) as Promise<Response>;
}
return new Promise<Response>((_, reject) => {
signal.addEventListener('abort', () => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
});
});
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
}),
}).run();
const exportAssertion = expect(backupService.exportBackupToWebdav()).rejects.toThrow('WebDAV 请求超时');
await vi.advanceTimersByTimeAsync(16_000);
await exportAssertion;
} finally {
fetchSpy.mockRestore();
vi.useRealTimers();
}
});
it('times out stalled webdav import requests', async () => {
vi.useFakeTimers();
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation((_, init) => {
const signal = init?.signal as AbortSignal | undefined;
if (!signal) {
return Promise.reject(new Error('missing abort signal')) as Promise<Response>;
}
return new Promise<Response>((_, reject) => {
signal.addEventListener('abort', () => {
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
}, { once: true });
});
});
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'https://dav.example.com/backups/metapi.json',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: false,
autoSyncCron: '0 */6 * * *',
}),
}).run();
const importAssertion = expect(backupService.importBackupFromWebdav()).rejects.toThrow('WebDAV 请求超时');
await vi.advanceTimersByTimeAsync(16_000);
await importAssertion;
} finally {
fetchSpy.mockRestore();
vi.useRealTimers();
}
});
it('does not schedule malformed imported webdav config', async () => {
const cronModule = await import('node-cron');
const scheduleSpy = vi.spyOn(cronModule.default, 'schedule');
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
try {
await db.insert(schema.settings).values({
key: 'backup_webdav_config_v1',
value: JSON.stringify({
enabled: true,
fileUrl: 'not-a-valid-url',
username: 'alice',
password: 'secret-pass',
exportType: 'all',
autoSyncEnabled: true,
autoSyncCron: '0 */6 * * *',
}),
}).run();
await expect(backupService.reloadBackupWebdavScheduler()).resolves.toBeUndefined();
expect(scheduleSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid config'));
} finally {
backupService.__resetBackupWebdavSchedulerForTests();
scheduleSpy.mockRestore();
warnSpy.mockRestore();
}
});
});
+323 -1
View File
@@ -1,4 +1,4 @@
import { asc } from 'drizzle-orm';
import { asc, eq } from 'drizzle-orm';
import cron from 'node-cron';
import { db, schema } from '../db/index.js';
import { upsertSetting } from '../db/upsertSetting.js';
@@ -9,6 +9,32 @@ const BACKUP_VERSION = '2.0';
export type BackupExportType = 'all' | 'accounts' | 'preferences';
export interface BackupWebdavConfig {
enabled: boolean;
fileUrl: string;
username: string;
password: string;
exportType: BackupExportType;
autoSyncEnabled: boolean;
autoSyncCron: string;
}
export interface BackupWebdavConfigView {
enabled: boolean;
fileUrl: string;
username: string;
exportType: BackupExportType;
autoSyncEnabled: boolean;
autoSyncCron: string;
hasPassword: boolean;
passwordMasked: string;
}
export interface BackupWebdavState {
lastSyncAt: string | null;
lastError: string | null;
}
type SiteRow = typeof schema.sites.$inferSelect;
type AccountRow = typeof schema.accounts.$inferSelect;
type AccountTokenRow = typeof schema.accountTokens.$inferSelect;
@@ -76,6 +102,11 @@ const EXCLUDED_SETTING_KEYS = new Set<string>([
// Keep current admin login credential unchanged to avoid accidental lock-out.
'auth_token',
]);
const BACKUP_WEBDAV_CONFIG_SETTING_KEY = 'backup_webdav_config_v1';
const BACKUP_WEBDAV_STATE_SETTING_KEY = 'backup_webdav_state_v1';
const BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON = '0 */6 * * *';
const BACKUP_WEBDAV_FETCH_TIMEOUT_MS = 15_000;
let backupWebdavTask: cron.ScheduledTask | null = null;
const DIRECT_API_PLATFORMS = new Set([
'openai',
@@ -722,6 +753,131 @@ function stringifySettingValue(value: unknown): string {
return JSON.stringify(value);
}
function isValidBackupExportType(value: unknown): value is BackupExportType {
return value === 'all' || value === 'accounts' || value === 'preferences';
}
function maskSecret(value: string): string {
if (!value) return '';
if (value.length <= 4) return '****';
return `${value.slice(0, 2)}****${value.slice(-2)}`;
}
function isValidHttpUrl(raw: string): boolean {
if (!raw.trim()) return false;
try {
const parsed = new URL(raw);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function normalizeBackupWebdavConfig(raw: unknown): BackupWebdavConfig {
const source = isRecord(raw) ? raw : {};
const exportType = isValidBackupExportType(source.exportType) ? source.exportType : 'all';
const autoSyncCron = typeof source.autoSyncCron === 'string' && cron.validate(source.autoSyncCron)
? source.autoSyncCron
: BACKUP_WEBDAV_DEFAULT_AUTO_SYNC_CRON;
return {
enabled: source.enabled === true,
fileUrl: asString(source.fileUrl),
username: asString(source.username),
password: typeof source.password === 'string' ? source.password : '',
exportType,
autoSyncEnabled: source.autoSyncEnabled === true,
autoSyncCron,
};
}
function normalizeBackupWebdavState(raw: unknown): BackupWebdavState {
const source = isRecord(raw) ? raw : {};
return {
lastSyncAt: typeof source.lastSyncAt === 'string' && source.lastSyncAt.trim() ? source.lastSyncAt : null,
lastError: typeof source.lastError === 'string' && source.lastError.trim() ? source.lastError : null,
};
}
function toBackupWebdavConfigView(config: BackupWebdavConfig): BackupWebdavConfigView {
return {
enabled: config.enabled,
fileUrl: config.fileUrl,
username: config.username,
exportType: config.exportType,
autoSyncEnabled: config.autoSyncEnabled,
autoSyncCron: config.autoSyncCron,
hasPassword: config.password.length > 0,
passwordMasked: maskSecret(config.password),
};
}
async function readSettingValue(key: string): Promise<unknown> {
const row = await db.select({ value: schema.settings.value }).from(schema.settings).where(eq(schema.settings.key, key)).get();
return parseSettingValue(row?.value ?? null);
}
async function loadBackupWebdavConfig(): Promise<BackupWebdavConfig> {
return normalizeBackupWebdavConfig(await readSettingValue(BACKUP_WEBDAV_CONFIG_SETTING_KEY));
}
async function loadBackupWebdavState(): Promise<BackupWebdavState> {
return normalizeBackupWebdavState(await readSettingValue(BACKUP_WEBDAV_STATE_SETTING_KEY));
}
async function writeBackupWebdavState(next: BackupWebdavState) {
await upsertSetting(BACKUP_WEBDAV_STATE_SETTING_KEY, next);
}
function resolveBackupWebdavAuthHeader(config: BackupWebdavConfig): string | null {
if (!config.username && !config.password) return null;
return `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
}
function validateBackupWebdavConfig(config: BackupWebdavConfig) {
if (config.enabled && !isValidHttpUrl(config.fileUrl)) {
throw new Error('WebDAV 文件地址无效,请填写 http/https 文件 URL');
}
if (!isValidBackupExportType(config.exportType)) {
throw new Error('WebDAV 导出类型无效,仅支持 all/accounts/preferences');
}
if (!cron.validate(config.autoSyncCron)) {
throw new Error('WebDAV 自动同步 Cron 表达式无效');
}
if (config.autoSyncEnabled && !config.enabled) {
throw new Error('启用自动同步前请先启用 WebDAV 备份');
}
}
async function fetchBackupWebdav(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
controller.abort();
}, BACKUP_WEBDAV_FETCH_TIMEOUT_MS);
try {
return await fetch(url, {
...init,
signal: controller.signal,
});
} catch (error: any) {
if (error?.name === 'AbortError') {
throw new Error(`WebDAV 请求超时(${Math.max(1, Math.round(BACKUP_WEBDAV_FETCH_TIMEOUT_MS / 1000))}s)`);
}
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
}
}
function stopBackupWebdavScheduler() {
backupWebdavTask?.stop();
backupWebdavTask = null;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
@@ -1103,3 +1259,169 @@ export async function importBackup(data: RawBackupData): Promise<BackupImportRes
warnings: importMetadata.warnings,
};
}
export async function getBackupWebdavConfig() {
const [config, state] = await Promise.all([
loadBackupWebdavConfig(),
loadBackupWebdavState(),
]);
return {
success: true,
config: toBackupWebdavConfigView(config),
state,
};
}
export async function saveBackupWebdavConfig(input: Partial<BackupWebdavConfig> & { password?: string; clearPassword?: boolean }) {
const existing = await loadBackupWebdavConfig();
const next: BackupWebdavConfig = {
enabled: input.enabled !== undefined ? input.enabled === true : existing.enabled,
fileUrl: input.fileUrl !== undefined ? asString(input.fileUrl) : existing.fileUrl,
username: input.username !== undefined ? asString(input.username) : existing.username,
password: input.clearPassword
? ''
: (input.password !== undefined
? String(input.password)
: existing.password),
exportType: isValidBackupExportType(input.exportType) ? input.exportType : existing.exportType,
autoSyncEnabled: input.autoSyncEnabled !== undefined ? input.autoSyncEnabled === true : existing.autoSyncEnabled,
autoSyncCron: typeof input.autoSyncCron === 'string' && input.autoSyncCron.trim()
? input.autoSyncCron.trim()
: existing.autoSyncCron,
};
if (!next.enabled) {
next.autoSyncEnabled = false;
}
validateBackupWebdavConfig(next);
await upsertSetting(BACKUP_WEBDAV_CONFIG_SETTING_KEY, next);
await reloadBackupWebdavScheduler();
return getBackupWebdavConfig();
}
export async function exportBackupToWebdav(type?: BackupExportType) {
const config = await loadBackupWebdavConfig();
validateBackupWebdavConfig(config);
if (!config.enabled) {
throw new Error('WebDAV 备份未启用');
}
if (!config.fileUrl) {
throw new Error('WebDAV 文件地址不能为空');
}
const exportType = type && isValidBackupExportType(type) ? type : config.exportType;
const payload = await exportBackup(exportType);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const authHeader = resolveBackupWebdavAuthHeader(config);
if (authHeader) headers.Authorization = authHeader;
try {
const response = await fetchBackupWebdav(config.fileUrl, {
method: 'PUT',
headers,
body: JSON.stringify(payload, null, 2),
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`WebDAV 导出失败:HTTP ${response.status}${text ? ` ${text.slice(0, 120)}` : ''}`);
}
const syncedAt = new Date().toISOString();
await writeBackupWebdavState({
lastSyncAt: syncedAt,
lastError: null,
});
return {
success: true,
fileUrl: config.fileUrl,
exportType,
syncedAt,
lastSyncAt: syncedAt,
lastError: null,
};
} catch (error: any) {
const previousState = await loadBackupWebdavState();
await writeBackupWebdavState({
lastSyncAt: previousState.lastSyncAt,
lastError: error?.message || 'WebDAV 导出失败',
});
throw error;
}
}
export async function importBackupFromWebdav() {
const config = await loadBackupWebdavConfig();
validateBackupWebdavConfig(config);
if (!config.enabled) {
throw new Error('WebDAV 备份未启用');
}
if (!config.fileUrl) {
throw new Error('WebDAV 文件地址不能为空');
}
const headers: Record<string, string> = {};
const authHeader = resolveBackupWebdavAuthHeader(config);
if (authHeader) headers.Authorization = authHeader;
try {
const response = await fetchBackupWebdav(config.fileUrl, {
method: 'GET',
headers,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`WebDAV 导入失败:HTTP ${response.status}${text ? ` ${text.slice(0, 120)}` : ''}`);
}
const raw = await response.text();
const parsed = JSON.parse(raw) as RawBackupData;
const result = await importBackup(parsed);
const syncedAt = new Date().toISOString();
await writeBackupWebdavState({
lastSyncAt: syncedAt,
lastError: null,
});
return {
success: true,
fileUrl: config.fileUrl,
syncedAt,
lastSyncAt: syncedAt,
lastError: null,
...result,
};
} catch (error: any) {
const previousState = await loadBackupWebdavState();
await writeBackupWebdavState({
lastSyncAt: previousState.lastSyncAt,
lastError: error?.message || 'WebDAV 导入失败',
});
throw error;
}
}
export async function reloadBackupWebdavScheduler() {
stopBackupWebdavScheduler();
const config = await loadBackupWebdavConfig();
if (!config.enabled || !config.autoSyncEnabled) return;
try {
validateBackupWebdavConfig(config);
} catch (error: any) {
console.warn(`[backup/webdav] invalid config: ${error?.message || 'unknown error'}`);
return;
}
backupWebdavTask = cron.schedule(config.autoSyncCron, () => {
void exportBackupToWebdav(config.exportType).catch((error) => {
console.warn(`[backup/webdav] auto sync failed: ${(error as Error)?.message || 'unknown error'}`);
});
});
}
export function __resetBackupWebdavSchedulerForTests() {
stopBackupWebdavScheduler();
}
+6
View File
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
import { reportTokenExpired } from './alertService.js';
import {
buildStoredSub2ApiSubscriptionSummary,
getAutoReloginConfig,
getCredentialModeFromExtraConfig,
getProxyUrlFromExtraConfig,
@@ -483,6 +484,11 @@ export async function refreshBalance(accountId: number) {
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, balanceInfo.todayIncome);
}
if (balanceInfo.subscriptionSummary && isSub2ApiPlatform(site.platform)) {
nextExtraConfig = mergeAccountExtraConfig(nextExtraConfig, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary(balanceInfo.subscriptionSummary),
});
}
const existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig);
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const cronStopMock = vi.fn();
const scheduleMock = vi.fn(() => ({
stop: cronStopMock,
}));
const validateMock = vi.fn(() => true);
const allMock = vi.fn();
vi.mock('node-cron', () => ({
default: {
schedule: (...args: unknown[]) => scheduleMock(...args),
validate: (...args: unknown[]) => validateMock(...args),
},
}));
vi.mock('../db/index.js', () => {
const queryChain = {
where: () => queryChain,
get: () => undefined,
all: () => [],
from: () => queryChain,
innerJoin: () => queryChain,
};
return {
db: {
select: () => queryChain,
},
schema: {
settings: { key: 'key' },
accounts: { checkinEnabled: 'checkinEnabled', status: 'status' },
sites: { id: 'id' },
},
};
});
vi.mock('./checkinService.js', () => ({
checkinAll: (...args: unknown[]) => allMock(...args),
}));
describe('checkinScheduler', () => {
beforeEach(() => {
vi.useFakeTimers();
cronStopMock.mockReset();
scheduleMock.mockClear();
validateMock.mockClear();
allMock.mockReset();
});
afterEach(async () => {
const scheduler = await import('./checkinScheduler.js');
scheduler.__resetCheckinSchedulerForTests();
vi.useRealTimers();
});
it('switches from cron mode to interval mode and back', async () => {
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval');
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
const scheduler = await import('./checkinScheduler.js');
scheduler.updateCheckinSchedule({
mode: 'cron',
cronExpr: '0 8 * * *',
intervalHours: 6,
});
expect(scheduleMock).toHaveBeenCalledTimes(1);
scheduler.updateCheckinSchedule({
mode: 'interval',
intervalHours: 6,
});
expect(cronStopMock).toHaveBeenCalledTimes(1);
expect(setIntervalSpy).toHaveBeenCalledTimes(1);
scheduler.updateCheckinSchedule({
mode: 'cron',
cronExpr: '5 9 * * *',
intervalHours: 6,
});
expect(clearIntervalSpy).toHaveBeenCalledTimes(1);
expect(scheduleMock).toHaveBeenCalledTimes(2);
});
it('selects due accounts from the last successful checkin time', async () => {
const scheduler = await import('./checkinScheduler.js');
const now = new Date('2026-03-20T12:00:00.000Z');
expect(scheduler.selectDueIntervalCheckinAccountIds([
{ id: 1, lastCheckinAt: null },
{ id: 2, lastCheckinAt: '2026-03-20T05:59:59.000Z' },
{ id: 3, lastCheckinAt: '2026-03-20T06:30:00.000Z' },
], 6, now)).toEqual([1, 2]);
});
});
+148 -8
View File
@@ -9,13 +9,18 @@ import { sendNotification } from './notifyService.js';
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
export type CheckinScheduleMode = 'cron' | 'interval';
let checkinTask: cron.ScheduledTask | null = null;
let checkinIntervalTimer: ReturnType<typeof setInterval> | null = null;
let balanceTask: cron.ScheduledTask | null = null;
let dailySummaryTask: cron.ScheduledTask | null = null;
let logCleanupTask: cron.ScheduledTask | null = null;
const intervalAttemptByAccount = new Map<number, number>();
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *';
const CHECKIN_INTERVAL_POLL_MS = 60_000;
async function resolveJsonSetting<T>(
settingKey: string,
@@ -54,7 +59,7 @@ function createCheckinTask(cronExpr: string) {
return cron.schedule(cronExpr, async () => {
console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`);
try {
const results = await checkinAll();
const results = await checkinAll({ scheduleMode: 'cron' });
const success = results.filter((r) => r.result.success).length;
const failed = results.length - success;
console.log(`[Scheduler] Check-in complete: ${success} success, ${failed} failed`);
@@ -64,6 +69,94 @@ function createCheckinTask(cronExpr: string) {
});
}
type IntervalCheckinCandidate = {
id: number;
lastCheckinAt?: string | null;
};
export function selectDueIntervalCheckinAccountIds(
rows: IntervalCheckinCandidate[],
intervalHours: number,
now = new Date(),
attemptState = intervalAttemptByAccount,
) {
const nowMs = now.getTime();
const intervalMs = Math.max(1, intervalHours) * 60 * 60 * 1000;
return rows
.filter((row) => {
const lastCheckinMs = row.lastCheckinAt ? Date.parse(row.lastCheckinAt) : Number.NaN;
const lastAttemptMs = attemptState.get(row.id);
if (Number.isFinite(lastCheckinMs)) {
if (nowMs - lastCheckinMs < intervalMs) return false;
if (typeof lastAttemptMs === 'number' && lastAttemptMs >= lastCheckinMs && nowMs - lastAttemptMs < intervalMs) {
return false;
}
return true;
}
if (typeof lastAttemptMs === 'number' && nowMs - lastAttemptMs < intervalMs) return false;
return true;
})
.map((row) => row.id);
}
async function runIntervalCheckinPass(now = new Date()) {
const rows = await db
.select()
.from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
.all();
const dueAccountIds = selectDueIntervalCheckinAccountIds(
rows
.filter((row: any) => row.accounts?.checkinEnabled === true && row.accounts?.status === 'active' && row.sites?.status !== 'disabled')
.map((row: any) => ({
id: row.accounts.id,
lastCheckinAt: row.accounts.lastCheckinAt,
})),
config.checkinIntervalHours,
now,
);
if (dueAccountIds.length === 0) return;
try {
const results = await checkinAll({
accountIds: dueAccountIds,
scheduleMode: 'interval',
});
const nowMs = now.getTime();
for (const item of results) {
intervalAttemptByAccount.set(item.accountId, nowMs);
}
const success = results.filter((r) => r.result.success).length;
const failed = results.length - success;
console.log(`[Scheduler] Interval check-in complete: ${success} success, ${failed} failed`);
} catch (err) {
console.error('[Scheduler] Interval check-in error:', err);
}
}
function stopCheckinSchedule() {
checkinTask?.stop();
checkinTask = null;
if (checkinIntervalTimer) {
clearInterval(checkinIntervalTimer);
checkinIntervalTimer = null;
}
}
function startCheckinSchedule() {
stopCheckinSchedule();
if (config.checkinScheduleMode === 'interval') {
checkinIntervalTimer = setInterval(() => {
void runIntervalCheckinPass();
}, CHECKIN_INTERVAL_POLL_MS);
return;
}
checkinTask = createCheckinTask(config.checkinCron);
}
function createBalanceTask(cronExpr: string) {
return cron.schedule(cronExpr, async () => {
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
@@ -119,6 +212,15 @@ function createLogCleanupTask(cronExpr: string) {
export async function startScheduler() {
const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron);
const activeCheckinScheduleMode = await resolveJsonSetting<CheckinScheduleMode>(
'checkin_schedule_mode',
(value): value is CheckinScheduleMode => value === 'cron' || value === 'interval',
config.checkinScheduleMode as CheckinScheduleMode,
);
const activeCheckinIntervalHours = await resolvePositiveIntegerSetting(
'checkin_interval_hours',
config.checkinIntervalHours,
);
const activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
const activeLogCleanupCron = await resolveCronSetting('log_cleanup_cron', config.logCleanupCron || LOG_CLEANUP_DEFAULT_CRON);
@@ -135,22 +237,24 @@ export async function startScheduler() {
normalizeLogCleanupRetentionDays(config.logCleanupRetentionDays),
);
config.checkinCron = activeCheckinCron;
config.checkinScheduleMode = activeCheckinScheduleMode;
config.checkinIntervalHours = Math.min(24, Math.max(1, activeCheckinIntervalHours));
config.balanceRefreshCron = activeBalanceCron;
config.logCleanupCron = activeLogCleanupCron;
config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled;
config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled;
config.logCleanupRetentionDays = activeLogCleanupRetentionDays;
checkinTask?.stop();
stopCheckinSchedule();
balanceTask?.stop();
dailySummaryTask?.stop();
logCleanupTask?.stop();
checkinTask = createCheckinTask(activeCheckinCron);
startCheckinSchedule();
balanceTask = createBalanceTask(activeBalanceCron);
dailySummaryTask = createDailySummaryTask(activeDailySummaryCron);
logCleanupTask = createLogCleanupTask(activeLogCleanupCron);
console.log(`[Scheduler] Check-in cron: ${activeCheckinCron}`);
console.log(`[Scheduler] Check-in schedule: ${config.checkinScheduleMode} (${config.checkinScheduleMode === 'cron' ? activeCheckinCron : `${config.checkinIntervalHours}h`})`);
console.log(`[Scheduler] Balance refresh cron: ${activeBalanceCron}`);
console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`);
console.log(
@@ -159,10 +263,35 @@ export async function startScheduler() {
}
export function updateCheckinCron(cronExpr: string) {
if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`);
config.checkinCron = cronExpr;
checkinTask?.stop();
checkinTask = createCheckinTask(cronExpr);
updateCheckinSchedule({
mode: 'cron',
cronExpr,
intervalHours: config.checkinIntervalHours,
});
}
export function updateCheckinSchedule(input: {
mode: CheckinScheduleMode;
cronExpr?: string;
intervalHours?: number;
}) {
const nextMode = input.mode;
if (nextMode !== 'cron' && nextMode !== 'interval') {
throw new Error(`Invalid checkin schedule mode: ${String(nextMode)}`);
}
const nextCronExpr = input.cronExpr ?? config.checkinCron;
if (!cron.validate(nextCronExpr)) throw new Error(`Invalid cron: ${nextCronExpr}`);
const nextIntervalHours = input.intervalHours ?? config.checkinIntervalHours;
if (!Number.isFinite(nextIntervalHours) || nextIntervalHours < 1 || nextIntervalHours > 24) {
throw new Error(`Invalid interval hours: ${String(nextIntervalHours)}`);
}
config.checkinScheduleMode = nextMode;
config.checkinCron = nextCronExpr;
config.checkinIntervalHours = Math.trunc(nextIntervalHours);
startCheckinSchedule();
}
export function updateBalanceRefreshCron(cronExpr: string) {
@@ -191,3 +320,14 @@ export function updateLogCleanupSettings(input: {
logCleanupTask?.stop();
logCleanupTask = createLogCleanupTask(cronExpr);
}
export function __resetCheckinSchedulerForTests() {
stopCheckinSchedule();
balanceTask?.stop();
dailySummaryTask?.stop();
logCleanupTask?.stop();
balanceTask = null;
dailySummaryTask = null;
logCleanupTask = null;
intervalAttemptByAccount.clear();
}
@@ -248,6 +248,63 @@ describe('checkinService auto relogin', () => {
expect(notifyMock).not.toHaveBeenCalled();
});
it('does not advance lastCheckinAt for already checked in responses in interval mode', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 16,
username: 'interval-user',
accessToken: 'token',
status: 'active',
extraConfig: null,
},
sites: {
id: 16,
name: 'demo',
url: 'https://example.com',
platform: 'new-api',
},
},
]);
adapterMock.checkin.mockResolvedValue({ success: false, message: '今天已经签到过啦' });
const { checkinAccount } = await import('./checkinService.js');
const result = await checkinAccount(16, { scheduleMode: 'interval' });
expect(result.success).toBe(true);
expect(result.status).toBe('success');
expect(updateSetMock).not.toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) }));
});
it('advances lastCheckinAt when interval mode gets a direct success', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 17,
username: 'interval-success',
accessToken: 'token',
status: 'active',
extraConfig: null,
},
sites: {
id: 17,
name: 'demo',
url: 'https://example.com',
platform: 'new-api',
},
},
]);
adapterMock.checkin.mockResolvedValue({ success: true, message: '签到成功' });
const { checkinAccount } = await import('./checkinService.js');
const result = await checkinAccount(17, { scheduleMode: 'interval' });
expect(result.success).toBe(true);
expect(updateSetMock).toHaveBeenCalledWith(expect.objectContaining({ lastCheckinAt: expect.any(String) }));
});
it('treats unsupported checkin endpoint responses as skipped', async () => {
selectAllMock.mockReturnValue([
{
@@ -283,6 +340,40 @@ describe('checkinService auto relogin', () => {
expect(notifyMock).not.toHaveBeenCalled();
});
it('skips account updates when unsupported checkin responses do not change account state', async () => {
selectAllMock.mockReturnValue([
{
accounts: {
id: 18,
username: 'plain-user',
accessToken: 'token',
status: 'active',
extraConfig: null,
},
sites: {
id: 18,
name: 'done-hub',
url: 'https://done.example.com',
platform: 'donehub',
},
},
]);
adapterMock.checkin.mockResolvedValue({
success: false,
message: 'checkin endpoint not found',
});
const { checkinAccount } = await import('./checkinService.js');
const result = await checkinAccount(18);
expect(result.success).toBe(true);
expect(result.status).toBe('skipped');
expect(updateSetMock).not.toHaveBeenCalled();
const firstInsertPayload = insertValuesMock.mock.calls[0]?.[0] as Record<string, unknown>;
expect(firstInsertPayload?.status).toBe('skipped');
});
it('treats sub2api checkin unsupported message as skipped', async () => {
selectAllMock.mockReturnValue([
{

Some files were not shown because too many files have changed in this diff Show More