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:
@@ -299,6 +299,35 @@ jobs:
|
|||||||
- name: Audit production dependencies
|
- name: Audit production dependencies
|
||||||
run: npm audit --omit=dev --audit-level=high
|
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:
|
publish-docker-arch:
|
||||||
name: Publish Docker Image (${{ matrix.arch }})
|
name: Publish Docker Image (${{ matrix.arch }})
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
@@ -314,6 +343,9 @@ jobs:
|
|||||||
- arch: arm64
|
- arch: arm64
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
|
- arch: armv7
|
||||||
|
platform: linux/arm/v7
|
||||||
|
runner: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -329,6 +361,10 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
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
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4
|
||||||
with:
|
with:
|
||||||
@@ -419,5 +455,6 @@ jobs:
|
|||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--tag "$tag" \
|
--tag "$tag" \
|
||||||
"${tag}-amd64" \
|
"${tag}-amd64" \
|
||||||
"${tag}-arm64"
|
"${tag}-arm64" \
|
||||||
|
"${tag}-armv7"
|
||||||
done <<< "${{ steps.meta.outputs.tags }}"
|
done <<< "${{ steps.meta.outputs.tags }}"
|
||||||
|
|||||||
@@ -284,14 +284,17 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- arch: amd64
|
- arch: amd64
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runner: ubuntu-latest
|
runner: ubuntu-latest
|
||||||
- arch: arm64
|
- arch: arm64
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runner: ubuntu-24.04-arm
|
runner: ubuntu-24.04-arm
|
||||||
|
- arch: armv7
|
||||||
|
platform: linux/arm/v7
|
||||||
|
runner: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -304,8 +307,12 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
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
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v4
|
||||||
@@ -410,8 +417,9 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
while IFS= read -r tag; do
|
while IFS= read -r tag; do
|
||||||
[ -n "$tag" ] || continue
|
[ -n "$tag" ] || continue
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--tag "$tag" \
|
--tag "$tag" \
|
||||||
"${tag}-amd64" \
|
"${tag}-amd64" \
|
||||||
"${tag}-arm64"
|
"${tag}-arm64" \
|
||||||
done <<< "${{ steps.meta.outputs.tags }}"
|
"${tag}-armv7"
|
||||||
|
done <<< "${{ steps.meta.outputs.tags }}"
|
||||||
|
|||||||
@@ -279,7 +279,7 @@
|
|||||||
### 📦 轻量部署
|
### 📦 轻量部署
|
||||||
|
|
||||||
- **单 Docker 容器**,默认本地数据目录部署,支持外接 MySQL / PostgreSQL 运行时数据库
|
- **单 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` 登录即可。
|
启动后访问 `http://localhost:4000`,用 `AUTH_TOKEN` 登录即可。
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Docker 镜像支持 `amd64`、`arm64` 和 `armv7l`(`linux/arm/v7`)服务端部署。
|
||||||
|
> 当前 `armv7l` 支持范围仅限服务端 / Docker 运行,不包含桌面安装包。
|
||||||
|
|
||||||
|
<!-- markdownlint-disable-next-line MD028 -->
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。
|
> 请务必修改 `AUTH_TOKEN` 和 `PROXY_TOKEN`,不要使用默认值。数据存储在 `./data` 目录,升级不会丢失。
|
||||||
|
|
||||||
@@ -369,7 +374,7 @@ Docker Compose、桌面安装包、反向代理、升级与数据库选项等详
|
|||||||
| **数据库** | SQLite / MySQL / PostgreSQL +[Drizzle ORM](https://orm.drizzle.team) |
|
| **数据库** | SQLite / MySQL / PostgreSQL +[Drizzle ORM](https://orm.drizzle.team) |
|
||||||
| **数据可视化** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) |
|
| **数据可视化** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) |
|
||||||
| **定时任务** | [node-cron](https://github.com/node-cron/node-cron) |
|
| **定时任务** | [node-cron](https://github.com/node-cron/node-cron) |
|
||||||
| **容器化** | Docker (Alpine) + Docker Compose |
|
| **容器化** | Docker (Debian slim) + Docker Compose |
|
||||||
| **测试** | [Vitest](https://vitest.dev) |
|
| **测试** | [Vitest](https://vitest.dev) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+7
-2
@@ -287,7 +287,7 @@ Alert scenarios: low balance warning, site/account anomalies, check-in failures,
|
|||||||
### Lightweight Deployment
|
### Lightweight Deployment
|
||||||
|
|
||||||
- **Single Docker container** with a default local data directory, plus optional external MySQL / PostgreSQL runtime DB
|
- **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
|
- 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`!
|
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]
|
> [!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.
|
> 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) |
|
| **Database** | SQLite / MySQL / PostgreSQL + [Drizzle ORM](https://orm.drizzle.team) |
|
||||||
| **Charts** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) |
|
| **Charts** | [VChart](https://visactor.io/vchart) (@visactor/react-vchart) |
|
||||||
| **Scheduling** | [node-cron](https://github.com/node-cron/node-cron) |
|
| **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) |
|
| **Testing** | [Vitest](https://vitest.dev) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
FROM node:25-bookworm-slim AS builder
|
FROM node:22-bookworm-slim AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,13 +10,13 @@ RUN apt-get update \
|
|||||||
ENV PYTHON=/usr/bin/python3
|
ENV PYTHON=/usr/bin/python3
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
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 . .
|
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
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ services:
|
|||||||
metapi:
|
metapi:
|
||||||
image: 1467078763/metapi:latest
|
image: 1467078763/metapi:latest
|
||||||
ports:
|
ports:
|
||||||
- "4000:4000"
|
- "127.0.0.1:4000:4000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -213,6 +213,15 @@ Metapi 的路由引擎按多因子加权选择最优通道。
|
|||||||
|
|
||||||
> `TOKEN_ROUTER_CACHE_TTL_MS`、`PROXY_LOG_RETENTION_DAYS`、`PROXY_LOG_RETENTION_PRUNE_INTERVAL_MINUTES` 当前仍属于部署级环境变量,不在后台运行时设置里单独维护。
|
> `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 与反向代理
|
- [部署指南](./deployment.md) — Docker Compose 与反向代理
|
||||||
|
|||||||
@@ -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 本地数据库
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🛠️ 故障排查
|
## 🛠️ 故障排查
|
||||||
|
|
||||||
### 问题:添加站点后无法获取模型列表
|
### 问题:添加站点后无法获取模型列表
|
||||||
|
|||||||
@@ -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
@@ -99,6 +99,20 @@
|
|||||||
"when": 1773855000000,
|
"when": 1773855000000,
|
||||||
"tag": "0014_explicit_group_routes",
|
"tag": "0014_explicit_group_routes",
|
||||||
"breakpoints": true
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+540
-4
@@ -22,6 +22,7 @@
|
|||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
"get-port": "^7.1.0",
|
"get-port": "^7.1.0",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"minimatch": "^10.2.4",
|
"minimatch": "^10.2.4",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
"drizzle-kit": "^0.28.1",
|
"drizzle-kit": "^0.28.1",
|
||||||
"electron": "^41.0.2",
|
"electron": "^41.0.2",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -349,6 +351,67 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -638,6 +701,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@chevrotain/cst-dts-gen": {
|
||||||
"version": "11.1.2",
|
"version": "11.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",
|
||||||
@@ -682,6 +758,146 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/@develar/schema-utils": {
|
||||||
"version": "2.6.5",
|
"version": "2.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||||
@@ -2188,6 +2404,24 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/@fastify/accept-negotiator": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-2.0.1.tgz",
|
"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": "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": {
|
"node_modules/bindings": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
@@ -7000,6 +7244,20 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -7635,6 +7893,20 @@
|
|||||||
"lodash-es": "^4.17.21"
|
"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": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
"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": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@@ -9664,6 +9943,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/html-void-elements": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
|
||||||
@@ -9876,6 +10168,13 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/is-property": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||||
@@ -10021,6 +10320,67 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"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": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
@@ -10620,10 +10980,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/marked": {
|
"node_modules/marked": {
|
||||||
"version": "16.4.2",
|
"version": "17.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
|
||||||
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
|
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"marked": "bin/marked.js"
|
"marked": "bin/marked.js"
|
||||||
@@ -10678,6 +11037,13 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/mermaid": {
|
||||||
"version": "11.12.3",
|
"version": "11.12.3",
|
||||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz",
|
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz",
|
||||||
@@ -10707,6 +11073,19 @@
|
|||||||
"uuid": "^11.1.0"
|
"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": {
|
"node_modules/mermaid/node_modules/uuid": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
@@ -11524,6 +11903,32 @@
|
|||||||
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -12675,6 +13080,19 @@
|
|||||||
"node": ">=11.0.0"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -13376,6 +13794,13 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"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": {
|
"node_modules/tabbable": {
|
||||||
"version": "6.4.0",
|
"version": "6.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||||
@@ -13639,6 +14064,26 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.5",
|
"version": "0.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||||
@@ -13703,6 +14148,32 @@
|
|||||||
"geo2topo": "bin/geo2topo"
|
"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": {
|
"node_modules/tree-kill": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
"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": {
|
"node_modules/wait-on": {
|
||||||
"version": "9.0.4",
|
"version": "9.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz",
|
||||||
@@ -16796,6 +17280,41 @@
|
|||||||
"defaults": "^1.0.3"
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"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": {
|
"node_modules/xmlbuilder": {
|
||||||
"version": "15.1.1",
|
"version": "15.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
|
||||||
@@ -16970,6 +17499,13 @@
|
|||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"fastify": "^5.8.2",
|
"fastify": "^5.8.2",
|
||||||
"get-port": "^7.1.0",
|
"get-port": "^7.1.0",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"minimatch": "^10.2.4",
|
"minimatch": "^10.2.4",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"mysql2": "^3.15.3",
|
"mysql2": "^3.15.3",
|
||||||
@@ -98,6 +99,7 @@
|
|||||||
"drizzle-kit": "^0.28.1",
|
"drizzle-kit": "^0.28.1",
|
||||||
"electron": "^41.0.2",
|
"electron": "^41.0.2",
|
||||||
"electron-builder": "^26.0.12",
|
"electron-builder": "^26.0.12",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import sharp from 'sharp';
|
|||||||
export const DESKTOP_ICON_SIZE = 512;
|
export const DESKTOP_ICON_SIZE = 512;
|
||||||
export const DESKTOP_ICON_PADDING = 40;
|
export const DESKTOP_ICON_PADDING = 40;
|
||||||
export const DESKTOP_ICON_RADIUS = 96;
|
export const DESKTOP_ICON_RADIUS = 96;
|
||||||
|
export const DESKTOP_TRAY_TEMPLATE_PADDING = 152;
|
||||||
|
|
||||||
function createRoundedMask(size, cornerRadius) {
|
function createRoundedMask(size, cornerRadius) {
|
||||||
return Buffer.from(
|
return Buffer.from(
|
||||||
@@ -44,34 +45,73 @@ async function renderDesktopIconBuffer({
|
|||||||
.toBuffer();
|
.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({
|
export async function generateDesktopIconAssets({
|
||||||
sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'),
|
sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'),
|
||||||
buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'),
|
buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'),
|
||||||
webOutputPath = join(process.cwd(), 'src', 'web', 'public', '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,
|
size = DESKTOP_ICON_SIZE,
|
||||||
padding = DESKTOP_ICON_PADDING,
|
padding = DESKTOP_ICON_PADDING,
|
||||||
cornerRadius = DESKTOP_ICON_RADIUS,
|
cornerRadius = DESKTOP_ICON_RADIUS,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const outputBuffer = await renderDesktopIconBuffer({
|
const [outputBuffer, trayTemplateBuffer] = await Promise.all([
|
||||||
sourcePath,
|
renderDesktopIconBuffer({
|
||||||
size,
|
sourcePath,
|
||||||
padding,
|
size,
|
||||||
cornerRadius,
|
padding,
|
||||||
});
|
cornerRadius,
|
||||||
|
}),
|
||||||
|
renderTrayTemplateIconBuffer({
|
||||||
|
sourcePath,
|
||||||
|
size,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
mkdir(dirname(buildOutputPath), { recursive: true }),
|
mkdir(dirname(buildOutputPath), { recursive: true }),
|
||||||
mkdir(dirname(webOutputPath), { recursive: true }),
|
mkdir(dirname(webOutputPath), { recursive: true }),
|
||||||
|
mkdir(dirname(trayTemplateOutputPath), { recursive: true }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
sharp(outputBuffer).toFile(buildOutputPath),
|
sharp(outputBuffer).toFile(buildOutputPath),
|
||||||
sharp(outputBuffer).toFile(webOutputPath),
|
sharp(outputBuffer).toFile(webOutputPath),
|
||||||
|
sharp(trayTemplateBuffer).toFile(trayTemplateOutputPath),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buildOutputPath,
|
buildOutputPath,
|
||||||
webOutputPath,
|
webOutputPath,
|
||||||
|
trayTemplateOutputPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,5 +121,6 @@ if (isDirectRun) {
|
|||||||
const outputs = await generateDesktopIconAssets();
|
const outputs = await generateDesktopIconAssets();
|
||||||
console.log(`[metapi-desktop] Generated desktop icons:
|
console.log(`[metapi-desktop] Generated desktop icons:
|
||||||
- ${outputs.buildOutputPath}
|
- ${outputs.buildOutputPath}
|
||||||
- ${outputs.webOutputPath}`);
|
- ${outputs.webOutputPath}
|
||||||
|
- ${outputs.trayTemplateOutputPath}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('generateDesktopIconAssets', () => {
|
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 { generateDesktopIconAssets } = await import('./generate-icons.mjs');
|
||||||
|
|
||||||
const dir = await makeTempDir();
|
const dir = await makeTempDir();
|
||||||
const sourcePath = join(dir, 'logo.png');
|
const sourcePath = join(dir, 'logo.png');
|
||||||
const buildOutputPath = join(dir, 'build.png');
|
const buildOutputPath = join(dir, 'build.png');
|
||||||
const webOutputPath = join(dir, 'desktop-icon.png');
|
const webOutputPath = join(dir, 'desktop-icon.png');
|
||||||
|
const trayTemplateOutputPath = join(dir, 'desktop-tray-template.png');
|
||||||
|
|
||||||
await sharp({
|
await sharp({
|
||||||
create: {
|
create: {
|
||||||
@@ -42,15 +43,19 @@ describe('generateDesktopIconAssets', () => {
|
|||||||
sourcePath,
|
sourcePath,
|
||||||
buildOutputPath,
|
buildOutputPath,
|
||||||
webOutputPath,
|
webOutputPath,
|
||||||
|
trayTemplateOutputPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildMeta = await sharp(buildOutputPath).metadata();
|
const buildMeta = await sharp(buildOutputPath).metadata();
|
||||||
const webMeta = await sharp(webOutputPath).metadata();
|
const webMeta = await sharp(webOutputPath).metadata();
|
||||||
|
const trayMeta = await sharp(trayTemplateOutputPath).metadata();
|
||||||
|
|
||||||
expect(buildMeta.width).toBe(512);
|
expect(buildMeta.width).toBe(512);
|
||||||
expect(buildMeta.height).toBe(512);
|
expect(buildMeta.height).toBe(512);
|
||||||
expect(webMeta.width).toBe(512);
|
expect(webMeta.width).toBe(512);
|
||||||
expect(webMeta.height).toBe(512);
|
expect(webMeta.height).toBe(512);
|
||||||
|
expect(trayMeta.width).toBe(512);
|
||||||
|
expect(trayMeta.height).toBe(512);
|
||||||
|
|
||||||
const { data, info } = await sharp(buildOutputPath)
|
const { data, info } = await sharp(buildOutputPath)
|
||||||
.ensureAlpha()
|
.ensureAlpha()
|
||||||
@@ -64,5 +69,18 @@ describe('generateDesktopIconAssets', () => {
|
|||||||
|
|
||||||
expect(await sharp(buildOutputPath).png().toBuffer())
|
expect(await sharp(buildOutputPath).png().toBuffer())
|
||||||
.toEqual(await sharp(webOutputPath).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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,12 +7,17 @@ describe('desktop icon assets', () => {
|
|||||||
const {
|
const {
|
||||||
DESKTOP_BUILD_ICON_RELATIVE_PATH,
|
DESKTOP_BUILD_ICON_RELATIVE_PATH,
|
||||||
DESKTOP_RUNTIME_ICON_RELATIVE_PATH,
|
DESKTOP_RUNTIME_ICON_RELATIVE_PATH,
|
||||||
|
DESKTOP_TRAY_TEMPLATE_ICON_RELATIVE_PATH,
|
||||||
getDesktopRuntimeIconPath,
|
getDesktopRuntimeIconPath,
|
||||||
|
getDesktopTrayIconPath,
|
||||||
} = await import('./iconAssets.js');
|
} = await import('./iconAssets.js');
|
||||||
|
|
||||||
expect(DESKTOP_RUNTIME_ICON_RELATIVE_PATH).toBe(join('dist', 'web', 'desktop-icon.png'));
|
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_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(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 () => {
|
it('points electron-builder at the generated desktop package icon', async () => {
|
||||||
|
|||||||
@@ -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_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_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 const DESKTOP_ICON_PUBLIC_PATH = '/desktop-icon.png';
|
||||||
|
|
||||||
export function getDesktopRuntimeIconPath(appPath: string) {
|
export function getDesktopRuntimeIconPath(appPath: string) {
|
||||||
return join(appPath, DESKTOP_RUNTIME_ICON_RELATIVE_PATH);
|
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
@@ -21,7 +21,7 @@ import {
|
|||||||
resolveDesktopServerWorkingDir,
|
resolveDesktopServerWorkingDir,
|
||||||
waitForServerReady,
|
waitForServerReady,
|
||||||
} from './runtime.js';
|
} from './runtime.js';
|
||||||
import { getDesktopRuntimeIconPath } from './iconAssets.js';
|
import { getDesktopRuntimeIconPath, getDesktopTrayIconPath } from './iconAssets.js';
|
||||||
|
|
||||||
const { autoUpdater } = electronUpdater;
|
const { autoUpdater } = electronUpdater;
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ function getServerEntryPath() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTrayIconPath() {
|
function getTrayIconPath() {
|
||||||
return getDesktopRuntimeIconPath(app.getAppPath());
|
return getDesktopTrayIconPath(app.getAppPath(), process.platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWindowIconPath() {
|
function getWindowIconPath() {
|
||||||
@@ -121,7 +121,15 @@ function buildTrayMenu() {
|
|||||||
|
|
||||||
function setupTray() {
|
function setupTray() {
|
||||||
if (tray) return;
|
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 = new Tray(trayImage);
|
||||||
tray.setToolTip('Metapi');
|
tray.setToolTip('Metapi');
|
||||||
tray.setContextMenu(buildTrayMenu());
|
tray.setContextMenu(buildTrayMenu());
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ describe('buildConfig', () => {
|
|||||||
const config = buildConfig({});
|
const config = buildConfig({});
|
||||||
|
|
||||||
expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org');
|
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', () => {
|
it('ships CLI-aligned OAuth defaults', () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import type { FastifyServerOptions } from 'fastify';
|
import type { FastifyServerOptions } from 'fastify';
|
||||||
|
import { normalizePayloadRulesConfig } from './services/payloadRules.js';
|
||||||
|
|
||||||
const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024;
|
const DEFAULT_REQUEST_BODY_LIMIT = 20 * 1024 * 1024;
|
||||||
const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
const DEFAULT_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||||
@@ -32,6 +33,15 @@ function parseOptionalSecret(value: string | undefined): string {
|
|||||||
return (value || '').trim();
|
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' {
|
function parseDbType(value: string | undefined): 'sqlite' | 'mysql' | 'postgres' {
|
||||||
const normalized = (value || 'sqlite').trim().toLowerCase();
|
const normalized = (value || 'sqlite').trim().toLowerCase();
|
||||||
if (normalized === 'mysql') return 'mysql';
|
if (normalized === 'mysql') return 'mysql';
|
||||||
@@ -57,6 +67,10 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
|||||||
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
|
systemProxyUrl: env.SYSTEM_PROXY_URL || '',
|
||||||
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
accountCredentialSecret: env.ACCOUNT_CREDENTIAL_SECRET || env.AUTH_TOKEN || 'change-me-admin-token',
|
||||||
checkinCron: env.CHECKIN_CRON || '0 8 * * *',
|
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 * * * *',
|
balanceRefreshCron: env.BALANCE_REFRESH_CRON || '0 * * * *',
|
||||||
logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *',
|
logCleanupCron: env.LOG_CLEANUP_CRON || '0 6 * * *',
|
||||||
logCleanupConfigured: false,
|
logCleanupConfigured: false,
|
||||||
@@ -73,6 +87,8 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
|
|||||||
telegramApiBaseUrl: 'https://api.telegram.org',
|
telegramApiBaseUrl: 'https://api.telegram.org',
|
||||||
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
|
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
|
||||||
telegramChatId: env.TELEGRAM_CHAT_ID || '',
|
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),
|
smtpEnabled: parseBoolean(env.SMTP_ENABLED, false),
|
||||||
smtpHost: env.SMTP_HOST || '',
|
smtpHost: env.SMTP_HOST || '',
|
||||||
smtpPort: parseInt(env.SMTP_PORT || '587'),
|
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))),
|
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))),
|
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))),
|
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),
|
proxyErrorKeywords: parseCsvList(env.PROXY_ERROR_KEYWORDS),
|
||||||
proxyEmptyContentFailEnabled: parseBoolean(env.PROXY_EMPTY_CONTENT_FAIL, false),
|
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: {
|
routingWeights: {
|
||||||
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
|
baseWeightFactor: parseNumber(env.BASE_WEIGHT_FACTOR, 0.5),
|
||||||
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
|
valueScoreFactor: parseNumber(env.VALUE_SCORE_FACTOR, 0.5),
|
||||||
|
|||||||
@@ -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 `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` 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 `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` 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 `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` DATETIME DEFAULT CURRENT_TIMESTAMP, 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` 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 `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` DATETIME DEFAULT CURRENT_TIMESTAMP);
|
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` DATETIME DEFAULT CURRENT_TIMESTAMP, `is_manual` BOOLEAN DEFAULT false, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE);
|
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` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `deleted_at` DATETIME);
|
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` DATETIME DEFAULT CURRENT_TIMESTAMP, `billing_details` JSON, `downstream_api_key_id` INT);
|
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` DATETIME, `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP);
|
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` 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 `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` DATETIME, `last_selected_at` DATETIME, `last_fail_at` DATETIME, `consecutive_fail_count` INT NOT NULL DEFAULT 0, `cooldown_level` INT NOT NULL DEFAULT 0, `cooldown_until` DATETIME, FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON DELETE CASCADE, FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON DELETE CASCADE, FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON DELETE SET NULL);
|
CREATE TABLE IF NOT EXISTS `route_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 `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 `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 `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 `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_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 `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 `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_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 `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 `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 `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 `sites_platform_url_unique` ON `sites` (`platform`(191), `url`(191));
|
||||||
CREATE UNIQUE INDEX `token_model_availability_token_model_unique` ON `token_model_availability` (`token_id`, `model_name`(191));
|
CREATE 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 `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_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_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_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_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`);
|
||||||
CREATE INDEX `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`(191), `created_at`);
|
CREATE INDEX `proxy_logs_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_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_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 `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 `site_disabled_models_site_id_idx` ON `site_disabled_models` (`site_id`);
|
||||||
CREATE INDEX `sites_status_idx` ON `sites` (`status`(191));
|
CREATE INDEX `sites_status_idx` ON `sites` (`status`(191));
|
||||||
CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`);
|
CREATE INDEX `token_model_availability_available_idx` ON `token_model_availability` (`available`);
|
||||||
|
|||||||
@@ -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);
|
-- no schema changes detected for mysql
|
||||||
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`);
|
|
||||||
|
|||||||
@@ -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 "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" 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 "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" 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 "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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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" 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 "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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "is_manual" BOOLEAN DEFAULT false, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE);
|
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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "deleted_at" TIMESTAMP);
|
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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "billing_details" JSONB, "downstream_api_key_id" INTEGER);
|
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" TIMESTAMP, "created_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
|
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" 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 "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" TIMESTAMP, "last_selected_at" TIMESTAMP, "last_fail_at" TIMESTAMP, "consecutive_fail_count" INTEGER NOT NULL DEFAULT 0, "cooldown_level" INTEGER NOT NULL DEFAULT 0, "cooldown_until" TIMESTAMP, FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE, FOREIGN KEY ("route_id") REFERENCES "token_routes"("id") ON DELETE CASCADE, FOREIGN KEY ("token_id") REFERENCES "account_tokens"("id") ON DELETE SET NULL);
|
CREATE TABLE IF NOT EXISTS "route_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 "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 "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 "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 "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_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 "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 "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_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 "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 "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 "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 "sites_platform_url_unique" ON "sites" ("platform", "url");
|
||||||
CREATE UNIQUE INDEX "token_model_availability_token_model_unique" ON "token_model_availability" ("token_id", "model_name");
|
CREATE 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 "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_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_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_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_downstream_api_key_created_at_idx" ON "proxy_logs" ("downstream_api_key_id", "created_at");
|
||||||
CREATE INDEX "proxy_logs_model_actual_created_at_idx" ON "proxy_logs" ("model_actual", "created_at");
|
CREATE INDEX "proxy_logs_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_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_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 "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 "site_disabled_models_site_id_idx" ON "site_disabled_models" ("site_id");
|
||||||
CREATE INDEX "sites_status_idx" ON "sites" ("status");
|
CREATE INDEX "sites_status_idx" ON "sites" ("status");
|
||||||
CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available");
|
CREATE INDEX "token_model_availability_available_idx" ON "token_model_availability" ("available");
|
||||||
|
|||||||
@@ -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);
|
-- no schema changes detected for postgres
|
||||||
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");
|
|
||||||
|
|||||||
@@ -653,6 +653,30 @@
|
|||||||
"notNull": false,
|
"notNull": false,
|
||||||
"defaultValue": null,
|
"defaultValue": null,
|
||||||
"primaryKey": false
|
"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": {
|
"site_disabled_models": {
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
@@ -1350,6 +1480,24 @@
|
|||||||
],
|
],
|
||||||
"unique": false
|
"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",
|
"name": "proxy_logs_created_at_idx",
|
||||||
"table": "proxy_logs",
|
"table": "proxy_logs",
|
||||||
@@ -1468,6 +1616,32 @@
|
|||||||
],
|
],
|
||||||
"unique": false
|
"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",
|
"name": "site_disabled_models_site_id_idx",
|
||||||
"table": "site_disabled_models",
|
"table": "site_disabled_models",
|
||||||
@@ -1591,6 +1765,14 @@
|
|||||||
"source_route_id"
|
"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",
|
"name": "site_disabled_models_site_model_unique",
|
||||||
"table": "site_disabled_models",
|
"table": "site_disabled_models",
|
||||||
@@ -1716,6 +1898,17 @@
|
|||||||
],
|
],
|
||||||
"onDelete": "CASCADE"
|
"onDelete": "CASCADE"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"table": "site_announcements",
|
||||||
|
"columns": [
|
||||||
|
"site_id"
|
||||||
|
],
|
||||||
|
"referencedTable": "sites",
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "CASCADE"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"table": "site_disabled_models",
|
"table": "site_disabled_models",
|
||||||
"columns": [
|
"columns": [
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const TABLES_WITH_NUMERIC_ID = new Set([
|
|||||||
'proxy_video_tasks',
|
'proxy_video_tasks',
|
||||||
'proxy_files',
|
'proxy_files',
|
||||||
'downstream_api_keys',
|
'downstream_api_keys',
|
||||||
|
'site_announcements',
|
||||||
'events',
|
'events',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ let mysqlPool: mysql.Pool | null = null;
|
|||||||
let pgPool: pg.Pool | null = null;
|
let pgPool: pg.Pool | null = null;
|
||||||
let proxyLogBillingDetailsColumnAvailable: boolean | null = null;
|
let proxyLogBillingDetailsColumnAvailable: boolean | null = null;
|
||||||
let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null;
|
let proxyLogDownstreamApiKeyIdColumnAvailable: boolean | null = null;
|
||||||
|
let proxyLogClientColumnsAvailable: boolean | null = null;
|
||||||
|
|
||||||
function resolveSqlitePath(): string {
|
function resolveSqlitePath(): string {
|
||||||
const raw = (config.dbUrl || '').trim();
|
const raw = (config.dbUrl || '').trim();
|
||||||
@@ -119,6 +121,13 @@ function tableColumnExists(table: string, column: string): boolean {
|
|||||||
return rows.some((row) => row.name === column);
|
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 {
|
function execSqliteStatement(sqlText: string): void {
|
||||||
requireSqliteConnection().exec(sqlText);
|
requireSqliteConnection().exec(sqlText);
|
||||||
}
|
}
|
||||||
@@ -173,6 +182,8 @@ function ensureTokenManagementSchema() {
|
|||||||
WHERE
|
WHERE
|
||||||
a.api_token IS NOT NULL
|
a.api_token IS NOT NULL
|
||||||
AND trim(a.api_token) <> ''
|
AND trim(a.api_token) <> ''
|
||||||
|
AND a.access_token IS NOT NULL
|
||||||
|
AND trim(a.access_token) <> ''
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM account_tokens AS t
|
SELECT 1 FROM account_tokens AS t
|
||||||
WHERE t.account_id = a.id
|
WHERE t.account_id = a.id
|
||||||
@@ -587,6 +598,40 @@ function ensureProxyLogDownstreamApiKeyIdSchema() {
|
|||||||
proxyLogDownstreamApiKeyIdColumnAvailable = true;
|
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 {
|
function normalizeSchemaErrorMessage(error: unknown): string {
|
||||||
if (typeof error === 'object' && error && 'message' in error) {
|
if (typeof error === 'object' && error && 'message' in error) {
|
||||||
return String((error as { message?: unknown }).message || '');
|
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');
|
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> {
|
export async function hasProxyLogBillingDetailsColumn(): Promise<boolean> {
|
||||||
if (proxyLogBillingDetailsColumnAvailable !== null) {
|
if (proxyLogBillingDetailsColumnAvailable !== null) {
|
||||||
return proxyLogBillingDetailsColumnAvailable;
|
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() {
|
function resetSchemaCapabilityCache() {
|
||||||
proxyLogBillingDetailsColumnAvailable = null;
|
proxyLogBillingDetailsColumnAvailable = null;
|
||||||
proxyLogDownstreamApiKeyIdColumnAvailable = null;
|
proxyLogDownstreamApiKeyIdColumnAvailable = null;
|
||||||
|
proxyLogClientColumnsAvailable = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) {
|
async function sqliteProxyQuery(sqlText: string, params: unknown[], method: SqlMethod) {
|
||||||
@@ -994,6 +1221,7 @@ function initSqliteDb() {
|
|||||||
ensureRouteGroupingSchema();
|
ensureRouteGroupingSchema();
|
||||||
ensureDownstreamApiKeySchema();
|
ensureDownstreamApiKeySchema();
|
||||||
ensureProxyLogBillingDetailsSchema();
|
ensureProxyLogBillingDetailsSchema();
|
||||||
|
ensureProxyLogClientSchema();
|
||||||
ensureProxyVideoTaskSchema();
|
ensureProxyVideoTaskSchema();
|
||||||
ensureProxyFileSchema();
|
ensureProxyFileSchema();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { classifyLegacyCompatMutation } from './legacySchemaCompat.js';
|
|||||||
describe('legacy schema compat boundary', () => {
|
describe('legacy schema compat boundary', () => {
|
||||||
it('allows only explicitly registered legacy upgrade shims', () => {
|
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 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('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('ALTER TABLE sites ADD COLUMN brand_new_column text;')).toBe('forbidden');
|
||||||
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "brand_new_column" = 1')).toBe('forbidden');
|
expect(classifyLegacyCompatMutation('UPDATE "sites" SET "brand_new_column" = 1')).toBe('forbidden');
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ const LEGACY_COMPAT_COLUMNS = new Set([
|
|||||||
'downstream_api_keys.group_name',
|
'downstream_api_keys.group_name',
|
||||||
'downstream_api_keys.tags',
|
'downstream_api_keys.tags',
|
||||||
'proxy_logs.billing_details',
|
'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',
|
'proxy_logs.downstream_api_key_id',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -81,6 +85,8 @@ const LEGACY_COMPAT_INDEXES = new Set([
|
|||||||
'downstream_api_keys_name_idx',
|
'downstream_api_keys_name_idx',
|
||||||
'downstream_api_keys_enabled_idx',
|
'downstream_api_keys_enabled_idx',
|
||||||
'downstream_api_keys_expires_at_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',
|
'proxy_logs_downstream_api_key_created_at_idx',
|
||||||
'route_group_sources_group_source_unique',
|
'route_group_sources_group_source_unique',
|
||||||
'route_group_sources_source_route_id_idx',
|
'route_group_sources_source_route_id_idx',
|
||||||
|
|||||||
@@ -193,6 +193,10 @@ export const proxyLogs = sqliteTable('proxy_logs', {
|
|||||||
totalTokens: integer('total_tokens'),
|
totalTokens: integer('total_tokens'),
|
||||||
estimatedCost: real('estimated_cost'),
|
estimatedCost: real('estimated_cost'),
|
||||||
billingDetails: text('billing_details'),
|
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'),
|
errorMessage: text('error_message'),
|
||||||
retryCount: integer('retry_count').default(0),
|
retryCount: integer('retry_count').default(0),
|
||||||
createdAt: text('created_at').default(sql`(datetime('now'))`),
|
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),
|
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),
|
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),
|
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', {
|
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),
|
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', {
|
export const events = sqliteTable('events', {
|
||||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||||
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
|
type: text('type').notNull(), // 'checkin' | 'balance' | 'token' | 'proxy' | 'status'
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ describe('schema artifact generator', () => {
|
|||||||
).toBeLessThan(
|
).toBeLessThan(
|
||||||
artifacts.postgresBootstrap.indexOf('CREATE TABLE IF NOT EXISTS "account_tokens"'),
|
artifacts.postgresBootstrap.indexOf('CREATE TABLE IF NOT EXISTS "account_tokens"'),
|
||||||
);
|
);
|
||||||
expect(artifacts.mysqlBootstrap).toContain('`created_at` DATETIME 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" TIMESTAMP DEFAULT CURRENT_TIMESTAMP');
|
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', () => {
|
it('uses varchar for mysql text primary keys so bootstrap ddl stays executable', () => {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaCo
|
|||||||
case 'real':
|
case 'real':
|
||||||
return 'DOUBLE';
|
return 'DOUBLE';
|
||||||
case 'datetime':
|
case 'datetime':
|
||||||
return 'DATETIME';
|
return 'VARCHAR(191)';
|
||||||
case 'json':
|
case 'json':
|
||||||
return 'JSON';
|
return 'JSON';
|
||||||
case 'text':
|
case 'text':
|
||||||
@@ -99,7 +99,7 @@ function mapColumnType(dialect: SqlDialect, columnName: string, column: SchemaCo
|
|||||||
case 'real':
|
case 'real':
|
||||||
return 'DOUBLE PRECISION';
|
return 'DOUBLE PRECISION';
|
||||||
case 'datetime':
|
case 'datetime':
|
||||||
return 'TIMESTAMP';
|
return 'TEXT';
|
||||||
case 'json':
|
case 'json':
|
||||||
return 'JSONB';
|
return 'JSONB';
|
||||||
case 'text':
|
case 'text':
|
||||||
@@ -117,7 +117,10 @@ function formatDefaultValue(dialect: SqlDialect, column: SchemaContractColumn):
|
|||||||
if (dialect === 'sqlite') {
|
if (dialect === 'sqlite') {
|
||||||
return " DEFAULT (datetime('now'))";
|
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') {
|
if (column.logicalType === 'boolean') {
|
||||||
|
|||||||
@@ -85,10 +85,18 @@ describe('database schema parity', () => {
|
|||||||
const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8');
|
const postgresBootstrap = readFileSync(resolve(generatedDir, 'postgres.bootstrap.sql'), 'utf8');
|
||||||
|
|
||||||
expect(contract.tables.proxy_logs?.columns.downstream_api_key_id?.logicalType).toBe('integer');
|
expect(contract.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_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('`downstream_api_key_id`');
|
||||||
expect(mysqlBootstrap).toContain('`proxy_logs_downstream_api_key_created_at_idx`');
|
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('"downstream_api_key_id"');
|
||||||
expect(postgresBootstrap).toContain('"proxy_logs_downstream_api_key_created_at_idx"');
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
+49
-2
@@ -2,6 +2,7 @@ import Fastify from 'fastify';
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { buildFastifyOptions, config } from './config.js';
|
import { buildFastifyOptions, config } from './config.js';
|
||||||
|
import { normalizePayloadRulesConfig } from './services/payloadRules.js';
|
||||||
import { authMiddleware } from './middleware/auth.js';
|
import { authMiddleware } from './middleware/auth.js';
|
||||||
import { sitesRoutes } from './routes/api/sites.js';
|
import { sitesRoutes } from './routes/api/sites.js';
|
||||||
import { accountsRoutes } from './routes/api/accounts.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 { monitorRoutes } from './routes/api/monitor.js';
|
||||||
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
import { downstreamApiKeysRoutes } from './routes/api/downstreamApiKeys.js';
|
||||||
import { oauthRoutes } from './routes/api/oauth.js';
|
import { oauthRoutes } from './routes/api/oauth.js';
|
||||||
|
import { siteAnnouncementsRoutes } from './routes/api/siteAnnouncements.js';
|
||||||
import { proxyRoutes } from './routes/proxy/router.js';
|
import { proxyRoutes } from './routes/proxy/router.js';
|
||||||
import { startScheduler } from './services/checkinScheduler.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 { setLegacyProxyLogRetentionFallbackEnabled, stopProxyLogRetentionService } from './services/proxyLogRetentionService.js';
|
||||||
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
import { buildStartupSummaryLines } from './services/startupInfo.js';
|
||||||
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
import { repairStoredCreatedAtValues } from './services/storedTimestampRepairService.js';
|
||||||
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
import { migrateSiteApiKeysToAccounts } from './services/siteApiKeyMigrationService.js';
|
||||||
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
||||||
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.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 { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
||||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
@@ -35,6 +41,7 @@ import { normalizeLogCleanupRetentionDays } from './services/logCleanupService.j
|
|||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
ensureProxyFileCompatibilityColumns,
|
ensureProxyFileCompatibilityColumns,
|
||||||
|
ensureProxyLogClientColumns,
|
||||||
ensureProxyLogDownstreamApiKeyIdColumn,
|
ensureProxyLogDownstreamApiKeyIdColumn,
|
||||||
ensureProxyLogBillingDetailsColumn,
|
ensureProxyLogBillingDetailsColumn,
|
||||||
ensureRouteGroupingCompatibilityColumns,
|
ensureRouteGroupingCompatibilityColumns,
|
||||||
@@ -139,9 +146,36 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
|
|||||||
config.proxyEmptyContentFailEnabled = proxyEmptyContentFailEnabled;
|
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');
|
const checkinCron = parseSettingFromMap<string>(settingsMap, 'checkin_cron');
|
||||||
if (typeof checkinCron === 'string' && checkinCron) config.checkinCron = checkinCron;
|
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');
|
const balanceRefreshCron = parseSettingFromMap<string>(settingsMap, 'balance_refresh_cron');
|
||||||
if (typeof balanceRefreshCron === 'string' && balanceRefreshCron) config.balanceRefreshCron = balanceRefreshCron;
|
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');
|
const telegramChatId = parseSettingFromMap<string>(settingsMap, 'telegram_chat_id');
|
||||||
if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId;
|
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');
|
const smtpEnabled = parseSettingFromMap<boolean>(settingsMap, 'smtp_enabled');
|
||||||
if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled;
|
if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled;
|
||||||
|
|
||||||
@@ -277,6 +317,7 @@ try {
|
|||||||
await ensureSiteCompatibilityColumns();
|
await ensureSiteCompatibilityColumns();
|
||||||
await ensureRouteGroupingCompatibilityColumns();
|
await ensureRouteGroupingCompatibilityColumns();
|
||||||
await ensureProxyFileCompatibilityColumns();
|
await ensureProxyFileCompatibilityColumns();
|
||||||
|
await ensureProxyLogClientColumns();
|
||||||
await ensureProxyLogDownstreamApiKeyIdColumn();
|
await ensureProxyLogDownstreamApiKeyIdColumn();
|
||||||
const finalRows = await db.select().from(schema.settings).all();
|
const finalRows = await db.select().from(schema.settings).all();
|
||||||
const finalMap = toSettingsMap(finalRows);
|
const finalMap = toSettingsMap(finalRows);
|
||||||
@@ -291,6 +332,7 @@ try {
|
|||||||
await repairStoredCreatedAtValues();
|
await repairStoredCreatedAtValues();
|
||||||
await migrateSiteApiKeysToAccounts();
|
await migrateSiteApiKeysToAccounts();
|
||||||
await ensureDefaultSitesSeeded();
|
await ensureDefaultSitesSeeded();
|
||||||
|
await rebuildTokenRoutesFromAvailability();
|
||||||
|
|
||||||
console.log('Loaded runtime settings overrides');
|
console.log('Loaded runtime settings overrides');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -319,8 +361,9 @@ await app.register(authRoutes);
|
|||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(accountTokensRoutes);
|
await app.register(accountTokensRoutes);
|
||||||
await app.register(searchRoutes);
|
await app.register(searchRoutes);
|
||||||
await app.register(eventsRoutes);
|
await app.register(eventsRoutes);
|
||||||
await app.register(taskRoutes);
|
await app.register(siteAnnouncementsRoutes);
|
||||||
|
await app.register(taskRoutes);
|
||||||
await app.register(testRoutes);
|
await app.register(testRoutes);
|
||||||
await app.register(monitorRoutes);
|
await app.register(monitorRoutes);
|
||||||
await app.register(downstreamApiKeysRoutes);
|
await app.register(downstreamApiKeysRoutes);
|
||||||
@@ -358,13 +401,17 @@ if (existsSync(webDir)) {
|
|||||||
|
|
||||||
// Start scheduler
|
// Start scheduler
|
||||||
await startScheduler();
|
await startScheduler();
|
||||||
|
await reloadBackupWebdavScheduler();
|
||||||
|
startSiteAnnouncementPolling();
|
||||||
try {
|
try {
|
||||||
await startOAuthLoopbackCallbackServers();
|
await startOAuthLoopbackCallbackServers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`);
|
console.warn(`Failed to start OAuth callback listeners: ${(error as Error)?.message || 'unknown error'}`);
|
||||||
}
|
}
|
||||||
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
||||||
|
startProxyFileRetentionService();
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
stopProxyFileRetentionService();
|
||||||
stopProxyLogRetentionService();
|
stopProxyLogRetentionService();
|
||||||
await stopOAuthLoopbackCallbackServers();
|
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';
|
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 {
|
function headerValueToString(value: unknown): string | null {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const trimmed = value.trim();
|
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 {
|
function isCodexPath(path: string): boolean {
|
||||||
const normalizedPath = path.trim().toLowerCase();
|
const normalizedPath = path.trim().toLowerCase();
|
||||||
return normalizedPath.startsWith('/v1/responses')
|
return normalizedPath.startsWith('/v1/responses')
|
||||||
@@ -62,7 +90,8 @@ export function isCodexRequest(input: DetectCliProfileInput): boolean {
|
|||||||
if (!headers) return false;
|
if (!headers) return false;
|
||||||
|
|
||||||
const originator = getHeaderValue(headers, 'originator');
|
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 (getHeaderValue(headers, 'openai-beta')) return true;
|
||||||
if (hasHeaderPrefix(headers, 'x-stainless-')) return true;
|
if (hasHeaderPrefix(headers, 'x-stainless-')) return true;
|
||||||
if (getCodexSessionId(headers)) 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', () => {
|
it('detects Claude Code requests on the count_tokens surface and exposes token counting support', () => {
|
||||||
expect(detectCliProfile({
|
expect(detectCliProfile({
|
||||||
downstreamPath: '/v1/messages/count_tokens',
|
downstreamPath: '/v1/messages/count_tokens',
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
|
import type { PreparedProviderRequest, PrepareProviderRequestInput, ProviderProfile } from './types.js';
|
||||||
|
import { config } from '../../config.js';
|
||||||
|
|
||||||
const CODEX_CLIENT_VERSION = '0.101.0';
|
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_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 {
|
function asTrimmedString(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -53,6 +55,7 @@ function uuidFromSeed(seed: string): string {
|
|||||||
function buildCodexRuntimeHeaders(input: {
|
function buildCodexRuntimeHeaders(input: {
|
||||||
baseHeaders: Record<string, string>;
|
baseHeaders: Record<string, string>;
|
||||||
providerHeaders?: Record<string, string>;
|
providerHeaders?: Record<string, string>;
|
||||||
|
oauthProvider?: string;
|
||||||
explicitSessionId?: string | null;
|
explicitSessionId?: string | null;
|
||||||
continuityKey?: string | null;
|
continuityKey?: string | null;
|
||||||
}): Record<string, string> {
|
}): Record<string, string> {
|
||||||
@@ -64,7 +67,21 @@ function buildCodexRuntimeHeaders(input: {
|
|||||||
const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs';
|
const originator = getInputHeader(input.providerHeaders, 'originator') || 'codex_cli_rs';
|
||||||
const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id');
|
const accountId = getInputHeader(input.providerHeaders, 'chatgpt-account-id');
|
||||||
const version = getInputHeader(input.baseHeaders, 'version') || CODEX_CLIENT_VERSION;
|
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 explicitSessionId = asTrimmedString(input.explicitSessionId);
|
||||||
const continuityKey = asTrimmedString(input.continuityKey);
|
const continuityKey = asTrimmedString(input.continuityKey);
|
||||||
const sessionId = (
|
const sessionId = (
|
||||||
@@ -87,6 +104,11 @@ function buildCodexRuntimeHeaders(input: {
|
|||||||
...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}),
|
...(accountId ? { 'Chatgpt-Account-Id': accountId } : {}),
|
||||||
Originator: originator,
|
Originator: originator,
|
||||||
Version: version,
|
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,
|
Session_id: sessionId,
|
||||||
...(conversationId ? { Conversation_id: conversationId } : {}),
|
...(conversationId ? { Conversation_id: conversationId } : {}),
|
||||||
'User-Agent': userAgent,
|
'User-Agent': userAgent,
|
||||||
@@ -101,6 +123,7 @@ export const codexProviderProfile: ProviderProfile = {
|
|||||||
const headers = buildCodexRuntimeHeaders({
|
const headers = buildCodexRuntimeHeaders({
|
||||||
baseHeaders: input.baseHeaders,
|
baseHeaders: input.baseHeaders,
|
||||||
providerHeaders: input.providerHeaders,
|
providerHeaders: input.providerHeaders,
|
||||||
|
oauthProvider: input.oauthProvider,
|
||||||
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
|
explicitSessionId: asTrimmedString(input.codexExplicitSessionId) || null,
|
||||||
continuityKey: asTrimmedString(input.codexSessionCacheKey) || 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()];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
@@ -13,6 +12,8 @@ import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
|
|||||||
import {
|
import {
|
||||||
buildClaudeCountTokensUpstreamRequest,
|
buildClaudeCountTokensUpstreamRequest,
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
|
recordUpstreamEndpointFailure,
|
||||||
|
recordUpstreamEndpointSuccess,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from '../../routes/proxy/upstreamEndpoint.js';
|
} from '../../routes/proxy/upstreamEndpoint.js';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
||||||
|
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
||||||
import { openAiChatTransformer } from '../../transformers/openai/chat/index.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 { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
ProxyInputFileResolutionError,
|
ProxyInputFileResolutionError,
|
||||||
hasNonImageFileInputInOpenAiBody,
|
|
||||||
resolveOpenAiBodyInputFiles,
|
resolveOpenAiBodyInputFiles,
|
||||||
} from '../../services/proxyInputFileResolver.js';
|
} from '../../services/proxyInputFileResolver.js';
|
||||||
import {
|
import {
|
||||||
@@ -39,14 +40,20 @@ import {
|
|||||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.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 {
|
import {
|
||||||
createGeminiCliStreamReader,
|
createGeminiCliStreamReader,
|
||||||
unwrapGeminiCliPayload,
|
unwrapGeminiCliPayload,
|
||||||
} from '../../routes/proxy/geminiCliCompat.js';
|
} from '../../routes/proxy/geminiCliCompat.js';
|
||||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
|
||||||
import { detectCliProfile } from '../cliProfiles/registry.js';
|
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||||
import type { CliProfileId } from '../cliProfiles/types.js';
|
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -58,25 +65,6 @@ function asTrimmedString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
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(
|
export async function handleChatSurfaceRequest(
|
||||||
request: FastifyRequest,
|
request: FastifyRequest,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
@@ -117,7 +105,8 @@ export async function handleChatSurfaceRequest(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody);
|
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(resolvedOpenAiBody);
|
||||||
|
const hasNonImageFileInput = conversationFileSummary.hasDocument;
|
||||||
const codexSessionCacheKey = deriveCodexSessionCacheKey({
|
const codexSessionCacheKey = deriveCodexSessionCacheKey({
|
||||||
downstreamFormat,
|
downstreamFormat,
|
||||||
body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body,
|
body: downstreamFormat === 'claude' ? claudeOriginalBody : request.body,
|
||||||
@@ -125,8 +114,6 @@ export async function handleChatSurfaceRequest(
|
|||||||
proxyToken: getProxyAuthContext(request)?.token || null,
|
proxyToken: getProxyAuthContext(request)?.token || null,
|
||||||
});
|
});
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
|
||||||
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
@@ -167,9 +154,20 @@ export async function handleChatSurfaceRequest(
|
|||||||
requestedModel,
|
requestedModel,
|
||||||
{
|
{
|
||||||
hasNonImageFileInput,
|
hasNonImageFileInput,
|
||||||
|
conversationFileSummary,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
const endpointRuntimeContext = {
|
||||||
|
siteId: selected.site.id,
|
||||||
|
modelName,
|
||||||
|
downstreamFormat,
|
||||||
|
requestedModelHint: requestedModel,
|
||||||
|
requestCapabilities: {
|
||||||
|
hasNonImageFileInput,
|
||||||
|
conversationFileSummary,
|
||||||
|
},
|
||||||
|
};
|
||||||
const buildProviderHeaders = () => (
|
const buildProviderHeaders = () => (
|
||||||
buildOauthProviderHeaders({
|
buildOauthProviderHeaders({
|
||||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||||
@@ -245,7 +243,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||||
};
|
};
|
||||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
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);
|
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
||||||
if (refreshedResponse.ok) {
|
if (refreshedResponse.ok) {
|
||||||
return {
|
return {
|
||||||
@@ -272,6 +270,20 @@ export async function handleChatSurfaceRequest(
|
|||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
tryRecover,
|
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,
|
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||||
onDowngrade: (ctx) => {
|
onDowngrade: (ctx) => {
|
||||||
logProxy(
|
logProxy(
|
||||||
@@ -290,7 +302,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -299,7 +311,11 @@ export async function handleChatSurfaceRequest(
|
|||||||
const status = endpointResult.status || 502;
|
const status = endpointResult.status || 502;
|
||||||
const errText = endpointResult.errText || 'unknown error';
|
const errText = endpointResult.errText || 'unknown error';
|
||||||
const rawErrText = endpointResult.rawErrText || errText;
|
const rawErrText = endpointResult.rawErrText || errText;
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status,
|
||||||
|
errorText: rawErrText,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -316,7 +332,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
await recordOauthQuotaResetHint({
|
await recordOauthQuotaResetHint({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -394,6 +410,37 @@ export async function handleChatSurfaceRequest(
|
|||||||
if (!upstreamContentType.includes('text/event-stream')) {
|
if (!upstreamContentType.includes('text/event-stream')) {
|
||||||
const fallbackText = await upstream.text();
|
const fallbackText = await upstream.text();
|
||||||
rawText = fallbackText;
|
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;
|
let fallbackData: unknown = null;
|
||||||
try {
|
try {
|
||||||
fallbackData = JSON.parse(fallbackText);
|
fallbackData = JSON.parse(fallbackText);
|
||||||
@@ -407,7 +454,11 @@ export async function handleChatSurfaceRequest(
|
|||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: failure.status,
|
||||||
|
errorText: failure.reason,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -424,7 +475,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||||
@@ -445,7 +496,11 @@ export async function handleChatSurfaceRequest(
|
|||||||
startSseResponse();
|
startSseResponse();
|
||||||
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: streamResult.errorMessage,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -462,7 +517,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -495,7 +550,11 @@ export async function handleChatSurfaceRequest(
|
|||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: streamResult.errorMessage,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -512,7 +571,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -548,7 +607,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
@@ -566,7 +625,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
billingDetails,
|
billingDetails,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -580,11 +639,15 @@ export async function handleChatSurfaceRequest(
|
|||||||
upstreamData = collected.payload;
|
upstreamData = collected.payload;
|
||||||
} else {
|
} else {
|
||||||
rawText = await upstream.text();
|
rawText = await upstream.text();
|
||||||
upstreamData = rawText;
|
if (looksLikeResponsesSseText(rawText)) {
|
||||||
try {
|
upstreamData = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload;
|
||||||
upstreamData = JSON.parse(rawText);
|
} else {
|
||||||
} catch {
|
|
||||||
upstreamData = rawText;
|
upstreamData = rawText;
|
||||||
|
try {
|
||||||
|
upstreamData = JSON.parse(rawText);
|
||||||
|
} catch {
|
||||||
|
upstreamData = rawText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||||
@@ -595,7 +658,11 @@ export async function handleChatSurfaceRequest(
|
|||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
const parsedUsage = parseProxyUsage(upstreamData);
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: failure.status,
|
||||||
|
errorText: failure.reason,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -612,7 +679,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||||
@@ -656,7 +723,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
@@ -674,12 +741,15 @@ export async function handleChatSurfaceRequest(
|
|||||||
billingDetails,
|
billingDetails,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return reply.send(downstreamResponse);
|
return reply.send(downstreamResponse);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
errorText: err?.message,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -696,7 +766,7 @@ export async function handleChatSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
@@ -775,8 +845,6 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
});
|
});
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
@@ -883,7 +951,11 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: upstream.status,
|
||||||
|
errorText: typeof payload === 'string' ? payload : text,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -900,7 +972,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
upstreamRequest.path,
|
upstreamRequest.path,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(upstream.status, typeof payload === 'string' ? payload : text) && retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -909,7 +981,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
return reply.code(upstream.status).type(contentType).send(payload);
|
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);
|
recordDownstreamCostUsage(request, 0);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
@@ -927,11 +999,14 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
upstreamRequest.path,
|
upstreamRequest.path,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return reply.code(upstream.status).type(contentType).send(payload);
|
return reply.code(upstream.status).type(contentType).send(payload);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
errorText: error?.message,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -948,7 +1023,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -994,11 +1069,11 @@ async function logProxy(
|
|||||||
upstreamPath,
|
upstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamApiKeyId,
|
||||||
modelRequested,
|
modelRequested,
|
||||||
modelActual: selected.actualModel,
|
modelActual: selected.actualModel,
|
||||||
status,
|
status,
|
||||||
@@ -1008,11 +1083,15 @@ async function logProxy(
|
|||||||
completionTokens,
|
completionTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
estimatedCost,
|
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,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/chat] failed to write proxy log', error);
|
console.warn('[proxy/chat] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/pro
|
|||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
import {
|
import {
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
|
recordUpstreamEndpointFailure,
|
||||||
|
recordUpstreamEndpointSuccess,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from '../../routes/proxy/upstreamEndpoint.js';
|
} from '../../routes/proxy/upstreamEndpoint.js';
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +31,9 @@ import {
|
|||||||
wrapGeminiCliRequest,
|
wrapGeminiCliRequest,
|
||||||
} from '../../routes/proxy/geminiCliCompat.js';
|
} from '../../routes/proxy/geminiCliCompat.js';
|
||||||
import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.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 MAX_RETRIES = 2;
|
||||||
const GEMINI_MODEL_PROBES = [
|
const GEMINI_MODEL_PROBES = [
|
||||||
@@ -114,10 +119,10 @@ function resolveUpstreamPath(apiVersion: string, modelActionPath: string): strin
|
|||||||
return `/${normalizedVersion}/${normalizedAction}`;
|
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 supportedModels = Array.isArray(policy.supportedModels) ? policy.supportedModels : [];
|
||||||
const allowedRouteIds = Array.isArray(policy.allowedRouteIds) ? policy.allowedRouteIds : [];
|
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 {
|
function extractGeminiListedModelName(item: unknown): string {
|
||||||
@@ -203,6 +208,7 @@ async function logProxy(
|
|||||||
retryCount: number,
|
retryCount: number,
|
||||||
downstreamPath: string,
|
downstreamPath: string,
|
||||||
upstreamPath: string | null,
|
upstreamPath: string | null,
|
||||||
|
clientContext: DownstreamClientContext | null = null,
|
||||||
promptTokens = 0,
|
promptTokens = 0,
|
||||||
completionTokens = 0,
|
completionTokens = 0,
|
||||||
totalTokens = 0,
|
totalTokens = 0,
|
||||||
@@ -210,11 +216,16 @@ async function logProxy(
|
|||||||
try {
|
try {
|
||||||
const createdAt = formatUtcSqlDateTime(new Date());
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
|
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||||
|
? clientContext.clientKind
|
||||||
|
: null,
|
||||||
|
sessionId: clientContext?.sessionId || null,
|
||||||
|
traceHint: clientContext?.traceHint || null,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -227,11 +238,14 @@ async function logProxy(
|
|||||||
completionTokens,
|
completionTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
estimatedCost: 0,
|
estimatedCost: 0,
|
||||||
billingDetails: null,
|
clientFamily: clientContext?.clientKind || null,
|
||||||
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/gemini] failed to write proxy log', error);
|
console.warn('[proxy/gemini] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
@@ -285,7 +299,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
lastStatus = upstream.status;
|
lastStatus = upstream.status;
|
||||||
lastText = text;
|
lastText = text;
|
||||||
lastContentType = upstream.headers.get('content-type') || 'application/json';
|
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) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
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);
|
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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;
|
lastStatus = 502;
|
||||||
lastContentType = 'application/json';
|
lastContentType = 'application/json';
|
||||||
lastText = JSON.stringify({
|
lastText = JSON.stringify({
|
||||||
@@ -355,6 +374,11 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
const policy = getDownstreamRoutingPolicy(request);
|
const policy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamPath = resolveDownstreamPath(request);
|
const downstreamPath = resolveDownstreamPath(request);
|
||||||
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body: request.body,
|
||||||
|
});
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
let lastStatus = 503;
|
let lastStatus = 503;
|
||||||
@@ -394,7 +418,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
type: 'server_error',
|
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) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -478,7 +505,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
lastStatus = upstream.status;
|
lastStatus = upstream.status;
|
||||||
lastContentType = contentType;
|
lastContentType = contentType;
|
||||||
lastText = await upstream.text();
|
lastText = await upstream.text();
|
||||||
await tokenRouter.recordFailure?.(selected.channel.id);
|
await tokenRouter.recordFailure?.(selected.channel.id, {
|
||||||
|
status: upstream.status,
|
||||||
|
errorText: lastText,
|
||||||
|
});
|
||||||
await logProxy(
|
await logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -489,6 +519,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -523,6 +554,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
);
|
);
|
||||||
reply.raw.end();
|
reply.raw.end();
|
||||||
return;
|
return;
|
||||||
@@ -572,6 +604,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
parsedUsage.promptTokens,
|
parsedUsage.promptTokens,
|
||||||
parsedUsage.completionTokens,
|
parsedUsage.completionTokens,
|
||||||
parsedUsage.totalTokens,
|
parsedUsage.totalTokens,
|
||||||
@@ -607,6 +640,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
parsedUsage.promptTokens,
|
parsedUsage.promptTokens,
|
||||||
parsedUsage.completionTokens,
|
parsedUsage.completionTokens,
|
||||||
parsedUsage.totalTokens,
|
parsedUsage.totalTokens,
|
||||||
@@ -629,6 +663,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
);
|
);
|
||||||
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
return reply.code(upstream.status).type(contentType || 'application/json').send(text);
|
||||||
}
|
}
|
||||||
@@ -651,6 +686,8 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
modelName: actualModel,
|
modelName: actualModel,
|
||||||
stream: isStreamAction,
|
stream: isStreamAction,
|
||||||
});
|
});
|
||||||
|
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(openAiBody);
|
||||||
|
const hasNonImageFileInput = conversationFileSummary.hasDocument;
|
||||||
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||||
{
|
{
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
@@ -659,7 +696,21 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
actualModel,
|
actualModel,
|
||||||
'openai',
|
'openai',
|
||||||
requestedModel,
|
requestedModel,
|
||||||
|
{
|
||||||
|
hasNonImageFileInput,
|
||||||
|
conversationFileSummary,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
const endpointRuntimeContext = {
|
||||||
|
siteId: selected.site.id,
|
||||||
|
modelName: actualModel,
|
||||||
|
downstreamFormat: 'openai' as const,
|
||||||
|
requestedModelHint: requestedModel,
|
||||||
|
requestCapabilities: {
|
||||||
|
hasNonImageFileInput,
|
||||||
|
conversationFileSummary,
|
||||||
|
},
|
||||||
|
};
|
||||||
const buildEndpointRequest = (
|
const buildEndpointRequest = (
|
||||||
endpoint: 'chat' | 'messages' | 'responses',
|
endpoint: 'chat' | 'messages' | 'responses',
|
||||||
requestOptions: { forceNormalizeClaudeBody?: boolean } = {},
|
requestOptions: { forceNormalizeClaudeBody?: boolean } = {},
|
||||||
@@ -721,6 +772,20 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
tryRecover: endpointStrategy.tryRecover,
|
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,
|
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||||
});
|
});
|
||||||
if (!endpointResult.ok) {
|
if (!endpointResult.ok) {
|
||||||
@@ -732,7 +797,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
type: 'upstream_error',
|
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(
|
await logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -743,6 +811,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
null,
|
null,
|
||||||
|
clientContext,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -780,6 +849,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath,
|
upstreamPath,
|
||||||
|
clientContext,
|
||||||
parsedUsage.promptTokens,
|
parsedUsage.promptTokens,
|
||||||
parsedUsage.completionTokens,
|
parsedUsage.completionTokens,
|
||||||
parsedUsage.totalTokens,
|
parsedUsage.totalTokens,
|
||||||
@@ -798,7 +868,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
type: 'upstream_error',
|
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(
|
await logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -809,6 +881,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
|
|||||||
retryCount,
|
retryCount,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
upstreamPath || null,
|
upstreamPath || null,
|
||||||
|
clientContext,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { TextDecoder } from 'node:util';
|
import { TextDecoder } from 'node:util';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.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 { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
import {
|
import {
|
||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
|
recordUpstreamEndpointFailure,
|
||||||
|
recordUpstreamEndpointSuccess,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from '../../routes/proxy/upstreamEndpoint.js';
|
} from '../../routes/proxy/upstreamEndpoint.js';
|
||||||
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
|
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
|
||||||
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
|
||||||
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/proxy/endpointFlow.js';
|
||||||
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
import { detectProxyFailure } from '../../routes/proxy/proxyFailureJudge.js';
|
||||||
|
import { buildUpstreamUrl } from '../../routes/proxy/upstreamUrl.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
import { resolveProxyLogBilling } from '../../routes/proxy/proxyBilling.js';
|
||||||
import { getProxyAuthContext, getProxyResourceOwner } from '../../middleware/auth.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 { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
|
||||||
import {
|
import {
|
||||||
ProxyInputFileResolutionError,
|
ProxyInputFileResolutionError,
|
||||||
hasNonImageFileInputInOpenAiBody,
|
|
||||||
resolveResponsesBodyInputFiles,
|
resolveResponsesBodyInputFiles,
|
||||||
} from '../../services/proxyInputFileResolver.js';
|
} from '../../services/proxyInputFileResolver.js';
|
||||||
import {
|
import {
|
||||||
@@ -34,14 +35,23 @@ import {
|
|||||||
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
|
||||||
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
import { recordOauthQuotaResetHint } from '../../services/oauth/quota.js';
|
||||||
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.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 {
|
import {
|
||||||
createGeminiCliStreamReader,
|
createGeminiCliStreamReader,
|
||||||
unwrapGeminiCliPayload,
|
unwrapGeminiCliPayload,
|
||||||
} from '../../routes/proxy/geminiCliCompat.js';
|
} from '../../routes/proxy/geminiCliCompat.js';
|
||||||
import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js';
|
import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js';
|
||||||
import { detectCliProfile } from '../cliProfiles/registry.js';
|
import {
|
||||||
import type { CliProfileId } from '../cliProfiles/types.js';
|
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;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -127,25 +137,6 @@ function carriesResponsesFileUrlInput(value: unknown): boolean {
|
|||||||
|
|
||||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
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 {
|
function deriveCodexExplicitSessionId(body: Record<string, unknown>): string | null {
|
||||||
const promptCacheKey = typeof body.prompt_cache_key === 'string'
|
const promptCacheKey = typeof body.prompt_cache_key === 'string'
|
||||||
? body.prompt_cache_key.trim()
|
? body.prompt_cache_key.trim()
|
||||||
@@ -188,8 +179,6 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
@@ -243,17 +232,12 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
isStream,
|
isStream,
|
||||||
{ defaultEncryptedReasoningInclude },
|
{ defaultEncryptedReasoningInclude },
|
||||||
);
|
);
|
||||||
const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody);
|
const conversationFileSummary = summarizeConversationFileInputsInOpenAiBody(openAiBody);
|
||||||
|
const hasNonImageFileInput = conversationFileSummary.hasDocument;
|
||||||
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
|
const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody);
|
||||||
const requiresNativeResponsesFileUrl = carriesResponsesFileUrlInput(normalizedResponsesBody.input);
|
const responsesConversationFileSummary = summarizeConversationFileInputsInResponsesBody(normalizedResponsesBody);
|
||||||
if (requiresNativeResponsesFileUrl && String(selected.site.platform || '').trim().toLowerCase() === 'claude') {
|
const requiresNativeResponsesFileUrl = responsesConversationFileSummary.hasRemoteDocumentUrl
|
||||||
return reply.code(400).send({
|
|| carriesResponsesFileUrlInput(normalizedResponsesBody.input);
|
||||||
error: {
|
|
||||||
message: 'Responses input_file.file_url requires an upstream /v1/responses endpoint; current site only supports /v1/messages.',
|
|
||||||
type: 'invalid_request_error',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
const endpointCandidates = await resolveUpstreamEndpointCandidates(
|
||||||
{
|
{
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
@@ -264,15 +248,24 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
requestedModel,
|
requestedModel,
|
||||||
{
|
{
|
||||||
hasNonImageFileInput,
|
hasNonImageFileInput,
|
||||||
|
conversationFileSummary,
|
||||||
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
|
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (requiresNativeResponsesFileUrl) {
|
|
||||||
endpointCandidates.splice(0, endpointCandidates.length, 'responses');
|
|
||||||
}
|
|
||||||
if (endpointCandidates.length === 0) {
|
if (endpointCandidates.length === 0) {
|
||||||
endpointCandidates.push('responses', 'chat', 'messages');
|
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 = () => (
|
const buildProviderHeaders = () => (
|
||||||
buildOauthProviderHeaders({
|
buildOauthProviderHeaders({
|
||||||
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
|
||||||
@@ -339,7 +332,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
|
||||||
};
|
};
|
||||||
const refreshedRequest = buildEndpointRequest(ctx.request.endpoint);
|
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);
|
const refreshedResponse = await dispatchRequest(refreshedRequest, refreshedTargetUrl);
|
||||||
if (refreshedResponse.ok) {
|
if (refreshedResponse.ok) {
|
||||||
return {
|
return {
|
||||||
@@ -367,6 +360,20 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
|
||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
tryRecover,
|
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,
|
shouldDowngrade: endpointStrategy.shouldDowngrade,
|
||||||
onDowngrade: (ctx) => {
|
onDowngrade: (ctx) => {
|
||||||
logProxy(
|
logProxy(
|
||||||
@@ -385,7 +392,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -394,7 +401,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const status = endpointResult.status || 502;
|
const status = endpointResult.status || 502;
|
||||||
const errText = endpointResult.errText || 'unknown error';
|
const errText = endpointResult.errText || 'unknown error';
|
||||||
const rawErrText = endpointResult.rawErrText || errText;
|
const rawErrText = endpointResult.rawErrText || errText;
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status,
|
||||||
|
errorText: rawErrText,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -411,7 +422,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
await recordOauthQuotaResetHint({
|
await recordOauthQuotaResetHint({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -486,14 +497,14 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||||
usageForLog.promptTokens, usageForLog.completionTokens, usageForLog.totalTokens, estimatedCost, billingDetails,
|
usageForLog.promptTokens, usageForLog.completionTokens, usageForLog.totalTokens, estimatedCost, billingDetails,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[responses] post-stream success logging failed:', error);
|
console.error('[responses] post-stream success logging failed:', error);
|
||||||
@@ -541,6 +552,39 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
});
|
});
|
||||||
if (!upstreamContentType.includes('text/event-stream')) {
|
if (!upstreamContentType.includes('text/event-stream')) {
|
||||||
const rawText = await upstream.text();
|
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;
|
let upstreamData: unknown = rawText;
|
||||||
try {
|
try {
|
||||||
upstreamData = JSON.parse(rawText);
|
upstreamData = JSON.parse(rawText);
|
||||||
@@ -555,7 +599,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: failure.status,
|
||||||
|
errorText: failure.reason,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -572,7 +620,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||||
@@ -590,7 +638,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
startSseResponse();
|
startSseResponse();
|
||||||
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
|
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: streamResult.errorMessage,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -607,7 +659,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -646,7 +698,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
if (streamResult.status === 'failed') {
|
if (streamResult.status === 'failed') {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: 502,
|
||||||
|
errorText: streamResult.errorMessage,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -663,7 +719,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -692,11 +748,15 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
upstreamData = collected.payload;
|
upstreamData = collected.payload;
|
||||||
} else {
|
} else {
|
||||||
rawText = await upstream.text();
|
rawText = await upstream.text();
|
||||||
upstreamData = rawText;
|
if (looksLikeResponsesSseText(rawText)) {
|
||||||
try {
|
upstreamData = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload;
|
||||||
upstreamData = JSON.parse(rawText);
|
} else {
|
||||||
} catch {
|
|
||||||
upstreamData = rawText;
|
upstreamData = rawText;
|
||||||
|
try {
|
||||||
|
upstreamData = JSON.parse(rawText);
|
||||||
|
} catch {
|
||||||
|
upstreamData = rawText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
if (String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli') {
|
||||||
@@ -706,7 +766,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const parsedUsage = parseProxyUsage(upstreamData);
|
const parsedUsage = parseProxyUsage(upstreamData);
|
||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: failure.status,
|
||||||
|
errorText: failure.reason,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -723,7 +787,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(failure.status, failure.reason) && retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -770,18 +834,21 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
return reply.send(downstreamData);
|
return reply.send(downstreamData);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
errorText: err?.message,
|
||||||
|
modelName,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
@@ -798,7 +865,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
clientContext,
|
clientContext,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -845,11 +912,11 @@ async function logProxy(
|
|||||||
upstreamPath,
|
upstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamApiKeyId,
|
||||||
modelRequested,
|
modelRequested,
|
||||||
modelActual: selected.actualModel,
|
modelActual: selected.actualModel,
|
||||||
status,
|
status,
|
||||||
@@ -859,11 +926,15 @@ async function logProxy(
|
|||||||
completionTokens,
|
completionTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
estimatedCost,
|
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,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/responses] failed to write proxy log', error);
|
console.warn('[proxy/responses] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ vi.mock('../../services/checkinService.js', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/checkinScheduler.js', () => ({
|
vi.mock('../../services/checkinScheduler.js', () => ({
|
||||||
updateCheckinCron: vi.fn(),
|
updateCheckinSchedule: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../db/index.js', () => {
|
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 new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
await app.close();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { config } from '../../config.js';
|
||||||
import { db, schema } from '../../db/index.js';
|
import { db, schema } from '../../db/index.js';
|
||||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||||
import { eq, desc } from 'drizzle-orm';
|
import { eq, desc } from 'drizzle-orm';
|
||||||
import { checkinAccount, checkinAll } from '../../services/checkinService.js';
|
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 { startBackgroundTask, summarizeCheckinResults } from '../../services/backgroundTaskService.js';
|
||||||
import { classifyFailureReason } from '../../services/failureReasonService.js';
|
import { classifyFailureReason } from '../../services/failureReasonService.js';
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ export async function checkinRoutes(app: FastifyInstance) {
|
|||||||
failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`,
|
failureMessage: (currentTask) => `全部账号签到任务失败:${currentTask.error || 'unknown error'}`,
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
const results = await checkinAll();
|
const results = await checkinAll({ scheduleMode: config.checkinScheduleMode });
|
||||||
return {
|
return {
|
||||||
summary: summarizeCheckinResults(results),
|
summary: summarizeCheckinResults(results),
|
||||||
total: results.length,
|
total: results.length,
|
||||||
@@ -108,7 +109,7 @@ export async function checkinRoutes(app: FastifyInstance) {
|
|||||||
// Trigger check-in for a specific account
|
// Trigger check-in for a specific account
|
||||||
app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => {
|
app.post<{ Params: { id: string } }>('/api/checkin/trigger/:id', async (request) => {
|
||||||
const id = parseInt(request.params.id, 10);
|
const id = parseInt(request.params.id, 10);
|
||||||
const result = await checkinAccount(id);
|
const result = await checkinAccount(id, { scheduleMode: config.checkinScheduleMode });
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,11 +143,33 @@ export async function checkinRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update check-in schedule
|
// 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 {
|
try {
|
||||||
updateCheckinCron(request.body.cron);
|
const body = request.body || {};
|
||||||
await upsertSetting('checkin_cron', request.body.cron);
|
const nextMode: 'cron' | 'interval' = body.mode === 'interval' ? 'interval' : 'cron';
|
||||||
return { success: true, cron: request.body.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) {
|
} catch (err: any) {
|
||||||
return { error: err.message };
|
return { error: err.message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ function validateKeyShape(key: string): boolean {
|
|||||||
|
|
||||||
function looksLikeUniqueViolation(error: unknown): boolean {
|
function looksLikeUniqueViolation(error: unknown): boolean {
|
||||||
const message = (error as Error | undefined)?.message || '';
|
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');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,8 @@ describe('settings and auth events', () => {
|
|||||||
config.proxyToken = 'sk-old-proxy-token-123';
|
config.proxyToken = 'sk-old-proxy-token-123';
|
||||||
config.systemProxyUrl = '';
|
config.systemProxyUrl = '';
|
||||||
config.checkinCron = '0 8 * * *';
|
config.checkinCron = '0 8 * * *';
|
||||||
|
(config as any).checkinScheduleMode = 'cron';
|
||||||
|
(config as any).checkinIntervalHours = 6;
|
||||||
config.balanceRefreshCron = '0 * * * *';
|
config.balanceRefreshCron = '0 * * * *';
|
||||||
config.logCleanupConfigured = false;
|
config.logCleanupConfigured = false;
|
||||||
config.logCleanupCron = '0 6 * * *';
|
config.logCleanupCron = '0 6 * * *';
|
||||||
@@ -55,6 +57,8 @@ describe('settings and auth events', () => {
|
|||||||
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
|
||||||
(config as any).telegramBotToken = '';
|
(config as any).telegramBotToken = '';
|
||||||
(config as any).telegramChatId = '';
|
(config as any).telegramChatId = '';
|
||||||
|
(config as any).telegramUseSystemProxy = false;
|
||||||
|
(config as any).telegramMessageThreadId = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -85,6 +89,28 @@ describe('settings and auth events', () => {
|
|||||||
expect(events[0].message || '').toContain('签到 Cron');
|
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 () => {
|
it('returns current recognized admin IP in runtime settings response', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -96,8 +122,10 @@ describe('settings and auth events', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
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(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 () => {
|
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');
|
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 () => {
|
it('rejects invalid telegram api base url when telegram is enabled', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -219,6 +273,32 @@ describe('settings and auth events', () => {
|
|||||||
expect(body.message).toContain('Telegram API Base URL');
|
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 () => {
|
it('persists and returns routing fallback unit cost from runtime settings', async () => {
|
||||||
const updateResponse = await app.inject({
|
const updateResponse = await app.inject({
|
||||||
method: 'PUT',
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,22 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
|
import { fetch } from 'undici';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
import { db, runtimeDbDialect, schema } from '../../db/index.js';
|
||||||
import { upsertSetting } from '../../db/upsertSetting.js';
|
import { upsertSetting } from '../../db/upsertSetting.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.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 { 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 { startBackgroundTask } from '../../services/backgroundTaskService.js';
|
||||||
import {
|
import {
|
||||||
maskConnectionString,
|
maskConnectionString,
|
||||||
@@ -15,9 +25,9 @@ import {
|
|||||||
testDatabaseConnection,
|
testDatabaseConnection,
|
||||||
type MigrationDialect,
|
type MigrationDialect,
|
||||||
} from '../../services/databaseMigrationService.js';
|
} 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 { 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 { performFactoryReset } from '../../services/factoryResetService.js';
|
||||||
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
|
import { normalizeLogCleanupRetentionDays } from '../../services/logCleanupService.js';
|
||||||
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
|
import { stopProxyLogRetentionService } from '../../services/proxyLogRetentionService.js';
|
||||||
@@ -28,6 +38,8 @@ interface RuntimeSettingsBody {
|
|||||||
proxyToken?: string;
|
proxyToken?: string;
|
||||||
systemProxyUrl?: string;
|
systemProxyUrl?: string;
|
||||||
checkinCron?: string;
|
checkinCron?: string;
|
||||||
|
checkinScheduleMode?: 'cron' | 'interval';
|
||||||
|
checkinIntervalHours?: number;
|
||||||
balanceRefreshCron?: string;
|
balanceRefreshCron?: string;
|
||||||
logCleanupCron?: string;
|
logCleanupCron?: string;
|
||||||
logCleanupUsageLogsEnabled?: boolean;
|
logCleanupUsageLogsEnabled?: boolean;
|
||||||
@@ -43,6 +55,8 @@ interface RuntimeSettingsBody {
|
|||||||
telegramApiBaseUrl?: string;
|
telegramApiBaseUrl?: string;
|
||||||
telegramBotToken?: string;
|
telegramBotToken?: string;
|
||||||
telegramChatId?: string;
|
telegramChatId?: string;
|
||||||
|
telegramUseSystemProxy?: boolean;
|
||||||
|
telegramMessageThreadId?: string;
|
||||||
smtpEnabled?: boolean;
|
smtpEnabled?: boolean;
|
||||||
smtpHost?: string;
|
smtpHost?: string;
|
||||||
smtpPort?: number;
|
smtpPort?: number;
|
||||||
@@ -66,6 +80,21 @@ interface DatabaseMigrationBody {
|
|||||||
ssl?: unknown;
|
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 = {
|
type RuntimeDatabaseConfig = {
|
||||||
dialect: MigrationDialect;
|
dialect: MigrationDialect;
|
||||||
connectionString: string;
|
connectionString: string;
|
||||||
@@ -76,6 +105,8 @@ const PROXY_TOKEN_PREFIX = 'sk-';
|
|||||||
const DB_TYPE_SETTING_KEY = 'db_type';
|
const DB_TYPE_SETTING_KEY = 'db_type';
|
||||||
const DB_URL_SETTING_KEY = 'db_url';
|
const DB_URL_SETTING_KEY = 'db_url';
|
||||||
const DB_SSL_SETTING_KEY = 'db_ssl';
|
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 {
|
function isValidProxyToken(value: string): boolean {
|
||||||
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
return value.startsWith(PROXY_TOKEN_PREFIX) && value.length >= 6;
|
||||||
@@ -114,6 +145,94 @@ function toPositiveNumberOrFallback(value: unknown, fallback: number) {
|
|||||||
return n;
|
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[] {
|
function toStringList(value: unknown): string[] {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value
|
return value
|
||||||
@@ -171,12 +290,46 @@ function normalizeTelegramApiBaseUrl(raw: string): string {
|
|||||||
return String(raw || '').trim().replace(/\/+$/, '');
|
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) {
|
function applyImportedSettingToRuntime(key: string, value: unknown) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'checkin_cron': {
|
case 'checkin_cron': {
|
||||||
if (typeof value !== 'string' || !value || !cron.validate(value)) return;
|
if (typeof value !== 'string' || !value || !cron.validate(value)) return;
|
||||||
config.checkinCron = value;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
case 'balance_refresh_cron': {
|
case 'balance_refresh_cron': {
|
||||||
@@ -288,6 +441,15 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
|||||||
config.telegramChatId = value.trim();
|
config.telegramChatId = value.trim();
|
||||||
return;
|
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': {
|
case 'smtp_enabled': {
|
||||||
config.smtpEnabled = !!value;
|
config.smtpEnabled = !!value;
|
||||||
return;
|
return;
|
||||||
@@ -363,6 +525,8 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
|
|||||||
function getRuntimeSettingsResponse(currentAdminIp = '') {
|
function getRuntimeSettingsResponse(currentAdminIp = '') {
|
||||||
return {
|
return {
|
||||||
checkinCron: config.checkinCron,
|
checkinCron: config.checkinCron,
|
||||||
|
checkinScheduleMode: config.checkinScheduleMode,
|
||||||
|
checkinIntervalHours: config.checkinIntervalHours,
|
||||||
balanceRefreshCron: config.balanceRefreshCron,
|
balanceRefreshCron: config.balanceRefreshCron,
|
||||||
logCleanupCron: config.logCleanupCron,
|
logCleanupCron: config.logCleanupCron,
|
||||||
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
|
logCleanupUsageLogsEnabled: config.logCleanupUsageLogsEnabled,
|
||||||
@@ -380,6 +544,8 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
|||||||
telegramApiBaseUrl: config.telegramApiBaseUrl,
|
telegramApiBaseUrl: config.telegramApiBaseUrl,
|
||||||
telegramBotTokenMasked: maskSecret(config.telegramBotToken),
|
telegramBotTokenMasked: maskSecret(config.telegramBotToken),
|
||||||
telegramChatId: config.telegramChatId,
|
telegramChatId: config.telegramChatId,
|
||||||
|
telegramUseSystemProxy: config.telegramUseSystemProxy,
|
||||||
|
telegramMessageThreadId: config.telegramMessageThreadId,
|
||||||
smtpEnabled: config.smtpEnabled,
|
smtpEnabled: config.smtpEnabled,
|
||||||
smtpHost: config.smtpHost,
|
smtpHost: config.smtpHost,
|
||||||
smtpPort: config.smtpPort,
|
smtpPort: config.smtpPort,
|
||||||
@@ -391,6 +557,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
|
|||||||
notifyCooldownSec: config.notifyCooldownSec,
|
notifyCooldownSec: config.notifyCooldownSec,
|
||||||
adminIpAllowlist: config.adminIpAllowlist,
|
adminIpAllowlist: config.adminIpAllowlist,
|
||||||
currentAdminIp,
|
currentAdminIp,
|
||||||
|
serverTimeZone: getResolvedTimeZone(),
|
||||||
systemProxyUrl: config.systemProxyUrl,
|
systemProxyUrl: config.systemProxyUrl,
|
||||||
proxyErrorKeywords: config.proxyErrorKeywords,
|
proxyErrorKeywords: config.proxyErrorKeywords,
|
||||||
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
|
proxyEmptyContentFailEnabled: config.proxyEmptyContentFailEnabled,
|
||||||
@@ -472,6 +639,43 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
return getRuntimeSettingsResponse(currentAdminIp);
|
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) => {
|
app.put<{ Body: RuntimeSettingsBody }>('/api/settings/runtime', async (request, reply) => {
|
||||||
const body = request.body || {};
|
const body = request.body || {};
|
||||||
const changedLabels: string[] = [];
|
const changedLabels: string[] = [];
|
||||||
@@ -512,7 +716,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const telegramTouched = body.telegramEnabled !== undefined
|
const telegramTouched = body.telegramEnabled !== undefined
|
||||||
|| body.telegramApiBaseUrl !== undefined
|
|| body.telegramApiBaseUrl !== undefined
|
||||||
|| body.telegramBotToken !== undefined
|
|| body.telegramBotToken !== undefined
|
||||||
|| body.telegramChatId !== undefined;
|
|| body.telegramChatId !== undefined
|
||||||
|
|| body.telegramUseSystemProxy !== undefined
|
||||||
|
|| body.telegramMessageThreadId !== undefined;
|
||||||
const nextTelegramEnabled = body.telegramEnabled !== undefined
|
const nextTelegramEnabled = body.telegramEnabled !== undefined
|
||||||
? !!body.telegramEnabled
|
? !!body.telegramEnabled
|
||||||
: config.telegramEnabled;
|
: config.telegramEnabled;
|
||||||
@@ -525,6 +731,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
const nextTelegramChatId = body.telegramChatId !== undefined
|
const nextTelegramChatId = body.telegramChatId !== undefined
|
||||||
? String(body.telegramChatId || '').trim()
|
? String(body.telegramChatId || '').trim()
|
||||||
: config.telegramChatId;
|
: config.telegramChatId;
|
||||||
|
const nextTelegramMessageThreadId = body.telegramMessageThreadId !== undefined
|
||||||
|
? normalizeTelegramMessageThreadId(body.telegramMessageThreadId)
|
||||||
|
: config.telegramMessageThreadId;
|
||||||
if (telegramTouched && nextTelegramEnabled) {
|
if (telegramTouched && nextTelegramEnabled) {
|
||||||
if (!nextTelegramBotToken) {
|
if (!nextTelegramBotToken) {
|
||||||
return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' });
|
return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' });
|
||||||
@@ -535,13 +744,22 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
if (!nextTelegramChatId) {
|
if (!nextTelegramChatId) {
|
||||||
return reply.code(400).send({ success: false, message: 'Telegram Chat ID 不能为空(启用 Telegram 时)' });
|
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)) {
|
if (nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
|
||||||
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
|
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
|
||||||
}
|
}
|
||||||
} else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
|
} else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
|
||||||
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
|
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 (body.checkinCron !== undefined) {
|
||||||
if (!cron.validate(body.checkinCron)) {
|
if (!cron.validate(body.checkinCron)) {
|
||||||
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
|
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
|
||||||
@@ -549,8 +767,50 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
if (body.checkinCron !== config.checkinCron) {
|
if (body.checkinCron !== config.checkinCron) {
|
||||||
changedLabels.push(`签到 Cron(${config.checkinCron} -> ${body.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) {
|
if (body.balanceRefreshCron !== undefined) {
|
||||||
@@ -768,6 +1028,23 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
upsertSetting('telegram_chat_id', config.telegramChatId);
|
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 !== undefined) {
|
||||||
if (!!body.smtpEnabled !== config.smtpEnabled) {
|
if (!!body.smtpEnabled !== config.smtpEnabled) {
|
||||||
changedLabels.push('SMTP 开关');
|
changedLabels.push('SMTP 开关');
|
||||||
@@ -1010,6 +1287,9 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
for (const item of result.appliedSettings) {
|
for (const item of result.appliedSettings) {
|
||||||
applyImportedSettingToRuntime(item.key, item.value);
|
applyImportedSettingToRuntime(item.key, item.value);
|
||||||
}
|
}
|
||||||
|
if (result.appliedSettings.some((item) => item.key === 'backup_webdav_config_v1')) {
|
||||||
|
await reloadBackupWebdavScheduler();
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: '导入完成',
|
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) => {
|
app.post('/api/settings/notify/test', async (_, reply) => {
|
||||||
try {
|
try {
|
||||||
const result = await sendNotification(
|
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' })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import { mkdtempSync } from 'node:fs';
|
|||||||
|
|
||||||
type DbModule = typeof import('../../db/index.js');
|
type DbModule = typeof import('../../db/index.js');
|
||||||
|
|
||||||
describe('sites system proxy settings', () => {
|
describe('sites proxy settings', () => {
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
@@ -36,7 +36,7 @@ describe('sites system proxy settings', () => {
|
|||||||
delete process.env.DATA_DIR;
|
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({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/sites',
|
url: '/api/sites',
|
||||||
@@ -44,6 +44,7 @@ describe('sites system proxy settings', () => {
|
|||||||
name: 'proxy-site',
|
name: 'proxy-site',
|
||||||
url: 'https://proxy-site.example.com',
|
url: 'https://proxy-site.example.com',
|
||||||
platform: 'new-api',
|
platform: 'new-api',
|
||||||
|
proxyUrl: 'socks5://127.0.0.1:1080',
|
||||||
useSystemProxy: true,
|
useSystemProxy: true,
|
||||||
customHeaders: JSON.stringify({
|
customHeaders: JSON.stringify({
|
||||||
'cf-access-client-id': 'site-client-id',
|
'cf-access-client-id': 'site-client-id',
|
||||||
@@ -56,11 +57,13 @@ describe('sites system proxy settings', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const payload = response.json() as {
|
const payload = response.json() as {
|
||||||
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
globalWeight?: number;
|
globalWeight?: number;
|
||||||
};
|
};
|
||||||
|
expect(payload.proxyUrl).toBe('socks5://127.0.0.1:1080');
|
||||||
expect(payload.useSystemProxy).toBe(true);
|
expect(payload.useSystemProxy).toBe(true);
|
||||||
expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}');
|
expect(payload.customHeaders).toBe('{"cf-access-client-id":"site-client-id","x-site-scope":"internal"}');
|
||||||
expect(payload.externalCheckinUrl).toBe('https://checkin.example.com/welfare');
|
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');
|
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 () => {
|
it('rejects invalid site global weight', async () => {
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -115,7 +134,7 @@ describe('sites system proxy settings', () => {
|
|||||||
expect((response.json() as { error?: string }).error).toContain('Invalid externalCheckinUrl');
|
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({
|
const created = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/api/sites',
|
url: '/api/sites',
|
||||||
@@ -133,12 +152,15 @@ describe('sites system proxy settings', () => {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
url: `/api/sites/${site.id}`,
|
url: `/api/sites/${site.id}`,
|
||||||
payload: {
|
payload: {
|
||||||
|
proxyUrl: 'http://127.0.0.1:8080',
|
||||||
useSystemProxy: true,
|
useSystemProxy: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
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 () => {
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { db, schema } from '../../db/index.js';
|
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 { 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 { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
|
||||||
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
|
||||||
|
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
|
||||||
|
|
||||||
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
|
||||||
if (input === undefined || input === null) return 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(/\/+$/, '') };
|
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) {
|
export async function sitesRoutes(app: FastifyInstance) {
|
||||||
function invalidateSiteCaches() {
|
function invalidateSiteCaches() {
|
||||||
invalidateSiteProxyCache();
|
invalidateSiteProxyCache();
|
||||||
@@ -137,21 +197,23 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
// List all sites
|
// List all sites
|
||||||
app.get('/api/sites', async () => {
|
app.get('/api/sites', async () => {
|
||||||
const siteRows = await db.select().from(schema.sites).all();
|
const siteRows = await db.select().from(schema.sites).all();
|
||||||
const accountBalanceRows = await db.select({
|
const accountRows = await db.select({
|
||||||
siteId: schema.accounts.siteId,
|
siteId: schema.accounts.siteId,
|
||||||
totalBalance: sql<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
|
balance: schema.accounts.balance,
|
||||||
}).from(schema.accounts)
|
extraConfig: schema.accounts.extraConfig,
|
||||||
.groupBy(schema.accounts.siteId)
|
}).from(schema.accounts).all();
|
||||||
.all();
|
|
||||||
|
|
||||||
const totalBalanceBySiteId: Record<number, number> = {};
|
const totalBalanceBySiteId: Record<number, number> = {};
|
||||||
for (const row of accountBalanceRows) {
|
const subscriptionBySiteId: Record<number, SiteSubscriptionAggregate | undefined> = {};
|
||||||
totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0);
|
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) => ({
|
return siteRows.map((site) => ({
|
||||||
...site,
|
...site,
|
||||||
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
|
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;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
@@ -168,7 +231,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
globalWeight?: number;
|
globalWeight?: number;
|
||||||
} }>('/api/sites', async (request, reply) => {
|
} }>('/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);
|
const normalizedStatus = normalizeSiteStatus(status);
|
||||||
if (status !== undefined && !normalizedStatus) {
|
if (status !== undefined && !normalizedStatus) {
|
||||||
return reply.code(400).send({ error: 'Invalid site status. Expected active or disabled.' });
|
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) {
|
if (useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
|
||||||
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
|
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);
|
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(externalCheckinUrl);
|
||||||
if (!normalizedExternalCheckinUrl.valid) {
|
if (!normalizedExternalCheckinUrl.valid) {
|
||||||
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
|
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,
|
name,
|
||||||
url: url.replace(/\/+$/, ''),
|
url: url.replace(/\/+$/, ''),
|
||||||
platform: detectedPlatform,
|
platform: detectedPlatform,
|
||||||
|
proxyUrl: normalizedProxyUrl.proxyUrl,
|
||||||
useSystemProxy: normalizedUseSystemProxy ?? false,
|
useSystemProxy: normalizedUseSystemProxy ?? false,
|
||||||
customHeaders: normalizedCustomHeaders.customHeaders,
|
customHeaders: normalizedCustomHeaders.customHeaders,
|
||||||
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
externalCheckinUrl: normalizedExternalCheckinUrl.url,
|
||||||
@@ -238,6 +306,7 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
name?: string;
|
name?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
|
proxyUrl?: string | null;
|
||||||
useSystemProxy?: boolean;
|
useSystemProxy?: boolean;
|
||||||
customHeaders?: string | null;
|
customHeaders?: string | null;
|
||||||
externalCheckinUrl?: string | null;
|
externalCheckinUrl?: string | null;
|
||||||
@@ -266,6 +335,10 @@ export async function sitesRoutes(app: FastifyInstance) {
|
|||||||
if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
|
if (body.useSystemProxy !== undefined && normalizedUseSystemProxy === null) {
|
||||||
return reply.code(400).send({ error: 'Invalid useSystemProxy value. Expected boolean.' });
|
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);
|
const normalizedExternalCheckinUrl = normalizeOptionalExternalCheckinUrl(body.externalCheckinUrl);
|
||||||
if (!normalizedExternalCheckinUrl.valid) {
|
if (!normalizedExternalCheckinUrl.valid) {
|
||||||
return reply.code(400).send({ error: 'Invalid externalCheckinUrl. Expected a valid http(s) URL.' });
|
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.name !== undefined) updates.name = body.name;
|
||||||
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
if (body.url !== undefined) updates.url = body.url.replace(/\/+$/, '');
|
||||||
if (body.platform !== undefined) updates.platform = body.platform;
|
if (body.platform !== undefined) updates.platform = body.platform;
|
||||||
|
if (normalizedProxyUrl.present) updates.proxyUrl = normalizedProxyUrl.proxyUrl;
|
||||||
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
if (body.useSystemProxy !== undefined) updates.useSystemProxy = normalizedUseSystemProxy;
|
||||||
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
|
if (normalizedCustomHeaders.present) updates.customHeaders = normalizedCustomHeaders.customHeaders;
|
||||||
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
|
if (normalizedExternalCheckinUrl.present) updates.externalCheckinUrl = normalizedExternalCheckinUrl.url;
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ describe('stats proxy logs routes', () => {
|
|||||||
modelRequested: 'gpt-4o',
|
modelRequested: 'gpt-4o',
|
||||||
modelActual: 'gpt-4o',
|
modelActual: 'gpt-4o',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
|
clientFamily: 'generic',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
promptTokens: 10,
|
promptTokens: 10,
|
||||||
completionTokens: 5,
|
completionTokens: 5,
|
||||||
totalTokens: 15,
|
totalTokens: 15,
|
||||||
@@ -88,6 +92,7 @@ describe('stats proxy logs routes', () => {
|
|||||||
modelRequested: 'gpt-4o-mini',
|
modelRequested: 'gpt-4o-mini',
|
||||||
modelActual: 'gpt-4o-mini',
|
modelActual: 'gpt-4o-mini',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
|
clientFamily: 'codex',
|
||||||
promptTokens: 8,
|
promptTokens: 8,
|
||||||
completionTokens: 2,
|
completionTokens: 2,
|
||||||
totalTokens: 10,
|
totalTokens: 10,
|
||||||
@@ -139,6 +144,10 @@ describe('stats proxy logs routes', () => {
|
|||||||
totalCost: number;
|
totalCost: number;
|
||||||
totalTokensAll: number;
|
totalTokensAll: number;
|
||||||
};
|
};
|
||||||
|
clientOptions: Array<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(body.page).toBe(2);
|
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]?.downstreamKeyName).toBe('项目A-Key');
|
||||||
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A');
|
expect(body.items[0]?.downstreamKeyGroupName).toBe('项目A');
|
||||||
expect(body.items[0]?.downstreamKeyTags).toEqual(['VIP', '灰度']);
|
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.items[0]).not.toHaveProperty('billingDetails');
|
||||||
|
expect(body.clientOptions).toEqual([
|
||||||
|
{ value: 'family:codex', label: '协议 · Codex' },
|
||||||
|
]);
|
||||||
expect(body.summary).toEqual({
|
expect(body.summary).toEqual({
|
||||||
totalCount: 3,
|
totalCount: 3,
|
||||||
successCount: 1,
|
successCount: 1,
|
||||||
@@ -188,6 +204,10 @@ describe('stats proxy logs routes', () => {
|
|||||||
modelRequested: 'gpt-5',
|
modelRequested: 'gpt-5',
|
||||||
modelActual: 'gpt-5',
|
modelActual: 'gpt-5',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
|
clientFamily: 'codex',
|
||||||
|
clientAppId: 'cherry_studio',
|
||||||
|
clientAppName: 'Cherry Studio',
|
||||||
|
clientConfidence: 'exact',
|
||||||
promptTokens: 100,
|
promptTokens: 100,
|
||||||
completionTokens: 20,
|
completionTokens: 20,
|
||||||
totalTokens: 120,
|
totalTokens: 120,
|
||||||
@@ -214,6 +234,10 @@ describe('stats proxy logs routes', () => {
|
|||||||
downstreamKeyName: string | null;
|
downstreamKeyName: string | null;
|
||||||
downstreamKeyGroupName: string | null;
|
downstreamKeyGroupName: string | null;
|
||||||
downstreamKeyTags: string[];
|
downstreamKeyTags: string[];
|
||||||
|
clientFamily: string | null;
|
||||||
|
clientAppId: string | null;
|
||||||
|
clientAppName: string | null;
|
||||||
|
clientConfidence: string | null;
|
||||||
billingDetails: Record<string, unknown> | null;
|
billingDetails: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -223,6 +247,10 @@ describe('stats proxy logs routes', () => {
|
|||||||
expect(body.downstreamKeyName).toBe('detail-key');
|
expect(body.downstreamKeyName).toBe('detail-key');
|
||||||
expect(body.downstreamKeyGroupName).toBe('测试项目');
|
expect(body.downstreamKeyGroupName).toBe('测试项目');
|
||||||
expect(body.downstreamKeyTags).toEqual(['回归', '日志']);
|
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({
|
expect(body.billingDetails).toMatchObject({
|
||||||
breakdown: { totalCost: 0.12 },
|
breakdown: { totalCost: 0.12 },
|
||||||
usage: { promptTokens: 100, completionTokens: 20 },
|
usage: { promptTokens: 100, completionTokens: 20 },
|
||||||
@@ -392,4 +420,118 @@ describe('stats proxy logs routes', () => {
|
|||||||
totalTokensAll: 30,
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
parseProxyLogBillingDetails,
|
parseProxyLogBillingDetails,
|
||||||
withProxyLogSelectFields,
|
withProxyLogSelectFields,
|
||||||
} from '../../services/proxyLogStore.js';
|
} from '../../services/proxyLogStore.js';
|
||||||
|
import { parseProxyLogMessageMeta } from '../proxy/logPathMeta.js';
|
||||||
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
|
import { requiresManagedAccountTokens } from '../../services/accountExtraConfig.js';
|
||||||
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
|
import { ACCOUNT_TOKEN_VALUE_STATUS_READY } from '../../services/accountTokenService.js';
|
||||||
import {
|
import {
|
||||||
@@ -85,6 +86,22 @@ function proxyCostSqlExpression() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProxyLogStatusFilter = 'all' | 'success' | 'failed';
|
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 {
|
function normalizeProxyLogPageSize(raw?: string): number {
|
||||||
const parsed = Number.parseInt(raw || '50', 10);
|
const parsed = Number.parseInt(raw || '50', 10);
|
||||||
@@ -115,6 +132,20 @@ function normalizeProxyLogSiteId(raw?: string): number | null {
|
|||||||
return parsed;
|
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 {
|
function normalizeProxyLogTimeBoundary(raw?: string): string | null {
|
||||||
const text = (raw || '').trim();
|
const text = (raw || '').trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
@@ -166,9 +197,18 @@ function buildProxyLogStatusCondition(status: ProxyLogStatusFilter) {
|
|||||||
return null;
|
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: {
|
function buildProxyLogWhereClause(params: {
|
||||||
status?: ProxyLogStatusFilter;
|
status?: ProxyLogStatusFilter;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
client?: ProxyLogClientFilter;
|
||||||
siteId?: number | null;
|
siteId?: number | null;
|
||||||
fromUtc?: string | null;
|
fromUtc?: string | null;
|
||||||
toUtc?: string | null;
|
toUtc?: string | null;
|
||||||
@@ -176,6 +216,7 @@ function buildProxyLogWhereClause(params: {
|
|||||||
const conditions = [
|
const conditions = [
|
||||||
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
params.status ? buildProxyLogStatusCondition(params.status) : null,
|
||||||
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
params.search ? buildProxyLogSearchCondition(params.search) : null,
|
||||||
|
params.client ? buildProxyLogClientCondition(params.client) : null,
|
||||||
params.siteId ? eq(schema.sites.id, params.siteId) : null,
|
params.siteId ? eq(schema.sites.id, params.siteId) : null,
|
||||||
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
|
params.fromUtc ? gte(schema.proxyLogs.createdAt, params.fromUtc) : null,
|
||||||
params.toUtc ? lt(schema.proxyLogs.createdAt, params.toUtc) : 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;
|
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_COUNT = 24;
|
||||||
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
|
const SITE_AVAILABILITY_BUCKET_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
@@ -351,11 +469,16 @@ function mapProxyLogRow(
|
|||||||
},
|
},
|
||||||
options?: { includeBillingDetails?: boolean },
|
options?: { includeBillingDetails?: boolean },
|
||||||
) {
|
) {
|
||||||
|
const clientMeta = resolveProxyLogClientMeta(row.proxy_logs);
|
||||||
return {
|
return {
|
||||||
...row.proxy_logs,
|
...row.proxy_logs,
|
||||||
...(options?.includeBillingDetails
|
...(options?.includeBillingDetails
|
||||||
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
? { billingDetails: parseProxyLogBillingDetails(row.proxy_logs.billingDetails) }
|
||||||
: {}),
|
: {}),
|
||||||
|
clientFamily: clientMeta.clientFamily,
|
||||||
|
clientAppId: clientMeta.clientAppId,
|
||||||
|
clientAppName: clientMeta.clientAppName,
|
||||||
|
clientConfidence: clientMeta.clientConfidence,
|
||||||
username: row.accounts?.username || null,
|
username: row.accounts?.username || null,
|
||||||
siteId: row.sites?.id || null,
|
siteId: row.sites?.id || null,
|
||||||
siteName: row.sites?.name || null,
|
siteName: row.sites?.name || null,
|
||||||
@@ -547,6 +670,7 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
offset?: string;
|
offset?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
client?: string;
|
||||||
siteId?: string;
|
siteId?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
@@ -555,11 +679,13 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
const offset = normalizeProxyLogOffset(request.query.offset);
|
const offset = normalizeProxyLogOffset(request.query.offset);
|
||||||
const status = normalizeProxyLogStatusFilter(request.query.status);
|
const status = normalizeProxyLogStatusFilter(request.query.status);
|
||||||
const search = normalizeProxyLogSearch(request.query.search);
|
const search = normalizeProxyLogSearch(request.query.search);
|
||||||
|
const client = normalizeProxyLogClientFilter(request.query.client);
|
||||||
const siteId = normalizeProxyLogSiteId(request.query.siteId);
|
const siteId = normalizeProxyLogSiteId(request.query.siteId);
|
||||||
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
|
const fromUtc = normalizeProxyLogTimeBoundary(request.query.from);
|
||||||
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
|
const toUtc = normalizeProxyLogTimeBoundary(request.query.to);
|
||||||
const listWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
const listWhere = buildProxyLogWhereClause({ status, search, client, siteId, fromUtc, toUtc });
|
||||||
const summaryWhere = buildProxyLogWhereClause({ search, siteId, fromUtc, toUtc });
|
const summaryWhere = buildProxyLogWhereClause({ search, client, siteId, fromUtc, toUtc });
|
||||||
|
const clientOptionsWhere = buildProxyLogWhereClause({ status, search, siteId, fromUtc, toUtc });
|
||||||
|
|
||||||
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
const listRows = await withProxyLogSelectFields(({ fields }) => {
|
||||||
let query = db.select({
|
let query = db.select({
|
||||||
@@ -604,6 +730,31 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
const totalRow = await totalQuery.get();
|
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({
|
let summaryQuery = db.select({
|
||||||
totalCount: sql<number>`count(*)`,
|
totalCount: sql<number>`count(*)`,
|
||||||
successCount: sql<number>`coalesce(sum(case when ${schema.proxyLogs.status} = 'success' then 1 else 0 end), 0)`,
|
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),
|
total: Number(totalRow?.total || 0),
|
||||||
page: Math.floor(offset / limit) + 1,
|
page: Math.floor(offset / limit) + 1,
|
||||||
pageSize: limit,
|
pageSize: limit,
|
||||||
|
clientOptions: buildProxyLogClientOptions(clientOptionRows),
|
||||||
summary: {
|
summary: {
|
||||||
totalCount: Number(summaryRow?.totalCount || 0),
|
totalCount: Number(summaryRow?.totalCount || 0),
|
||||||
successCount: Number(summaryRow?.successCount || 0),
|
successCount: Number(summaryRow?.successCount || 0),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
|
|||||||
estimatedCostFromQuota: 0,
|
estimatedCostFromQuota: 0,
|
||||||
recoveredFromSelfLog: false,
|
recoveredFromSelfLog: false,
|
||||||
}));
|
}));
|
||||||
|
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||||
values: () => ({
|
values: () => ({
|
||||||
run: () => undefined,
|
run: () => undefined,
|
||||||
@@ -60,10 +61,16 @@ vi.mock('../../services/proxyUsageFallbackService.js', () => ({
|
|||||||
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
|
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
|
||||||
|
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../db/index.js', () => ({
|
vi.mock('../../db/index.js', () => ({
|
||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
@@ -105,6 +112,7 @@ describe('chat proxy codex oauth compatibility', () => {
|
|||||||
reportProxyAllFailedMock.mockReset();
|
reportProxyAllFailedMock.mockReset();
|
||||||
reportTokenExpiredMock.mockReset();
|
reportTokenExpiredMock.mockReset();
|
||||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||||
|
refreshOauthAccessTokenSingleflightMock.mockReset();
|
||||||
dbInsertMock.mockClear();
|
dbInsertMock.mockClear();
|
||||||
|
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
@@ -128,6 +136,11 @@ describe('chat proxy codex oauth compatibility', () => {
|
|||||||
actualModel: 'gpt-5.4',
|
actualModel: 'gpt-5.4',
|
||||||
});
|
});
|
||||||
selectNextChannelMock.mockReturnValue(null);
|
selectNextChannelMock.mockReturnValue(null);
|
||||||
|
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
|
||||||
|
accessToken: 'fresh-access-token',
|
||||||
|
accountId: 33,
|
||||||
|
accountKey: 'chatgpt-account-123',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
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('event: response.created');
|
||||||
expect(response.body).not.toContain('"type":"response.output_text.delta"');
|
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: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
|
import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
const selectChannelMock = vi.fn();
|
const selectChannelMock = vi.fn();
|
||||||
@@ -68,6 +69,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
},
|
},
|
||||||
@@ -99,6 +103,7 @@ describe('chat proxy stream behavior', () => {
|
|||||||
fetchModelPricingCatalogMock.mockReset();
|
fetchModelPricingCatalogMock.mockReset();
|
||||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||||
dbInsertMock.mockClear();
|
dbInsertMock.mockClear();
|
||||||
|
resetUpstreamEndpointRuntimeState();
|
||||||
|
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
@@ -110,6 +115,17 @@ describe('chat proxy stream behavior', () => {
|
|||||||
});
|
});
|
||||||
selectNextChannelMock.mockReturnValue(null);
|
selectNextChannelMock.mockReturnValue(null);
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue(null);
|
fetchModelPricingCatalogMock.mockResolvedValue(null);
|
||||||
|
(config as any).codexHeaderDefaults = {
|
||||||
|
userAgent: '',
|
||||||
|
betaFeatures: '',
|
||||||
|
};
|
||||||
|
(config as any).payloadRules = {
|
||||||
|
default: [],
|
||||||
|
defaultRaw: [],
|
||||||
|
override: [],
|
||||||
|
overrideRaw: [],
|
||||||
|
filter: [],
|
||||||
|
};
|
||||||
config.proxyEmptyContentFailEnabled = false;
|
config.proxyEmptyContentFailEnabled = false;
|
||||||
config.proxyErrorKeywords = [];
|
config.proxyErrorKeywords = [];
|
||||||
});
|
});
|
||||||
@@ -2016,6 +2032,87 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(targetUrl).toContain('/v1/messages');
|
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 () => {
|
it('prefers native /v1/responses for claude-family /v1/responses requests that explicitly ask for encrypted reasoning', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
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({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
site: { name: 'claude-site', url: 'https://upstream.example.com', platform: 'claude' },
|
site: { name: 'claude-site', url: 'https://upstream.example.com', platform: 'claude' },
|
||||||
@@ -2202,6 +2299,18 @@ describe('chat proxy stream behavior', () => {
|
|||||||
tokenValue: 'sk-demo',
|
tokenValue: 'sk-demo',
|
||||||
actualModel: 'upstream-claude',
|
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({
|
const response = await app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -2225,12 +2334,119 @@ describe('chat proxy stream behavior', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(200);
|
||||||
const body = response.json();
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(body?.error?.type).toBe('invalid_request_error');
|
const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any];
|
||||||
expect(body?.error?.message).toContain('input_file.file_url');
|
expect(targetUrl).toContain('/v1/messages');
|
||||||
expect(body?.error?.message).toContain('/v1/responses');
|
const forwardedBody = JSON.parse(options.body);
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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 () => {
|
it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without injecting a generic default include', async () => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
@@ -15,8 +14,10 @@ import { composeProxyLogMessage } from './logPathMeta.js';
|
|||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { detectProxyFailure } from './proxyFailureJudge.js';
|
import { detectProxyFailure } from './proxyFailureJudge.js';
|
||||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -26,12 +27,16 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
const requestedModel = body?.model;
|
const requestedModel = body?.model;
|
||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
|
||||||
}
|
}
|
||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
const downstreamPath = '/v1/completions';
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
const isStream = body.stream === true;
|
const isStream = body.stream === true;
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
@@ -75,8 +80,28 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
const errText = await upstream.text().catch(() => 'unknown error');
|
const errText = await upstream.text().catch(() => 'unknown error');
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
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 })) {
|
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
@@ -174,12 +199,25 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
parsedUsage,
|
parsedUsage,
|
||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
selected,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
requestedModel,
|
||||||
);
|
'success',
|
||||||
|
200,
|
||||||
|
latency,
|
||||||
|
null,
|
||||||
|
retryCount,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
resolvedUsage.promptTokens,
|
||||||
|
resolvedUsage.completionTokens,
|
||||||
|
resolvedUsage.totalTokens,
|
||||||
|
estimatedCost,
|
||||||
|
billingDetails,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,17 +233,28 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
|
||||||
if (failure) {
|
if (failure) {
|
||||||
const errText = failure.reason;
|
const errText = failure.reason;
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
|
status: failure.status,
|
||||||
|
errorText: errText,
|
||||||
|
modelName: selected.actualModel,
|
||||||
|
});
|
||||||
logProxy(
|
logProxy(
|
||||||
selected,
|
selected,
|
||||||
requestedModel,
|
requestedModel,
|
||||||
'failed',
|
'failed',
|
||||||
failure.status,
|
failure.status,
|
||||||
latency,
|
latency,
|
||||||
errText,
|
errText,
|
||||||
retryCount,
|
retryCount,
|
||||||
logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
downstreamApiKeyId,
|
||||||
);
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
null,
|
||||||
|
clientContext,
|
||||||
|
downstreamPath,
|
||||||
|
);
|
||||||
|
|
||||||
if (shouldRetryProxyRequest(failure.status, errText) && retryCount < MAX_RETRIES) {
|
if (shouldRetryProxyRequest(failure.status, errText) && retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
@@ -245,16 +294,49 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
|
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', 200, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
selected,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
requestedModel,
|
||||||
);
|
'success',
|
||||||
return reply.send(data);
|
200,
|
||||||
} catch (err: any) {
|
latency,
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
null,
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : 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, {
|
||||||
|
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) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
@@ -271,47 +353,58 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logProxy(
|
async function logProxy(
|
||||||
selected: any,
|
selected: any,
|
||||||
modelRequested: string,
|
modelRequested: string,
|
||||||
status: string,
|
status: string,
|
||||||
httpStatus: number,
|
httpStatus: number,
|
||||||
latencyMs: number,
|
latencyMs: number,
|
||||||
errorMessage: string | null,
|
errorMessage: string | null,
|
||||||
retryCount: number,
|
retryCount: number,
|
||||||
downstreamApiKeyId: number | null = null,
|
downstreamApiKeyId: number | null = null,
|
||||||
promptTokens = 0,
|
promptTokens = 0,
|
||||||
completionTokens = 0,
|
completionTokens = 0,
|
||||||
totalTokens = 0,
|
totalTokens = 0,
|
||||||
estimatedCost = 0,
|
estimatedCost = 0,
|
||||||
billingDetails: unknown = null,
|
billingDetails: unknown = null,
|
||||||
) {
|
clientContext: DownstreamClientContext | null = null,
|
||||||
try {
|
downstreamPath = '/v1/completions',
|
||||||
const createdAt = formatUtcSqlDateTime(new Date());
|
) {
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
try {
|
||||||
downstreamPath: '/v1/completions',
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
errorMessage,
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
});
|
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||||
await db.insert(schema.proxyLogs).values({
|
? clientContext.clientKind
|
||||||
routeId: selected.channel.routeId,
|
: null,
|
||||||
channelId: selected.channel.id,
|
sessionId: clientContext?.sessionId || null,
|
||||||
accountId: selected.account.id,
|
traceHint: clientContext?.traceHint || null,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamPath,
|
||||||
modelRequested,
|
errorMessage,
|
||||||
modelActual: selected.actualModel,
|
});
|
||||||
status,
|
await insertProxyLog({
|
||||||
|
routeId: selected.channel.routeId,
|
||||||
|
channelId: selected.channel.id,
|
||||||
|
accountId: selected.account.id,
|
||||||
|
downstreamApiKeyId,
|
||||||
|
modelRequested,
|
||||||
|
modelActual: selected.actualModel,
|
||||||
|
status,
|
||||||
httpStatus,
|
httpStatus,
|
||||||
latencyMs,
|
latencyMs,
|
||||||
promptTokens,
|
promptTokens,
|
||||||
completionTokens,
|
completionTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
estimatedCost,
|
estimatedCost,
|
||||||
billingDetails: billingDetails ? JSON.stringify(billingDetails) : null,
|
billingDetails,
|
||||||
errorMessage: normalizedErrorMessage,
|
clientFamily: clientContext?.clientKind || null,
|
||||||
retryCount,
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
createdAt,
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
}).run();
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
} catch (error) {
|
errorMessage: normalizedErrorMessage,
|
||||||
|
retryCount,
|
||||||
|
createdAt,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
console.warn('[proxy/completions] failed to write proxy log', error);
|
console.warn('[proxy/completions] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('downstream client context route logging', () => {
|
describe('downstream client context route logging', () => {
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ describe('isCodexResponsesSurface', () => {
|
|||||||
})).toBe(true);
|
})).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', () => {
|
it('returns false for generic responses clients', () => {
|
||||||
expect(isCodexResponsesSurface({
|
expect(isCodexResponsesSurface({
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
@@ -50,6 +60,9 @@ describe('detectDownstreamClientContext', () => {
|
|||||||
},
|
},
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
clientKind: 'codex',
|
clientKind: 'codex',
|
||||||
|
clientAppId: 'codex_cli_rs',
|
||||||
|
clientAppName: 'Codex CLI',
|
||||||
|
clientConfidence: 'exact',
|
||||||
sessionId: 'codex-session-123',
|
sessionId: 'codex-session-123',
|
||||||
traceHint: 'codex-session-123',
|
traceHint: 'codex-session-123',
|
||||||
});
|
});
|
||||||
@@ -63,6 +76,37 @@ describe('detectDownstreamClientContext', () => {
|
|||||||
},
|
},
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
clientKind: 'codex',
|
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,
|
body,
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
clientKind: 'claude_code',
|
clientKind: 'claude_code',
|
||||||
|
clientAppId: 'claude_code',
|
||||||
|
clientAppName: 'Claude Code',
|
||||||
|
clientConfidence: 'exact',
|
||||||
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
||||||
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
|
||||||
});
|
});
|
||||||
@@ -128,6 +175,54 @@ describe('detectDownstreamClientContext', () => {
|
|||||||
},
|
},
|
||||||
})).toEqual({
|
})).toEqual({
|
||||||
clientKind: 'gemini_cli',
|
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';
|
import type { CliProfileId } from '../../proxy-core/cliProfiles/types.js';
|
||||||
|
|
||||||
export type DownstreamClientKind = CliProfileId;
|
export type DownstreamClientKind = CliProfileId;
|
||||||
|
export type DownstreamClientConfidence = 'exact' | 'heuristic';
|
||||||
|
|
||||||
export type DownstreamClientContext = {
|
export type DownstreamClientContext = {
|
||||||
clientKind: DownstreamClientKind;
|
clientKind: DownstreamClientKind;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
traceHint?: 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 {
|
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
|
||||||
return isCodexResponsesSurfaceViaProfile(headers);
|
return isCodexResponsesSurfaceViaProfile(headers);
|
||||||
}
|
}
|
||||||
@@ -27,9 +283,15 @@ export function detectDownstreamClientContext(input: {
|
|||||||
body?: unknown;
|
body?: unknown;
|
||||||
}): DownstreamClientContext {
|
}): DownstreamClientContext {
|
||||||
const detected = detectCliProfile(input);
|
const detected = detectCliProfile(input);
|
||||||
|
const fingerprint = detectDownstreamClientFingerprint(input);
|
||||||
|
const protocolClientApp = fingerprint ? null : detectProtocolClientApp({
|
||||||
|
clientKind: detected.id,
|
||||||
|
headers: input.headers,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
clientKind: detected.id,
|
clientKind: detected.id,
|
||||||
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
...(detected.sessionId ? { sessionId: detected.sessionId } : {}),
|
||||||
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
...(detected.traceHint ? { traceHint: detected.traceHint } : {}),
|
||||||
|
...(fingerprint || protocolClientApp || {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { isModelAllowedByPolicyOrAllowedRoutes, recordManagedKeyCostUsage } from
|
|||||||
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
|
import { EMPTY_DOWNSTREAM_ROUTING_POLICY, type DownstreamRoutingPolicy } from '../../services/downstreamPolicyTypes.js';
|
||||||
|
|
||||||
export function getDownstreamRoutingPolicy(request: FastifyRequest): DownstreamRoutingPolicy {
|
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(
|
export async function ensureModelAllowedForDownstreamKey(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
@@ -16,6 +15,8 @@ import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
|||||||
import { resolveProxyLogBilling } from './proxyBilling.js';
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -29,8 +30,12 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
const downstreamPath = '/v1/embeddings';
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
@@ -71,8 +76,28 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
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 })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
@@ -97,8 +122,8 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
let data: any = {};
|
let data: any = {};
|
||||||
try { data = JSON.parse(text); } catch { data = {}; }
|
try { data = JSON.parse(text); } catch { data = {}; }
|
||||||
const parsedUsage = parseProxyUsage(data);
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
|
const parsedUsage = parseProxyUsage(data);
|
||||||
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
account: selected.account,
|
account: selected.account,
|
||||||
@@ -121,16 +146,37 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
parsedUsage,
|
parsedUsage,
|
||||||
resolvedUsage,
|
resolvedUsage,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
|
||||||
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null,
|
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId,
|
||||||
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails,
|
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
|
||||||
);
|
);
|
||||||
return reply.code(upstream.status).send(data);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
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) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
@@ -159,18 +205,25 @@ async function logProxy(
|
|||||||
totalTokens = 0,
|
totalTokens = 0,
|
||||||
estimatedCost = 0,
|
estimatedCost = 0,
|
||||||
billingDetails: unknown = null,
|
billingDetails: unknown = null,
|
||||||
|
clientContext: DownstreamClientContext | null = null,
|
||||||
|
downstreamPath = '/v1/embeddings',
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const createdAt = formatUtcSqlDateTime(new Date());
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
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,
|
errorMessage,
|
||||||
});
|
});
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamApiKeyId,
|
||||||
modelRequested,
|
modelRequested,
|
||||||
modelActual: selected.actualModel,
|
modelActual: selected.actualModel,
|
||||||
status,
|
status,
|
||||||
@@ -180,11 +233,15 @@ async function logProxy(
|
|||||||
completionTokens,
|
completionTokens,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
estimatedCost,
|
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,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/embeddings] failed to write proxy log', error);
|
console.warn('[proxy/embeddings] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ describe('executeEndpointFlow', () => {
|
|||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
expect(dispatchRequest).toHaveBeenCalledTimes(1);
|
expect(dispatchRequest).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatchRequest.mock.calls[0]?.[1]).toBe('https://example.com/v1/responses');
|
||||||
expect(fetchMock).not.toHaveBeenCalled();
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -155,6 +156,40 @@ describe('executeEndpointFlow', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
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 () => {
|
it('accepts recovered response from tryRecover hook', async () => {
|
||||||
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||||
error: { message: 'upstream_error', type: 'upstream_error' },
|
error: { message: 'upstream_error', type: 'upstream_error' },
|
||||||
@@ -185,22 +220,6 @@ describe('executeEndpointFlow', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it('returns normalized final error when all endpoints fail', async () => {
|
||||||
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
|
||||||
error: { message: 'upstream_error', type: 'upstream_error' },
|
error: { message: 'upstream_error', type: 'upstream_error' },
|
||||||
|
|||||||
@@ -27,6 +27,14 @@ export type EndpointAttemptContext = {
|
|||||||
rawErrText: string;
|
rawErrText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EndpointAttemptSuccessContext = {
|
||||||
|
endpointIndex: number;
|
||||||
|
endpointCount: number;
|
||||||
|
request: BuiltEndpointRequest;
|
||||||
|
targetUrl: string;
|
||||||
|
response: Awaited<ReturnType<typeof fetch>>;
|
||||||
|
};
|
||||||
|
|
||||||
export type EndpointRecoverResult = {
|
export type EndpointRecoverResult = {
|
||||||
upstream: Awaited<ReturnType<typeof fetch>>;
|
upstream: Awaited<ReturnType<typeof fetch>>;
|
||||||
upstreamPath: string;
|
upstreamPath: string;
|
||||||
@@ -51,7 +59,6 @@ export function withUpstreamPath(path: string, message: string): string {
|
|||||||
|
|
||||||
type ExecuteEndpointFlowInput = {
|
type ExecuteEndpointFlowInput = {
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
proxyUrl?: string | null;
|
|
||||||
endpointCandidates: UpstreamEndpoint[];
|
endpointCandidates: UpstreamEndpoint[];
|
||||||
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
|
buildRequest: (endpoint: UpstreamEndpoint, endpointIndex: number) => BuiltEndpointRequest;
|
||||||
dispatchRequest?: (
|
dispatchRequest?: (
|
||||||
@@ -61,14 +68,10 @@ type ExecuteEndpointFlowInput = {
|
|||||||
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
|
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
|
||||||
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
|
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
|
||||||
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
|
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> {
|
export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Promise<EndpointFlowResult> {
|
||||||
const endpointCount = input.endpointCandidates.length;
|
const endpointCount = input.endpointCandidates.length;
|
||||||
if (endpointCount <= 0) {
|
if (endpointCount <= 0) {
|
||||||
@@ -86,10 +89,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
|||||||
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
|
for (let endpointIndex = 0; endpointIndex < endpointCount; endpointIndex += 1) {
|
||||||
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
|
const endpoint = input.endpointCandidates[endpointIndex] as UpstreamEndpoint;
|
||||||
const request = input.buildRequest(endpoint, endpointIndex);
|
const request = input.buildRequest(endpoint, endpointIndex);
|
||||||
const defaultTarget = buildUpstreamUrl(input.siteUrl, request.path);
|
const targetUrl = buildUpstreamUrl(input.siteUrl, request.path);
|
||||||
const targetUrl = input.proxyUrl
|
|
||||||
? buildAbsoluteUrl(input.proxyUrl, request.path)
|
|
||||||
: defaultTarget;
|
|
||||||
|
|
||||||
let response = input.dispatchRequest
|
let response = input.dispatchRequest
|
||||||
? await input.dispatchRequest(request, targetUrl)
|
? await input.dispatchRequest(request, targetUrl)
|
||||||
@@ -100,6 +100,13 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
await input.onAttemptSuccess?.({
|
||||||
|
endpointIndex,
|
||||||
|
endpointCount,
|
||||||
|
request,
|
||||||
|
targetUrl,
|
||||||
|
response,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
upstream: response,
|
upstream: response,
|
||||||
@@ -120,6 +127,13 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
|||||||
if (input.tryRecover) {
|
if (input.tryRecover) {
|
||||||
const recovered = await input.tryRecover(baseContext);
|
const recovered = await input.tryRecover(baseContext);
|
||||||
if (recovered?.upstream?.ok) {
|
if (recovered?.upstream?.ok) {
|
||||||
|
await input.onAttemptSuccess?.({
|
||||||
|
endpointIndex,
|
||||||
|
endpointCount,
|
||||||
|
request: baseContext.request,
|
||||||
|
targetUrl: baseContext.targetUrl,
|
||||||
|
response: recovered.upstream,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
upstream: recovered.upstream,
|
upstream: recovered.upstream,
|
||||||
@@ -135,6 +149,10 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
|
|||||||
baseContext.request.path,
|
baseContext.request.path,
|
||||||
summarizeUpstreamError(response.status, rawErrText),
|
summarizeUpstreamError(response.status, rawErrText),
|
||||||
);
|
);
|
||||||
|
await input.onAttemptFailure?.({
|
||||||
|
...baseContext,
|
||||||
|
errText,
|
||||||
|
});
|
||||||
|
|
||||||
const isLastEndpoint = endpointIndex >= endpointCount - 1;
|
const isLastEndpoint = endpointIndex >= endpointCount - 1;
|
||||||
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
|
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
const fetchModelPricingCatalogMock = vi.fn();
|
const fetchModelPricingCatalogMock = vi.fn();
|
||||||
@@ -74,6 +75,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
select: (..._args: unknown[]) => createDbSelectChain(),
|
select: (..._args: unknown[]) => createDbSelectChain(),
|
||||||
insert: (arg: unknown) => dbInsertMock(arg),
|
insert: (arg: unknown) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
modelAvailability: {
|
modelAvailability: {
|
||||||
@@ -185,6 +189,7 @@ describe('gemini native proxy routes', () => {
|
|||||||
selectNextChannelMock.mockReturnValue(null);
|
selectNextChannelMock.mockReturnValue(null);
|
||||||
recordSuccessMock.mockResolvedValue(undefined);
|
recordSuccessMock.mockResolvedValue(undefined);
|
||||||
recordFailureMock.mockResolvedValue(undefined);
|
recordFailureMock.mockResolvedValue(undefined);
|
||||||
|
resetUpstreamEndpointRuntimeState();
|
||||||
explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 });
|
explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 });
|
||||||
isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true);
|
isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
@@ -258,7 +263,10 @@ describe('gemini native proxy routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
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 [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
|
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
expect(firstUrl).toContain('key=gemini-key');
|
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 () => {
|
it('routes Gemini native generateContent requests to antigravity upstreams through the internal content endpoint', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 43, routeId: 22 },
|
channel: { id: 43, routeId: 22 },
|
||||||
@@ -1414,7 +1508,10 @@ describe('gemini native proxy routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
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 [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
|
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
expect(firstUrl).toContain('key=gemini-key');
|
expect(firstUrl).toContain('key=gemini-key');
|
||||||
@@ -1464,7 +1561,10 @@ describe('gemini native proxy routes', () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
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 () => {
|
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(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
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 () => {
|
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(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
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 () => {
|
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(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
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');
|
expect(response.body).toContain('hello from fallback sse');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.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 { cloneFormDataWithOverrides, ensureMultipartBufferParser, parseMultipartFormData } from './multipart.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -27,8 +28,12 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
const downstreamPath = '/v1/images/generations';
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body,
|
||||||
|
});
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
@@ -70,8 +75,24 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
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 })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -103,13 +124,29 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
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);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
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) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
@@ -137,8 +174,12 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
const downstreamPath = '/v1/images/edits';
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body: jsonBody || Object.fromEntries(multipartForm?.entries?.() || []),
|
||||||
|
});
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
@@ -192,8 +233,24 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
const upstream = await fetch(targetUrl, requestInit);
|
const upstream = await fetch(targetUrl, requestInit);
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
|
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 })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -225,13 +282,29 @@ export async function imagesProxyRoute(app: FastifyInstance) {
|
|||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
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);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null, 0, '/v1/images/edits');
|
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) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount++;
|
retryCount++;
|
||||||
continue;
|
continue;
|
||||||
@@ -268,18 +341,24 @@ async function logProxy(
|
|||||||
downstreamApiKeyId: number | null = null,
|
downstreamApiKeyId: number | null = null,
|
||||||
estimatedCost = 0,
|
estimatedCost = 0,
|
||||||
downstreamPath = '/v1/images/generations',
|
downstreamPath = '/v1/images/generations',
|
||||||
|
clientContext: DownstreamClientContext | null = null,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const createdAt = formatUtcSqlDateTime(new Date());
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
const normalizedErrorMessage = composeProxyLogMessage({
|
const normalizedErrorMessage = composeProxyLogMessage({
|
||||||
|
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||||
|
? clientContext.clientKind
|
||||||
|
: null,
|
||||||
|
sessionId: clientContext?.sessionId || null,
|
||||||
|
traceHint: clientContext?.traceHint || null,
|
||||||
downstreamPath,
|
downstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
});
|
});
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamApiKeyId,
|
||||||
modelRequested,
|
modelRequested,
|
||||||
modelActual: selected.actualModel,
|
modelActual: selected.actualModel,
|
||||||
status,
|
status,
|
||||||
@@ -289,10 +368,14 @@ async function logProxy(
|
|||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
estimatedCost,
|
estimatedCost,
|
||||||
|
clientFamily: clientContext?.clientKind || null,
|
||||||
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
errorMessage: normalizedErrorMessage,
|
errorMessage: normalizedErrorMessage,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/images] failed to write proxy log', error);
|
console.warn('[proxy/images] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type ComposeProxyLogMessageArgs = {
|
|||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ParsedPathMeta = {
|
export type ParsedProxyLogMessageMeta = {
|
||||||
clientKind: string | null;
|
clientKind: string | null;
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
downstreamPath: string | null;
|
downstreamPath: string | null;
|
||||||
@@ -15,7 +15,7 @@ type ParsedPathMeta = {
|
|||||||
messageText: string;
|
messageText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
|
export function parseProxyLogMessageMeta(rawMessage: string): ParsedProxyLogMessageMeta {
|
||||||
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
|
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
|
||||||
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
|
||||||
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
|
||||||
@@ -42,7 +42,7 @@ export function composeProxyLogMessage({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
}: ComposeProxyLogMessageArgs): string | null {
|
}: ComposeProxyLogMessageArgs): string | null {
|
||||||
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
|
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
|
||||||
const parsed = parseExistingPathMeta(rawMessage);
|
const parsed = parseProxyLogMessageMeta(rawMessage);
|
||||||
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
|
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
|
||||||
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
|
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
|
||||||
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
|
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type DbModule = typeof import('../../db/index.js');
|
|||||||
type ProxyRouterModule = typeof import('./router.js');
|
type ProxyRouterModule = typeof import('./router.js');
|
||||||
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
|
type TokenRouterModule = typeof import('../../services/tokenRouter.js');
|
||||||
type TokensRoutesModule = typeof import('../api/tokens.js');
|
type TokensRoutesModule = typeof import('../api/tokens.js');
|
||||||
|
type ConfigModule = typeof import('../../config.js');
|
||||||
|
|
||||||
describe('/v1/models route', () => {
|
describe('/v1/models route', () => {
|
||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
@@ -15,6 +16,7 @@ describe('/v1/models route', () => {
|
|||||||
let proxyRoutes: ProxyRouterModule['proxyRoutes'];
|
let proxyRoutes: ProxyRouterModule['proxyRoutes'];
|
||||||
let tokensRoutes: TokensRoutesModule['tokensRoutes'];
|
let tokensRoutes: TokensRoutesModule['tokensRoutes'];
|
||||||
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
|
||||||
|
let config: ConfigModule['config'];
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let dataDir = '';
|
let dataDir = '';
|
||||||
|
|
||||||
@@ -27,12 +29,15 @@ describe('/v1/models route', () => {
|
|||||||
const proxyRouterModule = await import('./router.js');
|
const proxyRouterModule = await import('./router.js');
|
||||||
const tokenRouterModule = await import('../../services/tokenRouter.js');
|
const tokenRouterModule = await import('../../services/tokenRouter.js');
|
||||||
const tokensRoutesModule = await import('../api/tokens.js');
|
const tokensRoutesModule = await import('../api/tokens.js');
|
||||||
|
const configModule = await import('../../config.js');
|
||||||
|
|
||||||
db = dbModule.db;
|
db = dbModule.db;
|
||||||
schema = dbModule.schema;
|
schema = dbModule.schema;
|
||||||
proxyRoutes = proxyRouterModule.proxyRoutes;
|
proxyRoutes = proxyRouterModule.proxyRoutes;
|
||||||
tokensRoutes = tokensRoutesModule.tokensRoutes;
|
tokensRoutes = tokensRoutesModule.tokensRoutes;
|
||||||
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
|
||||||
|
config = configModule.config;
|
||||||
|
config.proxyToken = 'sk-global-proxy-token';
|
||||||
|
|
||||||
app = Fastify();
|
app = Fastify();
|
||||||
await app.register(tokensRoutes);
|
await app.register(tokensRoutes);
|
||||||
@@ -108,6 +113,7 @@ describe('/v1/models route', () => {
|
|||||||
name: 'managed-key',
|
name: 'managed-key',
|
||||||
key: 'sk-managed-models',
|
key: 'sk-managed-models',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
supportedModels: JSON.stringify(['routable-model']),
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -129,6 +135,64 @@ describe('/v1/models route', () => {
|
|||||||
expect(ids).not.toContain('orphan-model');
|
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 () => {
|
it('returns only whitelist models for managed key with supportedModels policy', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'test-site',
|
name: 'test-site',
|
||||||
@@ -290,6 +354,58 @@ describe('/v1/models route', () => {
|
|||||||
expect(ids).not.toContain('claude-sonnet-4-5');
|
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 () => {
|
it('returns only explicit-group public name while hiding source exact routes', async () => {
|
||||||
const site = await db.insert(schema.sites).values({
|
const site = await db.insert(schema.sites).values({
|
||||||
name: 'explicit-group-site',
|
name: 'explicit-group-site',
|
||||||
@@ -461,6 +577,7 @@ describe('/v1/models route', () => {
|
|||||||
name: 'search-key',
|
name: 'search-key',
|
||||||
key: 'sk-search-key',
|
key: 'sk-search-key',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
supportedModels: JSON.stringify(['gpt-4.1']),
|
||||||
}).run();
|
}).run();
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { config } from '../../config.js';
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
const selectChannelMock = vi.fn();
|
const selectChannelMock = vi.fn();
|
||||||
@@ -17,6 +18,7 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
|
|||||||
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
const refreshOauthAccessTokenSingleflightMock = vi.fn();
|
||||||
const recordOauthQuotaResetHintMock = vi.fn();
|
const recordOauthQuotaResetHintMock = vi.fn();
|
||||||
const insertedProxyLogs: Record<string, unknown>[] = [];
|
const insertedProxyLogs: Record<string, unknown>[] = [];
|
||||||
|
const originalProxyEmptyContentFailEnabled = config.proxyEmptyContentFailEnabled;
|
||||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||||
values: (values: Record<string, unknown>) => {
|
values: (values: Record<string, unknown>) => {
|
||||||
insertedProxyLogs.push(values);
|
insertedProxyLogs.push(values);
|
||||||
@@ -78,6 +80,8 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
@@ -109,6 +113,7 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
config.proxyEmptyContentFailEnabled = false;
|
||||||
fetchMock.mockReset();
|
fetchMock.mockReset();
|
||||||
selectChannelMock.mockReset();
|
selectChannelMock.mockReset();
|
||||||
selectNextChannelMock.mockReset();
|
selectNextChannelMock.mockReset();
|
||||||
@@ -152,6 +157,7 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
config.proxyEmptyContentFailEnabled = originalProxyEmptyContentFailEnabled;
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,6 +214,69 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
expect(response.json()?.output_text).toContain('ok after codex token 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 () => {
|
it('sends an explicit empty instructions field to codex responses when downstream body has no system prompt', async () => {
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
id: 'resp_codex_no_system',
|
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');
|
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 () => {
|
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'));
|
resolveProxyUsageWithSelfLogFallbackMock.mockRejectedValueOnce(new Error('usage accounting failed'));
|
||||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||||
@@ -578,4 +680,39 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
expect(recordFailureMock).not.toHaveBeenCalled();
|
expect(recordFailureMock).not.toHaveBeenCalled();
|
||||||
expect(recordSuccessMock).toHaveBeenCalledTimes(1);
|
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: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Fastify, { type FastifyInstance } from 'fastify';
|
import Fastify, { type FastifyInstance } from 'fastify';
|
||||||
|
import { createServer, type Server } from 'node:http';
|
||||||
import { AddressInfo } from 'node:net';
|
import { AddressInfo } from 'node:net';
|
||||||
import WebSocket from 'ws';
|
import WebSocket, { WebSocketServer } from 'ws';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const fetchMock = vi.fn();
|
const fetchMock = vi.fn();
|
||||||
@@ -72,6 +73,8 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
@@ -175,6 +178,14 @@ function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 100
|
|||||||
describe('responses websocket transport', () => {
|
describe('responses websocket transport', () => {
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let baseUrl: string;
|
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 () => {
|
beforeAll(async () => {
|
||||||
const { responsesProxyRoute } = await import('./responses.js');
|
const { responsesProxyRoute } = await import('./responses.js');
|
||||||
@@ -183,6 +194,39 @@ describe('responses websocket transport', () => {
|
|||||||
await app.listen({ port: 0, host: '127.0.0.1' });
|
await app.listen({ port: 0, host: '127.0.0.1' });
|
||||||
const address = app.server.address() as AddressInfo;
|
const address = app.server.address() as AddressInfo;
|
||||||
baseUrl = `ws://127.0.0.1:${address.port}`;
|
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(() => {
|
beforeEach(() => {
|
||||||
@@ -202,24 +246,90 @@ describe('responses websocket transport', () => {
|
|||||||
selectChannelMock.mockReturnValue(selectedChannel);
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
selectNextChannelMock.mockReturnValue(null);
|
selectNextChannelMock.mockReturnValue(null);
|
||||||
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
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 () => {
|
afterAll(async () => {
|
||||||
|
await new Promise<void>((resolve) => rejectedUpgradeServer.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => upstreamServer.close(() => resolve()));
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts response.create over GET /v1/responses websocket and forwards streamed responses events', async () => {
|
it('accepts response.create over GET /v1/responses websocket and forwards streamed responses events', async () => {
|
||||||
fetchMock.mockResolvedValue(createSseResponse([
|
const selectedChannel = createSelectedChannel({
|
||||||
'event: response.created\n',
|
siteUrl: upstreamSiteUrl,
|
||||||
'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',
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_ws","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
'event: response.output_text.delta\n',
|
upstreamMessageHandler = (socket) => {
|
||||||
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"pong"}\n\n',
|
socket.send(JSON.stringify({
|
||||||
'event: response.completed\n',
|
type: 'response.created',
|
||||||
'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',
|
response: {
|
||||||
'data: [DONE]\n\n',
|
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`);
|
const socket = new WebSocket(`${baseUrl}/v1/responses`);
|
||||||
await waitForSocketOpen(socket);
|
await waitForSocketOpen(socket);
|
||||||
@@ -247,7 +357,8 @@ describe('responses websocket transport', () => {
|
|||||||
'response.completed',
|
'response.completed',
|
||||||
]);
|
]);
|
||||||
expect(messages[3]?.response?.output?.[0]?.content?.[0]?.text).toBe('pong');
|
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 () => {
|
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');
|
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 () => {
|
it('merges follow-up response.create payloads when the selected upstream does not support incremental mode', async () => {
|
||||||
const selectedChannel = createSelectedChannel({
|
const selectedChannel = createSelectedChannel({
|
||||||
sitePlatform: 'openai',
|
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 () => {
|
it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => {
|
||||||
fetchMock
|
const selectedChannel = createSelectedChannel({
|
||||||
.mockResolvedValueOnce(createSseResponse([
|
siteUrl: upstreamSiteUrl,
|
||||||
'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',
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
'event: response.output_item.done\n',
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
'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',
|
upstreamMessageHandler = (socket, _parsed, requestIndex) => {
|
||||||
'event: response.completed\n',
|
if (requestIndex === 1) {
|
||||||
'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',
|
socket.send(JSON.stringify({
|
||||||
'data: [DONE]\n\n',
|
type: 'response.completed',
|
||||||
]))
|
response: {
|
||||||
.mockResolvedValueOnce(createSseResponse([
|
id: 'resp_ws_1',
|
||||||
'event: response.completed\n',
|
model: 'gpt-5.4',
|
||||||
'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',
|
status: 'completed',
|
||||||
'data: [DONE]\n\n',
|
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`);
|
const socket = new WebSocket(`${baseUrl}/v1/responses`);
|
||||||
await waitForSocketOpen(socket);
|
await waitForSocketOpen(socket);
|
||||||
@@ -415,12 +644,92 @@ describe('responses websocket transport', () => {
|
|||||||
await secondResponsePromise;
|
await secondResponsePromise;
|
||||||
socket.close();
|
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);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
const secondBody = JSON.parse(String(secondOptions.body));
|
const secondBody = JSON.parse(String(secondOptions.body));
|
||||||
expect(secondBody.previous_response_id).toBe('resp_ws_1');
|
expect(secondBody.previous_response_id).toBeUndefined();
|
||||||
expect(secondBody.model).toBe('gpt-5.4');
|
|
||||||
expect(secondBody.instructions).toBe('be helpful');
|
|
||||||
expect(secondBody.input).toEqual([
|
expect(secondBody.input).toEqual([
|
||||||
{
|
{
|
||||||
id: 'tool_out_1',
|
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 () => {
|
it('forwards generate=false upstream for websocket-capable upstreams instead of synthesizing prewarm events', async () => {
|
||||||
fetchMock.mockResolvedValueOnce(createSseResponse([
|
const selectedChannel = createSelectedChannel({
|
||||||
'event: response.completed\n',
|
siteUrl: upstreamSiteUrl,
|
||||||
'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',
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
]));
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
|
||||||
const socket = new WebSocket(`${baseUrl}/v1/responses`);
|
const socket = new WebSocket(`${baseUrl}/v1/responses`);
|
||||||
await waitForSocketOpen(socket);
|
await waitForSocketOpen(socket);
|
||||||
@@ -510,23 +819,42 @@ describe('responses websocket transport', () => {
|
|||||||
socket.close();
|
socket.close();
|
||||||
|
|
||||||
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
|
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(0);
|
||||||
const [, options] = fetchMock.mock.calls[0] as [string, RequestInit];
|
expect(upstreamRequests[0]).toMatchObject({
|
||||||
const forwardedBody = JSON.parse(String(options.body));
|
type: 'response.create',
|
||||||
expect(forwardedBody.generate).toBe(false);
|
generate: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits websocket error when the upstream stream closes before a terminal responses event', async () => {
|
it('emits websocket error when the upstream stream closes before a terminal responses event', async () => {
|
||||||
fetchMock.mockResolvedValue(createSseResponse([
|
const selectedChannel = createSelectedChannel({
|
||||||
'event: response.created\n',
|
siteUrl: upstreamSiteUrl,
|
||||||
'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',
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_ws","delta":"partial"}\n\n',
|
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`);
|
const socket = new WebSocket(`${baseUrl}/v1/responses`);
|
||||||
await waitForSocketOpen(socket);
|
await waitForSocketOpen(socket);
|
||||||
const messagesPromise = waitForSocketMessages(socket, 6, 400);
|
const messagesPromise = waitForSocketMessages(socket, 3, 400);
|
||||||
|
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'response.create',
|
type: 'response.create',
|
||||||
@@ -546,12 +874,8 @@ describe('responses websocket transport', () => {
|
|||||||
expect(messages.map((message) => message.type)).toEqual([
|
expect(messages.map((message) => message.type)).toEqual([
|
||||||
'response.created',
|
'response.created',
|
||||||
'response.output_text.delta',
|
'response.output_text.delta',
|
||||||
'response.output_text.done',
|
'error',
|
||||||
'response.content_part.done',
|
|
||||||
'response.output_item.done',
|
|
||||||
'response.failed',
|
|
||||||
]);
|
]);
|
||||||
expect(messages[5]?.response?.status).toBe('failed');
|
expect(messages[2]?.error?.message).toContain('stream closed before response.completed');
|
||||||
expect(messages[5]?.error?.message).toContain('stream closed before response.completed');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,4 +20,56 @@ describe('collectResponsesFinalPayloadFromSse', () => {
|
|||||||
.rejects
|
.rejects
|
||||||
.toThrow('quota exceeded');
|
.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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,36 @@ function getResponsesFailureMessage(payload: Record<string, unknown>): string {
|
|||||||
return 'upstream stream failed';
|
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 {
|
function hasCompleteFinalResponsesPayload(payload: Record<string, unknown>): boolean {
|
||||||
return (
|
return (
|
||||||
payload.object === 'response.compaction'
|
payload.object === 'response.compaction'
|
||||||
@@ -32,11 +62,101 @@ function hasCompleteFinalResponsesPayload(payload: Record<string, unknown>): boo
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectResponsesFinalPayloadFromSse(
|
function hasMeaningfulFinalResponsesPayload(payload: Record<string, unknown>): boolean {
|
||||||
upstream: { text(): Promise<string> },
|
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,
|
modelName: string,
|
||||||
): Promise<{ payload: Record<string, unknown>; rawText: string }> {
|
): { payload: Record<string, unknown>; rawText: string } {
|
||||||
const rawText = await upstream.text();
|
|
||||||
const { events } = openAiResponsesTransformer.pullSseEvents(rawText);
|
const { events } = openAiResponsesTransformer.pullSseEvents(rawText);
|
||||||
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
|
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
|
||||||
const aggregateState = openAiResponsesTransformer.aggregator.createState(modelName);
|
const aggregateState = openAiResponsesTransformer.aggregator.createState(modelName);
|
||||||
@@ -92,12 +212,17 @@ export async function collectResponsesFinalPayloadFromSse(
|
|||||||
|
|
||||||
const payloadType = typeof payload.type === 'string' ? payload.type : '';
|
const payloadType = typeof payload.type === 'string' ? payload.type : '';
|
||||||
const eventType = payloadType || event.event;
|
const eventType = payloadType || event.event;
|
||||||
|
rememberStreamResponseEnvelope(streamContext, payload);
|
||||||
usage = mergeProxyUsage(usage, parseProxyUsage(payload));
|
usage = mergeProxyUsage(usage, parseProxyUsage(payload));
|
||||||
captureCompletedPayloadFromEvent(eventType, payload);
|
captureCompletedPayloadFromEvent(eventType, payload);
|
||||||
if (completedPayload) {
|
if (completedPayload) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(payload, streamContext, modelName);
|
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(
|
||||||
|
payload,
|
||||||
|
streamContext,
|
||||||
|
modelName,
|
||||||
|
);
|
||||||
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.serialize({
|
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.serialize({
|
||||||
state: aggregateState,
|
state: aggregateState,
|
||||||
streamContext,
|
streamContext,
|
||||||
@@ -106,12 +231,19 @@ export async function collectResponsesFinalPayloadFromSse(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
completedPayload
|
||||||
|
&& !hasMeaningfulFinalResponsesPayload(completedPayload)
|
||||||
|
&& hasMeaningfulResponsesOutput(aggregateState.outputItems)
|
||||||
|
) {
|
||||||
|
completedPayload = materializeCompletedPayloadFromAggregate(aggregateState, streamContext, usage);
|
||||||
|
}
|
||||||
|
|
||||||
if (!completedPayload) {
|
if (!completedPayload) {
|
||||||
captureCompletedPayloadFromLines(openAiResponsesTransformer.aggregator.complete(
|
const materialized = materializeCompletedPayloadFromAggregate(aggregateState, streamContext, usage);
|
||||||
aggregateState,
|
if (materialized) {
|
||||||
streamContext,
|
completedPayload = materialized;
|
||||||
usage,
|
}
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completedPayload) {
|
if (completedPayload) {
|
||||||
@@ -123,3 +255,10 @@ export async function collectResponsesFinalPayloadFromSse(
|
|||||||
|
|
||||||
throw new Error('stream disconnected before response.completed');
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { IncomingMessage } from 'node:http';
|
import type { IncomingMessage } from 'node:http';
|
||||||
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
|
import { WebSocketServer, type RawData, type WebSocket } from 'ws';
|
||||||
|
import { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } from '../../proxy-core/runtime/codexWebsocketRuntime.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
|
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
|
import { buildUpstreamEndpointRequest } from './upstreamEndpoint.js';
|
||||||
|
|
||||||
const installedApps = new WeakSet<FastifyInstance>();
|
const installedApps = new WeakSet<FastifyInstance>();
|
||||||
const WS_TURN_STATE_HEADER = 'x-codex-turn-state';
|
const WS_TURN_STATE_HEADER = 'x-codex-turn-state';
|
||||||
const RESPONSES_WEBSOCKET_MODE_HEADER = 'x-metapi-responses-websocket-mode';
|
const RESPONSES_WEBSOCKET_MODE_HEADER = 'x-metapi-responses-websocket-mode';
|
||||||
const RESPONSES_WEBSOCKET_TRANSPORT_HEADER = 'x-metapi-responses-websocket-transport';
|
const RESPONSES_WEBSOCKET_TRANSPORT_HEADER = 'x-metapi-responses-websocket-transport';
|
||||||
|
const codexWebsocketRuntime = createCodexWebsocketRuntime();
|
||||||
|
|
||||||
|
type SelectedChannel = NonNullable<Awaited<ReturnType<typeof tokenRouter.selectChannel>>>;
|
||||||
|
|
||||||
type NormalizedResponsesWebsocketRequest =
|
type NormalizedResponsesWebsocketRequest =
|
||||||
| {
|
| {
|
||||||
@@ -42,6 +48,93 @@ function headerValueToTrimmedString(value: unknown): string {
|
|||||||
return '';
|
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 {
|
function parseJsonObject(raw: RawData): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(String(raw));
|
const parsed = JSON.parse(String(raw));
|
||||||
@@ -232,6 +325,77 @@ function collectResponsesOutput(payloads: unknown[]): unknown[] {
|
|||||||
.map(([, value]) => value);
|
.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[]> {
|
function buildInjectHeaders(request: IncomingMessage): Record<string, string | string[]> {
|
||||||
const headers: Record<string, string | string[]> = {};
|
const headers: Record<string, string | string[]> = {};
|
||||||
for (const [rawKey, rawValue] of Object.entries(request.headers)) {
|
for (const [rawKey, rawValue] of Object.entries(request.headers)) {
|
||||||
@@ -262,7 +426,7 @@ async function supportsResponsesWebsocketIncrementalInput(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const selected = await tokenRouter.previewSelectedChannel(requestModel);
|
const selected = await tokenRouter.previewSelectedChannel(requestModel);
|
||||||
return asTrimmedString(selected?.site?.platform).toLowerCase() === 'codex';
|
return selectedChannelSupportsIncrementalInput(selected, requestModel);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -273,8 +437,16 @@ async function handleResponsesWebsocketConnection(
|
|||||||
socket: WebSocket,
|
socket: WebSocket,
|
||||||
request: IncomingMessage,
|
request: IncomingMessage,
|
||||||
) {
|
) {
|
||||||
|
const websocketSessionId = headerValueToTrimmedString(request.headers['session_id'])
|
||||||
|
|| headerValueToTrimmedString(request.headers['session-id'])
|
||||||
|
|| randomUUID();
|
||||||
let lastRequest: Record<string, unknown> | null = null;
|
let lastRequest: Record<string, unknown> | null = null;
|
||||||
let lastResponseOutput: unknown[] = [];
|
let lastResponseOutput: unknown[] = [];
|
||||||
|
let selectedChannel: SelectedChannel | null = null;
|
||||||
|
|
||||||
|
socket.once('close', () => {
|
||||||
|
void codexWebsocketRuntime.closeSession(websocketSessionId);
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('message', async (raw) => {
|
socket.on('message', async (raw) => {
|
||||||
const parsed = parseJsonObject(raw);
|
const parsed = parseJsonObject(raw);
|
||||||
@@ -283,7 +455,9 @@ async function handleResponsesWebsocketConnection(
|
|||||||
return;
|
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(
|
const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally(
|
||||||
parsed,
|
parsed,
|
||||||
lastRequest,
|
lastRequest,
|
||||||
@@ -309,67 +483,95 @@ async function handleResponsesWebsocketConnection(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await app.inject({
|
if (!shouldReuseSelectedChannel(selectedChannel, requestModel)) {
|
||||||
method: 'POST',
|
selectedChannel = requestModel
|
||||||
url: '/v1/responses',
|
? await tokenRouter.selectChannel(requestModel)
|
||||||
headers: {
|
: null;
|
||||||
...buildInjectHeaders(request),
|
}
|
||||||
|
|
||||||
|
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',
|
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
|
||||||
...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
|
...(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 {
|
try {
|
||||||
payload = JSON.parse(response.body);
|
const runtimeResult = await codexWebsocketRuntime.sendRequest({
|
||||||
} catch {
|
sessionId: websocketSessionId,
|
||||||
payload = null;
|
requestUrl,
|
||||||
}
|
headers: prepared.headers,
|
||||||
writeResponsesWebsocketError(
|
body: prepared.body,
|
||||||
socket,
|
});
|
||||||
response.statusCode,
|
lastResponseOutput = collectResponsesOutput(runtimeResult.events);
|
||||||
response.statusMessage || 'Upstream error',
|
for (const payload of runtimeResult.events) {
|
||||||
payload,
|
socket.send(JSON.stringify(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 (error) {
|
||||||
} catch {
|
const runtimeError = error instanceof CodexWebsocketRuntimeError
|
||||||
// Ignore malformed SSE frames; the HTTP route already normalizes them.
|
? 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,
|
||||||
|
runtimeError.status || 408,
|
||||||
|
runtimeError.message,
|
||||||
|
runtimeError.payload,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
lastResponseOutput = collectResponsesOutput(forwardedPayloads);
|
|
||||||
if (!sawTerminalPayload) {
|
const forwardedOutput = await forwardResponsesRequestViaHttp({
|
||||||
writeResponsesWebsocketError(socket, 408, 'stream closed before response.completed');
|
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 () => {
|
app.addHook('onClose', async () => {
|
||||||
|
await codexWebsocketRuntime.closeAllSessions();
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
websocketServer.close(() => resolve());
|
websocketServer.close(() => resolve());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
db: {
|
db: {
|
||||||
insert: (arg: any) => dbInsertMock(arg),
|
insert: (arg: any) => dbInsertMock(arg),
|
||||||
},
|
},
|
||||||
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
|
hasProxyLogClientColumns: async () => false,
|
||||||
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { db, hasProxyLogDownstreamApiKeyIdColumn, schema } from '../../db/index.js';
|
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
@@ -13,6 +12,8 @@ import { composeProxyLogMessage } from './logPathMeta.js';
|
|||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
import { getProxyAuthContext } from '../../middleware/auth.js';
|
import { getProxyAuthContext } from '../../middleware/auth.js';
|
||||||
import { buildUpstreamUrl } from './upstreamUrl.js';
|
import { buildUpstreamUrl } from './upstreamUrl.js';
|
||||||
|
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
|
||||||
|
import { insertProxyLog } from '../../services/proxyLogStore.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
const DEFAULT_SEARCH_MODEL = '__search';
|
const DEFAULT_SEARCH_MODEL = '__search';
|
||||||
@@ -55,8 +56,12 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
|||||||
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
|
||||||
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
const downstreamPolicy = getDownstreamRoutingPolicy(request);
|
||||||
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
const downstreamApiKeyId = getProxyAuthContext(request)?.keyId ?? null;
|
||||||
const logDownstreamApiKeyId = downstreamApiKeyId !== null
|
const downstreamPath = '/v1/search';
|
||||||
&& await hasProxyLogDownstreamApiKeyIdColumn();
|
const clientContext = detectDownstreamClientContext({
|
||||||
|
downstreamPath,
|
||||||
|
headers: request.headers as Record<string, unknown>,
|
||||||
|
body,
|
||||||
|
});
|
||||||
const excludeChannelIds: number[] = [];
|
const excludeChannelIds: number[] = [];
|
||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
|
|
||||||
@@ -101,8 +106,23 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
|||||||
|
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
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 })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -126,13 +146,28 @@ export async function searchProxyRoute(app: FastifyInstance) {
|
|||||||
try { data = JSON.parse(text); } catch { data = { data: [] }; }
|
try { data = JSON.parse(text); } catch { data = { data: [] }; }
|
||||||
|
|
||||||
const latency = Date.now() - startTime;
|
const latency = Date.now() - startTime;
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
|
tokenRouter.recordSuccess(selected.channel.id, latency, 0, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, 0);
|
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);
|
return reply.code(upstream.status).send(data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, {
|
||||||
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount, logDownstreamApiKeyId ? downstreamApiKeyId : null);
|
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) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
@@ -158,14 +193,16 @@ async function logProxy(
|
|||||||
errorMessage: string | null,
|
errorMessage: string | null,
|
||||||
retryCount: number,
|
retryCount: number,
|
||||||
downstreamApiKeyId: number | null = null,
|
downstreamApiKeyId: number | null = null,
|
||||||
|
clientContext: DownstreamClientContext | null = null,
|
||||||
|
downstreamPath = '/v1/search',
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const createdAt = formatUtcSqlDateTime(new Date());
|
const createdAt = formatUtcSqlDateTime(new Date());
|
||||||
await db.insert(schema.proxyLogs).values({
|
await insertProxyLog({
|
||||||
routeId: selected.channel.routeId,
|
routeId: selected.channel.routeId,
|
||||||
channelId: selected.channel.id,
|
channelId: selected.channel.id,
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
...(downstreamApiKeyId !== null ? { downstreamApiKeyId } : {}),
|
downstreamApiKeyId,
|
||||||
modelRequested,
|
modelRequested,
|
||||||
modelActual: selected.actualModel,
|
modelActual: selected.actualModel,
|
||||||
status,
|
status,
|
||||||
@@ -176,12 +213,21 @@ async function logProxy(
|
|||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
estimatedCost: 0,
|
estimatedCost: 0,
|
||||||
errorMessage: composeProxyLogMessage({
|
errorMessage: composeProxyLogMessage({
|
||||||
downstreamPath: '/v1/search',
|
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
|
||||||
|
? clientContext.clientKind
|
||||||
|
: null,
|
||||||
|
sessionId: clientContext?.sessionId || null,
|
||||||
|
traceHint: clientContext?.traceHint || null,
|
||||||
|
downstreamPath,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}),
|
}),
|
||||||
|
clientFamily: clientContext?.clientKind || null,
|
||||||
|
clientAppId: clientContext?.clientAppId || null,
|
||||||
|
clientAppName: clientContext?.clientAppName || null,
|
||||||
|
clientConfidence: clientContext?.clientConfidence || null,
|
||||||
retryCount,
|
retryCount,
|
||||||
createdAt,
|
createdAt,
|
||||||
}).run();
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[proxy/search] failed to write proxy log', error);
|
console.warn('[proxy/search] failed to write proxy log', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { config } from '../../config.js';
|
||||||
|
|
||||||
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown): Promise<any> => null);
|
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: unknown): Promise<any> => null);
|
||||||
|
|
||||||
@@ -11,6 +12,9 @@ import {
|
|||||||
buildUpstreamEndpointRequest,
|
buildUpstreamEndpointRequest,
|
||||||
isUnsupportedMediaTypeError,
|
isUnsupportedMediaTypeError,
|
||||||
isEndpointDowngradeError,
|
isEndpointDowngradeError,
|
||||||
|
recordUpstreamEndpointFailure,
|
||||||
|
recordUpstreamEndpointSuccess,
|
||||||
|
resetUpstreamEndpointRuntimeState,
|
||||||
resolveUpstreamEndpointCandidates,
|
resolveUpstreamEndpointCandidates,
|
||||||
} from './upstreamEndpoint.js';
|
} from './upstreamEndpoint.js';
|
||||||
|
|
||||||
@@ -32,6 +36,18 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchModelPricingCatalogMock.mockReset();
|
fetchModelPricingCatalogMock.mockReset();
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue(null);
|
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 () => {
|
it('uses downstream-aligned endpoint priority for unknown platforms', async () => {
|
||||||
@@ -180,6 +196,129 @@ describe('resolveUpstreamEndpointCandidates', () => {
|
|||||||
expect(order).toEqual(['responses', 'messages', 'chat']);
|
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 () => {
|
it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => {
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
models: [
|
models: [
|
||||||
@@ -468,6 +607,136 @@ describe('buildUpstreamEndpointRequest', () => {
|
|||||||
expect(request.body.prompt_cache_key).toBe('codex-cache-123');
|
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', () => {
|
it('builds gemini-cli native requests with project envelope and bearer headers', () => {
|
||||||
const request = buildUpstreamEndpointRequest({
|
const request = buildUpstreamEndpointRequest({
|
||||||
endpoint: 'responses',
|
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({
|
const request = buildUpstreamEndpointRequest({
|
||||||
endpoint: 'responses',
|
endpoint: 'responses',
|
||||||
modelName: 'upstream-gpt',
|
modelName: 'upstream-gpt',
|
||||||
@@ -865,13 +1134,13 @@ describe('buildUpstreamEndpointRequest', () => {
|
|||||||
input: 'hello',
|
input: 'hello',
|
||||||
metadata: { trace: 'abc123' },
|
metadata: { trace: 'abc123' },
|
||||||
max_completion_tokens: 512,
|
max_completion_tokens: 512,
|
||||||
custom_vendor_flag: 'drop-me',
|
custom_vendor_flag: 'keep-me',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(request.path).toBe('/v1/responses');
|
expect(request.path).toBe('/v1/responses');
|
||||||
expect(request.body.metadata).toEqual({ trace: 'abc123' });
|
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_completion_tokens).toBeUndefined();
|
||||||
expect(request.body.max_output_tokens).toBe(512);
|
expect(request.body.max_output_tokens).toBe(512);
|
||||||
expect(request.body.input).toEqual([
|
expect(request.body.input).toEqual([
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
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 { resolveProviderProfile } from '../../proxy-core/providers/registry.js';
|
||||||
|
import { config } from '../../config.js';
|
||||||
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
import { fetchModelPricingCatalog } from '../../services/modelPricingService.js';
|
||||||
|
import { applyPayloadRules } from '../../services/payloadRules.js';
|
||||||
import type { DownstreamFormat } from '../../transformers/shared/normalized.js';
|
import type { DownstreamFormat } from '../../transformers/shared/normalized.js';
|
||||||
import {
|
import {
|
||||||
convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaTransformer,
|
convertOpenAiBodyToResponsesBody as convertOpenAiBodyToResponsesBodyViaTransformer,
|
||||||
@@ -13,7 +19,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildGeminiGenerateContentRequestFromOpenAi,
|
buildGeminiGenerateContentRequestFromOpenAi,
|
||||||
} from './geminiCliCompat.js';
|
} from './geminiCliCompat.js';
|
||||||
export {
|
import {
|
||||||
buildMinimalJsonHeadersForCompatibility,
|
buildMinimalJsonHeadersForCompatibility,
|
||||||
isEndpointDispatchDeniedError,
|
isEndpointDispatchDeniedError,
|
||||||
isEndpointDowngradeError,
|
isEndpointDowngradeError,
|
||||||
@@ -21,10 +27,31 @@ export {
|
|||||||
promoteResponsesCandidateAfterLegacyChatError,
|
promoteResponsesCandidateAfterLegacyChatError,
|
||||||
shouldPreferResponsesAfterLegacyChatError,
|
shouldPreferResponsesAfterLegacyChatError,
|
||||||
} from '../../transformers/shared/endpointCompatibility.js';
|
} from '../../transformers/shared/endpointCompatibility.js';
|
||||||
|
export {
|
||||||
|
buildMinimalJsonHeadersForCompatibility,
|
||||||
|
isEndpointDispatchDeniedError,
|
||||||
|
isEndpointDowngradeError,
|
||||||
|
isUnsupportedMediaTypeError,
|
||||||
|
promoteResponsesCandidateAfterLegacyChatError,
|
||||||
|
shouldPreferResponsesAfterLegacyChatError,
|
||||||
|
};
|
||||||
|
|
||||||
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
|
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
|
||||||
export type EndpointPreference = DownstreamFormat | '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 = {
|
type ChannelContext = {
|
||||||
site: {
|
site: {
|
||||||
id: number;
|
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> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
@@ -47,6 +78,20 @@ function asTrimmedString(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
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 {
|
function normalizePlatformName(platform: unknown): string {
|
||||||
return asTrimmedString(platform).toLowerCase();
|
return asTrimmedString(platform).toLowerCase();
|
||||||
}
|
}
|
||||||
@@ -557,6 +602,225 @@ function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
|
|||||||
return Array.from(normalized);
|
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(
|
function preferredEndpointOrder(
|
||||||
downstreamFormat: EndpointPreference,
|
downstreamFormat: EndpointPreference,
|
||||||
sitePlatform?: string,
|
sitePlatform?: string,
|
||||||
@@ -626,27 +890,44 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
requestedModelHint?: string,
|
requestedModelHint?: string,
|
||||||
requestCapabilities?: {
|
requestCapabilities?: {
|
||||||
hasNonImageFileInput?: boolean;
|
hasNonImageFileInput?: boolean;
|
||||||
|
conversationFileSummary?: ConversationFileInputSummary;
|
||||||
wantsNativeResponsesReasoning?: boolean;
|
wantsNativeResponsesReasoning?: boolean;
|
||||||
},
|
},
|
||||||
): Promise<UpstreamEndpoint[]> {
|
): Promise<UpstreamEndpoint[]> {
|
||||||
const sitePlatform = normalizePlatformName(context.site.platform);
|
const sitePlatform = normalizePlatformName(context.site.platform);
|
||||||
const preferMessagesForClaudeModel = (
|
const capabilityProfile = buildEndpointCapabilityProfile({
|
||||||
isClaudeFamilyModel(modelName)
|
modelName,
|
||||||
|| isClaudeFamilyModel(asTrimmedString(requestedModelHint))
|
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 conversationFileSummary = requestCapabilities?.conversationFileSummary ?? {
|
||||||
const wantsNativeResponsesReasoning = requestCapabilities?.wantsNativeResponsesReasoning === true;
|
hasImage: false,
|
||||||
|
hasAudio: false,
|
||||||
|
hasDocument: hasNonImageFileInput,
|
||||||
|
hasRemoteDocumentUrl: false,
|
||||||
|
};
|
||||||
if (sitePlatform === 'anyrouter') {
|
if (sitePlatform === 'anyrouter') {
|
||||||
// anyrouter deployments are effectively anthropic-protocol first.
|
// anyrouter deployments are effectively anthropic-protocol first.
|
||||||
if (hasNonImageFileInput) {
|
if (hasNonImageFileInput) {
|
||||||
return downstreamFormat === 'responses'
|
return applyRuntimePreference(downstreamFormat === 'responses'
|
||||||
? ['responses', 'messages', 'chat']
|
? ['responses', 'messages', 'chat']
|
||||||
: ['messages', 'responses', 'chat'];
|
: ['messages', 'responses', 'chat']);
|
||||||
}
|
}
|
||||||
if (downstreamFormat === 'responses') {
|
if (downstreamFormat === 'responses') {
|
||||||
return ['responses', 'messages', 'chat'];
|
return applyRuntimePreference(['responses', 'messages', 'chat']);
|
||||||
}
|
}
|
||||||
return ['messages', 'chat', 'responses'];
|
return applyRuntimePreference(['messages', 'chat', 'responses']);
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferred = preferredEndpointOrder(
|
const preferred = preferredEndpointOrder(
|
||||||
@@ -659,8 +940,14 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[];
|
if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[];
|
||||||
if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[];
|
if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[];
|
||||||
if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') return ['chat'] as UpstreamEndpoint[];
|
if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') return ['chat'] as UpstreamEndpoint[];
|
||||||
if (preferMessagesForClaudeModel) return ['messages', 'responses', 'chat'] as UpstreamEndpoint[];
|
return rankConversationFileEndpoints({
|
||||||
return ['responses', 'messages', 'chat'] as UpstreamEndpoint[];
|
sitePlatform,
|
||||||
|
requestedOrder: preferMessagesForClaudeModel
|
||||||
|
? ['messages', 'responses', 'chat']
|
||||||
|
: ['responses', 'messages', 'chat'],
|
||||||
|
summary: conversationFileSummary,
|
||||||
|
preferMessagesForClaudeModel,
|
||||||
|
});
|
||||||
})()
|
})()
|
||||||
: preferred;
|
: preferred;
|
||||||
const prioritizedPreferredEndpoints: UpstreamEndpoint[] = (
|
const prioritizedPreferredEndpoints: UpstreamEndpoint[] = (
|
||||||
@@ -699,20 +986,20 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) {
|
if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) {
|
||||||
return prioritizedPreferredEndpoints;
|
return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
const matched = catalog.models.find((item) =>
|
const matched = catalog.models.find((item) =>
|
||||||
asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(),
|
asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(),
|
||||||
);
|
);
|
||||||
if (!matched) return prioritizedPreferredEndpoints;
|
if (!matched) return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
|
|
||||||
const shouldIgnoreCatalogOrderingForClaudeMessages = (
|
const shouldIgnoreCatalogOrderingForClaudeMessages = (
|
||||||
preferMessagesForClaudeModel
|
preferMessagesForClaudeModel
|
||||||
&& (downstreamFormat !== 'responses' || sitePlatform !== 'openai')
|
&& (downstreamFormat !== 'responses' || sitePlatform !== 'openai')
|
||||||
);
|
);
|
||||||
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
|
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
|
||||||
return prioritizedPreferredEndpoints;
|
return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
|
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
|
||||||
@@ -732,7 +1019,7 @@ export async function resolveUpstreamEndpointCandidates(
|
|||||||
if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) {
|
if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) {
|
||||||
// Generic labels like openai/anthropic are too coarse for Claude models;
|
// Generic labels like openai/anthropic are too coarse for Claude models;
|
||||||
// keep messages-first order in this case.
|
// keep messages-first order in this case.
|
||||||
return prioritizedPreferredEndpoints;
|
return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
}
|
}
|
||||||
|
|
||||||
const supported = new Set<UpstreamEndpoint>();
|
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));
|
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
|
// Catalog metadata can be incomplete/inaccurate, so only use it to pick
|
||||||
// the first attempt. Keep downstream-driven fallback order unchanged.
|
// the first attempt. Keep downstream-driven fallback order unchanged.
|
||||||
return [
|
return applyRuntimePreference([
|
||||||
firstSupported,
|
firstSupported,
|
||||||
...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported),
|
...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported),
|
||||||
];
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
return prioritizedPreferredEndpoints;
|
return applyRuntimePreference(prioritizedPreferredEndpoints);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,6 +1172,16 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
stream: input.stream,
|
stream: input.stream,
|
||||||
oauthProjectId: asTrimmedString(input.oauthProjectId) || null,
|
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) {
|
if (isInternalGeminiUpstream) {
|
||||||
const instructions = (
|
const instructions = (
|
||||||
@@ -898,6 +1195,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
modelName: input.modelName,
|
modelName: input.modelName,
|
||||||
instructions,
|
instructions,
|
||||||
});
|
});
|
||||||
|
const configuredGeminiRequest = applyConfiguredPayloadRules(geminiRequest);
|
||||||
if (!providerProfile) {
|
if (!providerProfile) {
|
||||||
throw new Error(`missing provider profile for platform: ${sitePlatform}`);
|
throw new Error(`missing provider profile for platform: ${sitePlatform}`);
|
||||||
}
|
}
|
||||||
@@ -911,7 +1209,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
sitePlatform,
|
sitePlatform,
|
||||||
baseHeaders: commonHeaders,
|
baseHeaders: commonHeaders,
|
||||||
providerHeaders: input.providerHeaders,
|
providerHeaders: input.providerHeaders,
|
||||||
body: geminiRequest,
|
body: configuredGeminiRequest,
|
||||||
action: input.stream ? 'streamGenerateContent' : 'generateContent',
|
action: input.stream ? 'streamGenerateContent' : 'generateContent',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -952,6 +1250,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
?? sanitizeAnthropicMessagesBody(
|
?? sanitizeAnthropicMessagesBody(
|
||||||
convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream),
|
convertOpenAiBodyToAnthropicMessagesBody(openaiBody, input.modelName, input.stream),
|
||||||
);
|
);
|
||||||
|
const configuredClaudeBody = applyConfiguredPayloadRules(sanitizedBody);
|
||||||
|
|
||||||
if (providerProfile?.id === 'claude') {
|
if (providerProfile?.id === 'claude') {
|
||||||
return providerProfile.prepareRequest({
|
return providerProfile.prepareRequest({
|
||||||
@@ -964,7 +1263,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
sitePlatform,
|
sitePlatform,
|
||||||
baseHeaders: commonHeaders,
|
baseHeaders: commonHeaders,
|
||||||
claudeHeaders,
|
claudeHeaders,
|
||||||
body: sanitizedBody,
|
body: configuredClaudeBody,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -980,7 +1279,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
return {
|
return {
|
||||||
path: resolveEndpointPath('messages'),
|
path: resolveEndpointPath('messages'),
|
||||||
headers,
|
headers,
|
||||||
body: sanitizedBody,
|
body: configuredClaudeBody,
|
||||||
runtime,
|
runtime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1015,6 +1314,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
),
|
),
|
||||||
sitePlatform,
|
sitePlatform,
|
||||||
);
|
);
|
||||||
|
const configuredResponsesBody = applyConfiguredPayloadRules(body);
|
||||||
|
|
||||||
if (providerProfile?.id === 'codex') {
|
if (providerProfile?.id === 'codex') {
|
||||||
return providerProfile.prepareRequest({
|
return providerProfile.prepareRequest({
|
||||||
@@ -1032,7 +1332,7 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
providerHeaders: input.providerHeaders,
|
providerHeaders: input.providerHeaders,
|
||||||
codexSessionCacheKey: input.codexSessionCacheKey,
|
codexSessionCacheKey: input.codexSessionCacheKey,
|
||||||
codexExplicitSessionId: input.codexExplicitSessionId,
|
codexExplicitSessionId: input.codexExplicitSessionId,
|
||||||
body,
|
body: configuredResponsesBody,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,15 +1355,15 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
: null;
|
: null;
|
||||||
const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex'
|
const shouldInjectDerivedPromptCacheKey = sitePlatform === 'codex'
|
||||||
&& !!codexSessionId
|
&& !!codexSessionId
|
||||||
&& !asTrimmedString((body as Record<string, unknown>).prompt_cache_key)
|
&& !asTrimmedString((configuredResponsesBody as Record<string, unknown>).prompt_cache_key)
|
||||||
&& !asTrimmedString(input.codexExplicitSessionId)
|
&& !asTrimmedString(input.codexExplicitSessionId)
|
||||||
&& !!asTrimmedString(input.codexSessionCacheKey);
|
&& !!asTrimmedString(input.codexSessionCacheKey);
|
||||||
const runtimeBody = shouldInjectDerivedPromptCacheKey
|
const runtimeBody = shouldInjectDerivedPromptCacheKey
|
||||||
? {
|
? {
|
||||||
...body,
|
...configuredResponsesBody,
|
||||||
prompt_cache_key: codexSessionId,
|
prompt_cache_key: codexSessionId,
|
||||||
}
|
}
|
||||||
: body;
|
: configuredResponsesBody;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: resolveEndpointPath('responses'),
|
path: resolveEndpointPath('responses'),
|
||||||
@@ -1077,11 +1377,11 @@ export function buildUpstreamEndpointRequest(input: {
|
|||||||
return {
|
return {
|
||||||
path: resolveEndpointPath('chat'),
|
path: resolveEndpointPath('chat'),
|
||||||
headers,
|
headers,
|
||||||
body: {
|
body: applyConfiguredPayloadRules({
|
||||||
...openaiBody,
|
...openaiBody,
|
||||||
model: input.modelName,
|
model: input.modelName,
|
||||||
stream: input.stream,
|
stream: input.stream,
|
||||||
},
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1149,5 +1449,3 @@ export function buildClaudeCountTokensUpstreamRequest(input: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
|||||||
const upstream = await fetch(targetUrl, requestInit);
|
const upstream = await fetch(targetUrl, requestInit);
|
||||||
const text = await upstream.text();
|
const text = await upstream.text();
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, selected.actualModel);
|
||||||
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
if (isTokenExpiredError({ status: upstream.status, message: text })) {
|
||||||
await reportTokenExpired({
|
await reportTokenExpired({
|
||||||
accountId: selected.account.id,
|
accountId: selected.account.id,
|
||||||
@@ -155,11 +155,11 @@ export async function videosProxyRoute(app: FastifyInstance) {
|
|||||||
completionTokens: 0,
|
completionTokens: 0,
|
||||||
totalTokens: 0,
|
totalTokens: 0,
|
||||||
});
|
});
|
||||||
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId));
|
return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
tokenRouter.recordFailure(selected.channel.id);
|
tokenRouter.recordFailure(selected.channel.id, selected.actualModel);
|
||||||
if (retryCount < MAX_RETRIES) {
|
if (retryCount < MAX_RETRIES) {
|
||||||
retryCount += 1;
|
retryCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
import { runSqliteMigrations } from './db/migrate.js';
|
||||||
|
import {
|
||||||
|
__runtimeDatabaseBootstrapTestUtils,
|
||||||
|
ensureRuntimeDatabaseReady,
|
||||||
|
runSqliteRuntimeMigrations,
|
||||||
|
} from './runtimeDatabaseBootstrap.js';
|
||||||
|
|
||||||
|
vi.mock('./db/migrate.js', () => ({
|
||||||
|
runSqliteMigrations: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('runtimeDatabaseBootstrap', () => {
|
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 () => {
|
it('runs sqlite runtime migrations when dialect is sqlite', async () => {
|
||||||
const runSqliteRuntimeMigrations = vi.fn(async () => {});
|
const runSqliteRuntimeMigrations = vi.fn(async () => {});
|
||||||
const ensureExternalRuntimeSchema = vi.fn(async () => {});
|
const ensureExternalRuntimeSchema = vi.fn(async () => {});
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ let sqliteMigrationsBootstrapped = false;
|
|||||||
|
|
||||||
export async function runSqliteRuntimeMigrations(): Promise<void> {
|
export async function runSqliteRuntimeMigrations(): Promise<void> {
|
||||||
const migrateModule = await import('./db/migrate.js');
|
const migrateModule = await import('./db/migrate.js');
|
||||||
if (sqliteMigrationsBootstrapped) {
|
if (!sqliteMigrationsBootstrapped) {
|
||||||
migrateModule.runSqliteMigrations();
|
sqliteMigrationsBootstrapped = true;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
sqliteMigrationsBootstrapped = true;
|
migrateModule.runSqliteMigrations();
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnsureRuntimeDatabaseReadyInput = {
|
type EnsureRuntimeDatabaseReadyInput = {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
buildStoredSub2ApiSubscriptionSummary,
|
||||||
getCredentialModeFromExtraConfig,
|
getCredentialModeFromExtraConfig,
|
||||||
getPlatformUserIdFromExtraConfig,
|
getPlatformUserIdFromExtraConfig,
|
||||||
getProxyUrlFromExtraConfig,
|
getProxyUrlFromExtraConfig,
|
||||||
getSub2ApiAuthFromExtraConfig,
|
getSub2ApiAuthFromExtraConfig,
|
||||||
|
getSub2ApiSubscriptionFromExtraConfig,
|
||||||
guessPlatformUserIdFromUsername,
|
guessPlatformUserIdFromUsername,
|
||||||
mergeAccountExtraConfig,
|
mergeAccountExtraConfig,
|
||||||
normalizeCredentialMode,
|
normalizeCredentialMode,
|
||||||
@@ -120,4 +122,37 @@ describe('accountExtraConfig', () => {
|
|||||||
extraConfig: JSON.stringify({ credentialMode: 'session' }),
|
extraConfig: JSON.stringify({ credentialMode: 'session' }),
|
||||||
})).toBe(true);
|
})).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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { SubscriptionPlanSummary, SubscriptionSummary } from './platforms/base.js';
|
||||||
|
|
||||||
type AutoReloginConfig = {
|
type AutoReloginConfig = {
|
||||||
username?: unknown;
|
username?: unknown;
|
||||||
passwordCipher?: unknown;
|
passwordCipher?: unknown;
|
||||||
@@ -9,6 +11,13 @@ type Sub2ApiAuthConfig = {
|
|||||||
tokenExpiresAt?: unknown;
|
tokenExpiresAt?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Sub2ApiSubscriptionConfig = {
|
||||||
|
updatedAt?: unknown;
|
||||||
|
activeCount?: unknown;
|
||||||
|
totalUsedUsd?: unknown;
|
||||||
|
subscriptions?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
|
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
|
||||||
|
|
||||||
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
|
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
|
||||||
@@ -26,6 +35,7 @@ type AccountExtraConfig = {
|
|||||||
};
|
};
|
||||||
autoRelogin?: AutoReloginConfig;
|
autoRelogin?: AutoReloginConfig;
|
||||||
sub2apiAuth?: Sub2ApiAuthConfig;
|
sub2apiAuth?: Sub2ApiAuthConfig;
|
||||||
|
sub2apiSubscription?: Sub2ApiSubscriptionConfig;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,6 +74,39 @@ function normalizeTimestampMs(raw: unknown): number | undefined {
|
|||||||
return 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 {
|
export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined {
|
||||||
if (typeof raw !== 'string') return undefined;
|
if (typeof raw !== 'string') return undefined;
|
||||||
const normalized = raw.trim().toLowerCase();
|
const normalized = raw.trim().toLowerCase();
|
||||||
@@ -134,6 +177,10 @@ export type ManagedSub2ApiAuth = {
|
|||||||
tokenExpiresAt?: number;
|
tokenExpiresAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StoredSub2ApiSubscriptionSummary = SubscriptionSummary & {
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
|
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
|
||||||
const parsed = parseExtraConfig(extraConfig);
|
const parsed = parseExtraConfig(extraConfig);
|
||||||
const raw = parsed.sub2apiAuth;
|
const raw = parsed.sub2apiAuth;
|
||||||
@@ -146,6 +193,103 @@ export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): Mana
|
|||||||
: { refreshToken };
|
: { 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 {
|
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
|
||||||
const text = (username || '').trim();
|
const text = (username || '').trim();
|
||||||
if (!text) return undefined;
|
if (!text) return undefined;
|
||||||
|
|||||||
@@ -596,4 +596,254 @@ describe('backupService', () => {
|
|||||||
expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com');
|
expect(restoredAccount?.oauthAccountKey).toBe('oauth-user@example.com');
|
||||||
expect(restoredAccount?.oauthProjectId).toBe('oauth-project-id');
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { asc } from 'drizzle-orm';
|
import { asc, eq } from 'drizzle-orm';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { upsertSetting } from '../db/upsertSetting.js';
|
import { upsertSetting } from '../db/upsertSetting.js';
|
||||||
@@ -9,6 +9,32 @@ const BACKUP_VERSION = '2.0';
|
|||||||
|
|
||||||
export type BackupExportType = 'all' | 'accounts' | 'preferences';
|
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 SiteRow = typeof schema.sites.$inferSelect;
|
||||||
type AccountRow = typeof schema.accounts.$inferSelect;
|
type AccountRow = typeof schema.accounts.$inferSelect;
|
||||||
type AccountTokenRow = typeof schema.accountTokens.$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.
|
// Keep current admin login credential unchanged to avoid accidental lock-out.
|
||||||
'auth_token',
|
'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([
|
const DIRECT_API_PLATFORMS = new Set([
|
||||||
'openai',
|
'openai',
|
||||||
@@ -722,6 +753,131 @@ function stringifySettingValue(value: unknown): string {
|
|||||||
return JSON.stringify(value);
|
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 {
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
return typeof value === 'number' && Number.isFinite(value);
|
return typeof value === 'number' && Number.isFinite(value);
|
||||||
}
|
}
|
||||||
@@ -1103,3 +1259,169 @@ export async function importBackup(data: RawBackupData): Promise<BackupImportRes
|
|||||||
warnings: importMetadata.warnings,
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
|
|||||||
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
|
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
|
||||||
import { reportTokenExpired } from './alertService.js';
|
import { reportTokenExpired } from './alertService.js';
|
||||||
import {
|
import {
|
||||||
|
buildStoredSub2ApiSubscriptionSummary,
|
||||||
getAutoReloginConfig,
|
getAutoReloginConfig,
|
||||||
getCredentialModeFromExtraConfig,
|
getCredentialModeFromExtraConfig,
|
||||||
getProxyUrlFromExtraConfig,
|
getProxyUrlFromExtraConfig,
|
||||||
@@ -483,6 +484,11 @@ export async function refreshBalance(accountId: number) {
|
|||||||
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
|
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
|
||||||
nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, 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 existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig);
|
||||||
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,13 +9,18 @@ import { sendNotification } from './notifyService.js';
|
|||||||
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
|
import { buildDailySummaryNotification, collectDailySummaryMetrics } from './dailySummaryService.js';
|
||||||
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
|
import { cleanupConfiguredLogs, normalizeLogCleanupRetentionDays } from './logCleanupService.js';
|
||||||
|
|
||||||
|
export type CheckinScheduleMode = 'cron' | 'interval';
|
||||||
|
|
||||||
let checkinTask: cron.ScheduledTask | null = null;
|
let checkinTask: cron.ScheduledTask | null = null;
|
||||||
|
let checkinIntervalTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let balanceTask: cron.ScheduledTask | null = null;
|
let balanceTask: cron.ScheduledTask | null = null;
|
||||||
let dailySummaryTask: cron.ScheduledTask | null = null;
|
let dailySummaryTask: cron.ScheduledTask | null = null;
|
||||||
let logCleanupTask: cron.ScheduledTask | null = null;
|
let logCleanupTask: cron.ScheduledTask | null = null;
|
||||||
|
const intervalAttemptByAccount = new Map<number, number>();
|
||||||
|
|
||||||
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
|
const DAILY_SUMMARY_DEFAULT_CRON = '58 23 * * *';
|
||||||
const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *';
|
const LOG_CLEANUP_DEFAULT_CRON = '0 6 * * *';
|
||||||
|
const CHECKIN_INTERVAL_POLL_MS = 60_000;
|
||||||
|
|
||||||
async function resolveJsonSetting<T>(
|
async function resolveJsonSetting<T>(
|
||||||
settingKey: string,
|
settingKey: string,
|
||||||
@@ -54,7 +59,7 @@ function createCheckinTask(cronExpr: string) {
|
|||||||
return cron.schedule(cronExpr, async () => {
|
return cron.schedule(cronExpr, async () => {
|
||||||
console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`);
|
console.log(`[Scheduler] Running check-in at ${new Date().toISOString()}`);
|
||||||
try {
|
try {
|
||||||
const results = await checkinAll();
|
const results = await checkinAll({ scheduleMode: 'cron' });
|
||||||
const success = results.filter((r) => r.result.success).length;
|
const success = results.filter((r) => r.result.success).length;
|
||||||
const failed = results.length - success;
|
const failed = results.length - success;
|
||||||
console.log(`[Scheduler] Check-in complete: ${success} success, ${failed} failed`);
|
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) {
|
function createBalanceTask(cronExpr: string) {
|
||||||
return cron.schedule(cronExpr, async () => {
|
return cron.schedule(cronExpr, async () => {
|
||||||
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
|
console.log(`[Scheduler] Refreshing balances at ${new Date().toISOString()}`);
|
||||||
@@ -119,6 +212,15 @@ function createLogCleanupTask(cronExpr: string) {
|
|||||||
|
|
||||||
export async function startScheduler() {
|
export async function startScheduler() {
|
||||||
const activeCheckinCron = await resolveCronSetting('checkin_cron', config.checkinCron);
|
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 activeBalanceCron = await resolveCronSetting('balance_refresh_cron', config.balanceRefreshCron);
|
||||||
const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
|
const activeDailySummaryCron = await resolveCronSetting('daily_summary_cron', DAILY_SUMMARY_DEFAULT_CRON);
|
||||||
const activeLogCleanupCron = await resolveCronSetting('log_cleanup_cron', config.logCleanupCron || LOG_CLEANUP_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),
|
normalizeLogCleanupRetentionDays(config.logCleanupRetentionDays),
|
||||||
);
|
);
|
||||||
config.checkinCron = activeCheckinCron;
|
config.checkinCron = activeCheckinCron;
|
||||||
|
config.checkinScheduleMode = activeCheckinScheduleMode;
|
||||||
|
config.checkinIntervalHours = Math.min(24, Math.max(1, activeCheckinIntervalHours));
|
||||||
config.balanceRefreshCron = activeBalanceCron;
|
config.balanceRefreshCron = activeBalanceCron;
|
||||||
config.logCleanupCron = activeLogCleanupCron;
|
config.logCleanupCron = activeLogCleanupCron;
|
||||||
config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled;
|
config.logCleanupUsageLogsEnabled = activeLogCleanupUsageLogsEnabled;
|
||||||
config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled;
|
config.logCleanupProgramLogsEnabled = activeLogCleanupProgramLogsEnabled;
|
||||||
config.logCleanupRetentionDays = activeLogCleanupRetentionDays;
|
config.logCleanupRetentionDays = activeLogCleanupRetentionDays;
|
||||||
|
|
||||||
checkinTask?.stop();
|
stopCheckinSchedule();
|
||||||
balanceTask?.stop();
|
balanceTask?.stop();
|
||||||
dailySummaryTask?.stop();
|
dailySummaryTask?.stop();
|
||||||
logCleanupTask?.stop();
|
logCleanupTask?.stop();
|
||||||
checkinTask = createCheckinTask(activeCheckinCron);
|
startCheckinSchedule();
|
||||||
balanceTask = createBalanceTask(activeBalanceCron);
|
balanceTask = createBalanceTask(activeBalanceCron);
|
||||||
dailySummaryTask = createDailySummaryTask(activeDailySummaryCron);
|
dailySummaryTask = createDailySummaryTask(activeDailySummaryCron);
|
||||||
logCleanupTask = createLogCleanupTask(activeLogCleanupCron);
|
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] Balance refresh cron: ${activeBalanceCron}`);
|
||||||
console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`);
|
console.log(`[Scheduler] Daily summary cron: ${activeDailySummaryCron}`);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -159,10 +263,35 @@ export async function startScheduler() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updateCheckinCron(cronExpr: string) {
|
export function updateCheckinCron(cronExpr: string) {
|
||||||
if (!cron.validate(cronExpr)) throw new Error(`Invalid cron: ${cronExpr}`);
|
updateCheckinSchedule({
|
||||||
config.checkinCron = cronExpr;
|
mode: 'cron',
|
||||||
checkinTask?.stop();
|
cronExpr,
|
||||||
checkinTask = createCheckinTask(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) {
|
export function updateBalanceRefreshCron(cronExpr: string) {
|
||||||
@@ -191,3 +320,14 @@ export function updateLogCleanupSettings(input: {
|
|||||||
logCleanupTask?.stop();
|
logCleanupTask?.stop();
|
||||||
logCleanupTask = createLogCleanupTask(cronExpr);
|
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();
|
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 () => {
|
it('treats unsupported checkin endpoint responses as skipped', async () => {
|
||||||
selectAllMock.mockReturnValue([
|
selectAllMock.mockReturnValue([
|
||||||
{
|
{
|
||||||
@@ -283,6 +340,40 @@ describe('checkinService auto relogin', () => {
|
|||||||
expect(notifyMock).not.toHaveBeenCalled();
|
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 () => {
|
it('treats sub2api checkin unsupported message as skipped', async () => {
|
||||||
selectAllMock.mockReturnValue([
|
selectAllMock.mockReturnValue([
|
||||||
{
|
{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user