From 67a72f7255d24243dc2dcb222c3ee01f1db06342 Mon Sep 17 00:00:00 2001 From: zizi Date: Sat, 11 Apr 2026 22:57:26 +0800 Subject: [PATCH 1/5] chore: switch new-api deployment to shared infra --- .env.example | 8 ++ CLAUDE.md | 87 ++++++++----------- README.md | 31 +++++-- README.zh_CN.md | 31 +++++-- docker-compose.yml | 86 +++--------------- ops/remote/README.md | 9 ++ .../minione-ubuntu-infra-4c16g120g.AGENT.md | 63 ++++++++++++++ 7 files changed, 174 insertions(+), 141 deletions(-) create mode 100644 ops/remote/README.md create mode 100644 ops/remote/minione-ubuntu-infra-4c16g120g.AGENT.md diff --git a/.env.example b/.env.example index 0a64758d..72675aa2 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,10 @@ # 数据库相关配置 # 数据库连接字符串 # SQL_DSN=user:password@tcp(127.0.0.1:3306)/dbname?parseTime=true +# Host process profile on infra host: +# SQL_DSN=postgresql://root:example-password@127.0.0.1:5433/new-api +# Docker profile on same host: +# SQL_DSN=postgresql://root:example-password@infra-postgres:5432/new-api # 日志数据库连接字符串 # LOG_SQL_DSN=user:password@tcp(127.0.0.1:3306)/logdb?parseTime=true # SQLite数据库路径 @@ -36,6 +40,10 @@ # 缓存相关配置 # Redis连接字符串 # REDIS_CONN_STRING=redis://user:password@localhost:6379/0 +# Host process profile on infra host: +# REDIS_CONN_STRING=redis://:example-password@127.0.0.1:6379/0 +# Docker profile on same host: +# REDIS_CONN_STRING=redis://:example-password@infra-redis:6379/0 # 同步频率(单位:秒) # SYNC_FREQUENCY=60 # 内存缓存启用 diff --git a/CLAUDE.md b/CLAUDE.md index 220ea3b6..6be79b38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -224,71 +224,52 @@ For request structs that are parsed from client JSON and then re-marshaled to up - 是上游不通 - 还是服务端计费配置缺失 -## 9. 当前线上部署形态(2026-04-04 更新) +## 9. 当前目标部署形态(2026-04-11 更新) - 远程服务器仍是:`个人-minione-ubuntu-infra-4c16g120g` -- `new-api` **不再**以 Docker 应用容器运行;当前由宿主机 `systemd` 服务启动: +- `new-api` 继续以宿主机 `systemd` 进程运行: - 服务名:`new-api` - - `systemctl is-active new-api` 应返回 `active` - - `systemctl is-enabled new-api` 应返回 `enabled` -- 当前线上运行版本固定为最新稳定 tag: - - `v0.11.9` -- 实际部署目录不是 `/root/new-api`,而是单独的源码 worktree: - - `/root/new-api-v0.11.9` + - 只管理应用进程,不管理基础设施生命周期 +- 远程部署目录应为正常 Git checkout: + - 目标目录:`/root/new-api-deploy` - 关键文件: - - 二进制:`/root/new-api-v0.11.9/new-api` - - 运行环境:`/root/new-api-v0.11.9/.env.runtime` - - 启动脚本:`/root/new-api-v0.11.9/run-new-api.sh` + - 二进制:`/root/new-api-deploy/new-api` + - 运行环境:`/root/new-api-deploy/.env.runtime` - systemd 单元文件:`/etc/systemd/system/new-api.service` -## 10. 依赖服务与连接方式(2026-04-04 更新) -- PostgreSQL 和 Redis 仍然跑在 Docker 中,但**只作为依赖容器**存在,不再承载应用本体。 -- 当前容器: - - PostgreSQL:`postgres` - - Redis:`redis` -- 当前宿主机固定入口: +## 10. 依赖服务与连接方式(2026-04-11 更新) +- PostgreSQL 和 Redis 是共享基础设施,不属于 `new-api` 仓库内嵌部署的一部分。 +- 宿主机进程模式固定入口: - PostgreSQL:`127.0.0.1:5433` - Redis:`127.0.0.1:6379` -- `new-api` 宿主机进程必须连接宿主机固定入口,不要再依赖容器名或容器 IP: - - `SQL_DSN=postgresql://root:123456@127.0.0.1:5433/new-api` - - `REDIS_CONN_STRING=redis://127.0.0.1:6379` -- 原因: - - 容器 IP 会变,写死就是垃圾设计。 - - 用宿主机固定入口,重建 PG/Redis 容器时不需要改应用配置。 +- 同宿主机 Docker 模式固定入口: + - PostgreSQL:`infra-postgres:5432` + - Redis:`infra-redis:6379` +- `new-api` 不应再依赖容器 IP,也不应再依赖 `postgres` / `redis` 这种应用级旧名字。 -## 11. 数据保护与备份(2026-04-04 更新) -- PostgreSQL 数据仍保存在 Docker volume: - - `new-api_pg_data` -- **不要**删除、重建、覆盖这个 volume,除非明确知道自己在做什么。 -- 本次切换前已做 PG 备份: - - `/root/backups/new-api/new-api-cutover-20260404-094849.dump` -- 这次切换后已核对关键表仍在: - - `channels=10` - - `abilities=153` - - `options=9` +## 11. 共享数据库事实(2026-04-11 更新) +- 当前 PostgreSQL 是共享基础设施,至少同时承载: + - `new-api` + - `gitea` +- 迁移 PostgreSQL 时不要把它当成 `new-api` 独占数据库。 +- Redis 目前主要视为共享缓存基础设施。 -## 12. 运维入口与排障顺序(2026-04-04 更新) -- 现在排障先看 `systemd`,不是先看 Docker 应用容器: +## 12. 运维入口与排障顺序(2026-04-11 更新) +- `new-api` 排障先看: - `systemctl status new-api` - `journalctl -u new-api -n 200 --no-pager` - `journalctl -u new-api -f` -- 服务健康检查: +- `new-api` 健康检查: - `curl http://127.0.0.1:3000/api/status` -- 当前线上版本应看到: - - `v0.11.9` -- Docker 现在只需要关心依赖容器是否活着: - - `docker ps | grep -E 'postgres|redis'` -- 不要再默认使用 `/root/new-api/docker-compose.yml` 管理应用本体。 - - 这个仓库目录现在主要是源码主仓库,不是实际运行目录。 - - 实际运行目录是 `/root/new-api-v0.11.9` +- 基础设施排障再看: + - `docker ps | grep -E 'infra-postgres|infra-redis|gitea'` + - `ss -lntp | grep -E ':5433|:6379|:3000|:3001|:2222'` +- 不要再把仓库里的 `docker-compose.yml` 当成生产真相;它只描述 app-only Docker 运行方式。 -## 13. 后续升级原则(2026-04-04 更新) -- 升级时优先选择新的**稳定 tag**,不要直接拿 `main` 或 `latest` 当生产版本。 -- 合理流程应该是: - 1. 为目标稳定 tag 建单独部署目录或 worktree - 2. 编译产出新二进制 - 3. 保留 PG volume,不迁移数据库文件路径 - 4. 让 systemd 指向新版本 +## 13. 后续升级原则(2026-04-11 更新) +- 远程升级以 Git 可拉取 checkout 为前提,不再使用 detached tag worktree 作为长期部署形态。 +- 合理流程: + 1. 在 `/root/new-api-deploy` 拉取最新目标分支 + 2. 编译新二进制 + 3. 确认共享 PostgreSQL / Redis 健康 + 4. `systemd` 重启 `new-api` 5. 验证 `api/status`、日志、数据库连接再收尾 -- 如果有人再次看到 `docker-compose.yml` 里定义了 `new-api` 服务,不要想当然地认定线上还在用容器跑应用;先查: - - `systemctl status new-api` - - `ss -lntp | grep :3000` diff --git a/README.md b/README.md index 8f23d5dc..4896cdb7 100644 --- a/README.md +++ b/README.md @@ -106,18 +106,37 @@ ## 🚀 Quick Start -### Using Docker Compose (Recommended) +### Using Docker Compose ```bash # Clone the project git clone https://github.com/QuantumNous/new-api.git cd new-api -# Edit docker-compose.yml configuration -nano docker-compose.yml +# Copy environment template +cp .env.example .env.runtime + +# Edit SQL_DSN and REDIS_CONN_STRING for your runtime profile +nano .env.runtime # Start the service -docker-compose up -d +docker compose --env-file .env.runtime up -d +``` + +This compose file is app-only. PostgreSQL and Redis are expected to be external shared infrastructure. + +Host process profile on the infra host: + +```bash +SQL_DSN=postgresql://root:${POSTGRES_PASSWORD}@127.0.0.1:5433/new-api +REDIS_CONN_STRING=redis://:${REDIS_PASSWORD}@127.0.0.1:6379/0 +``` + +Docker profile on the same host: + +```bash +SQL_DSN=postgresql://root:${POSTGRES_PASSWORD}@infra-postgres:5432/new-api +REDIS_CONN_STRING=redis://:${REDIS_PASSWORD}@infra-redis:6379/0 ```
@@ -310,8 +329,8 @@ docker run --name new-api -d --restart always \ |--------|------|--------| | `SESSION_SECRET` | Session secret (required for multi-machine deployment) | - | | `CRYPTO_SECRET` | Encryption secret (required for Redis) | - | -| `SQL_DSN` | Database connection string | - | -| `REDIS_CONN_STRING` | Redis connection string | - | +| `SQL_DSN` | Database connection string. Use `127.0.0.1:5433` for host mode or `infra-postgres:5432` for same-host Docker mode | - | +| `REDIS_CONN_STRING` | Redis connection string. Use `127.0.0.1:6379` for host mode or `infra-redis:6379` for same-host Docker mode | - | | `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` | | `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` | diff --git a/README.zh_CN.md b/README.zh_CN.md index 92e5baa1..5ac04e8d 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -106,18 +106,37 @@ ## 🚀 快速开始 -### 使用 Docker Compose(推荐) +### 使用 Docker Compose ```bash # 克隆项目 git clone https://github.com/QuantumNous/new-api.git cd new-api -# 编辑 docker-compose.yml 配置 -nano docker-compose.yml +# 复制环境变量模板 +cp .env.example .env.runtime + +# 按运行模式填写 SQL_DSN 和 REDIS_CONN_STRING +nano .env.runtime # 启动服务 -docker-compose up -d +docker compose --env-file .env.runtime up -d +``` + +当前 `docker-compose.yml` 只负责应用本体,PostgreSQL 和 Redis 应作为外部共享基础设施提供。 + +宿主机进程模式: + +```bash +SQL_DSN=postgresql://root:${POSTGRES_PASSWORD}@127.0.0.1:5433/new-api +REDIS_CONN_STRING=redis://:${REDIS_PASSWORD}@127.0.0.1:6379/0 +``` + +同宿主机 Docker 模式: + +```bash +SQL_DSN=postgresql://root:${POSTGRES_PASSWORD}@infra-postgres:5432/new-api +REDIS_CONN_STRING=redis://:${REDIS_PASSWORD}@infra-redis:6379/0 ```
@@ -310,8 +329,8 @@ docker run --name new-api -d --restart always \ |--------|--------------------------------------------------------------|--------| | `SESSION_SECRET` | 会话密钥(多机部署必须) | - | | `CRYPTO_SECRET` | 加密密钥(Redis 必须) | - | -| `SQL_DSN` | 数据库连接字符串 | - | -| `REDIS_CONN_STRING` | Redis 连接字符串 | - | +| `SQL_DSN` | 数据库连接字符串。宿主机模式使用 `127.0.0.1:5433`,同宿主机 Docker 模式使用 `infra-postgres:5432` | - | +| `REDIS_CONN_STRING` | Redis 连接字符串。宿主机模式使用 `127.0.0.1:6379`,同宿主机 Docker 模式使用 `infra-redis:6379` | - | | `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | 流式扫描器单行最大缓冲(MB),图像生成等超大 `data:` 片段(如 4K 图片 base64)需适当调大 | `64` | | `MAX_REQUEST_BODY_MB` | 请求体最大大小(MB,**解压后**计;防止超大请求/zip bomb 导致内存暴涨),超过将返回 `413` | `32` | diff --git a/docker-compose.yml b/docker-compose.yml index 3c56faf3..83775ee6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,3 @@ -# New-API Docker Compose Configuration -# -# Quick Start: -# 1. docker-compose up -d -# 2. Access at http://localhost:3000 -# -# Using MySQL instead of PostgreSQL: -# 1. Comment out the postgres service and SQL_DSN line 15 -# 2. Uncomment the mysql service and SQL_DSN line 16 -# 3. Uncomment mysql in depends_on (line 28) -# 4. Uncomment mysql_data in volumes section (line 64) -# -# ⚠️ IMPORTANT: Change all default passwords before deploying to production! - -version: '3.4' # For compatibility with older Docker versions - services: new-api: image: calciumion/new-api:latest @@ -26,71 +10,21 @@ services: - ./data:/data - ./logs:/app/logs environment: - - SQL_DSN=postgresql://root:123456@postgres:5432/new-api # ⚠️ IMPORTANT: Change the password in production! -# - SQL_DSN=root:123456@tcp(mysql:3306)/new-api # Point to the mysql service, uncomment if using MySQL - - REDIS_CONN_STRING=redis://redis - - TZ=Asia/Shanghai - - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording) - - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update) -# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions) -# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!) -# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed -# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID) -# - UMAMI_WEBSITE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx # Umami 网站 ID (Umami Website ID) -# - UMAMI_SCRIPT_URL=https://analytics.umami.is/script.js # Umami 脚本 URL,默认为官方地址 (Umami Script URL, defaults to official URL) - - depends_on: - - redis - - postgres -# - mysql # Uncomment if using MySQL + SQL_DSN: ${SQL_DSN:?SQL_DSN is required} + REDIS_CONN_STRING: ${REDIS_CONN_STRING:?REDIS_CONN_STRING is required} + TZ: ${TZ:-Asia/Shanghai} + ERROR_LOG_ENABLED: ${ERROR_LOG_ENABLED:-true} + BATCH_UPDATE_ENABLED: ${BATCH_UPDATE_ENABLED:-true} networks: - - new-api-network + - default + - infra-shared healthcheck: test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"] interval: 30s timeout: 10s retries: 3 - redis: - image: redis:latest - container_name: redis - restart: always - networks: - - new-api-network - - postgres: - image: postgres:15 - container_name: postgres - restart: always - environment: - POSTGRES_USER: root - POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! - POSTGRES_DB: new-api - volumes: - - pg_data:/var/lib/postgresql/data - networks: - - new-api-network -# ports: -# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker - -# mysql: -# image: mysql:8.2 -# container_name: mysql -# restart: always -# environment: -# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production! -# MYSQL_DATABASE: new-api -# volumes: -# - mysql_data:/var/lib/mysql -# networks: -# - new-api-network -# ports: -# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker - -volumes: - pg_data: -# mysql_data: - networks: - new-api-network: - driver: bridge + infra-shared: + external: true + name: infra-shared diff --git a/ops/remote/README.md b/ops/remote/README.md new file mode 100644 index 00000000..5cfcfdb6 --- /dev/null +++ b/ops/remote/README.md @@ -0,0 +1,9 @@ +# Remote Runtime Notes + +这个目录记录远程主机运行事实,避免把主机级流水账继续堆回根文档。 + +约定: + +- 一台主机一份 `*.AGENT.md` +- 只写运行事实、入口、路径、端口、依赖和排障命令 +- 项目规则仍放在根 `AGENTS.md` / `CLAUDE.md` diff --git a/ops/remote/minione-ubuntu-infra-4c16g120g.AGENT.md b/ops/remote/minione-ubuntu-infra-4c16g120g.AGENT.md new file mode 100644 index 00000000..cb2cbe43 --- /dev/null +++ b/ops/remote/minione-ubuntu-infra-4c16g120g.AGENT.md @@ -0,0 +1,63 @@ +# Remote Host AGENT — minione-ubuntu-infra-4c16g120g + +## Host Identity + +- Host name: `minione-ubuntu-infra` +- Tailscale IP: `100.64.0.11` +- LAN IP: `192.168.1.3` +- Access profile: `个人-minione-ubuntu-infra-4c16g120g` + +## Runtime Topology + +- `new-api` runs as a host `systemd` process +- `gitea` runs in Docker Compose under `/opt/gitea` +- PostgreSQL and Redis run as shared Docker infrastructure +- Shared Docker network: `infra-shared` + +## Shared Infrastructure + +### PostgreSQL + +- Container name: `infra-postgres` +- Host port: `127.0.0.1:5433` +- Docker alias: `infra-postgres:5432` +- Databases: + - `new-api` + - `gitea` +- Data path: `/srv/infra/postgres/data` +- Backup path: `/srv/infra/postgres/backup` + +### Redis + +- Container name: `infra-redis` +- Host port: `127.0.0.1:6379` +- Docker alias: `infra-redis:6379` +- Data path: `/srv/infra/redis/data` + +## new-api + +- Service name: `new-api` +- Deploy path: `/root/new-api-deploy` +- Runtime env: `/root/new-api-deploy/.env.runtime` +- systemd unit: `/etc/systemd/system/new-api.service` +- Health check: `curl --noproxy '*' http://127.0.0.1:3000/api/status` + +Host-process connection profile: + +- `SQL_DSN=postgresql://root:@127.0.0.1:5433/new-api` +- `REDIS_CONN_STRING=redis://:@127.0.0.1:6379/0` + +## gitea + +- Compose path: `/opt/gitea/docker-compose.yml` +- Config path: `/opt/gitea/data/gitea/conf/app.ini` +- Web: `http://100.64.0.11:3001/` +- SSH: `ssh -p 2222 git@100.64.0.11` +- Database host: `infra-postgres:5432` + +## Routine Checks + +- `systemctl status new-api` +- `journalctl -u new-api -n 200 --no-pager` +- `docker ps --format '{{.Names}} | {{.Image}} | {{.Ports}}'` +- `ss -lntp | egrep ':3000|:3001|:2222|:5433|:6379'` From 769a700b5c42f405040af88c2572d36ab18c3d84 Mon Sep 17 00:00:00 2001 From: zizi Date: Thu, 16 Apr 2026 17:07:06 +0800 Subject: [PATCH 2/5] Move Muse mirror-user provisioning into new-api control plane Muse needs a trusted path to create and disable per-user long-lived relay tokens so billing, routing, and downgrade logic can stay inside new-api instead of leaking back into Muse. This adds a small internal route group guarded by a shared secret, provisions a mirror user plus a dedicated long-lived token, and exposes disable/status/revoke operations for the control plane. The sync path stores the Muse user id in remark as a temporary lookup anchor until Muse persists the returned new-api ids. Constraint: Must reuse new-api billing/token model behavior without adding schema changes in phase 1 Constraint: Must avoid CreateUser side effects such as signup quota grants for mirrored internal users Rejected: Reusing existing self-service token controllers | they only manage the authenticated owner and cannot return control-plane ids/keys Rejected: Keying mirror users by username/email alone | unstable and collision-prone for external identity sync Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Treat remark-based muse_user_id lookup as a phase-1 bridge; prefer persisted new_api_user_id from Muse binding records for later operations Tested: go test ./controller ./service ./middleware -run MuseInternal -count=1 Tested: go test ./controller ./service ./middleware -count=1 Not-tested: Full relay/data-plane integration against a live Muse control-plane caller --- controller/muse_internal.go | 67 +++++++ controller/muse_internal_test.go | 151 +++++++++++++++ dto/muse_internal.go | 15 ++ middleware/muse_internal_auth.go | 25 +++ router/api-router.go | 9 + service/muse_internal_service.go | 305 +++++++++++++++++++++++++++++++ 6 files changed, 572 insertions(+) create mode 100644 controller/muse_internal.go create mode 100644 controller/muse_internal_test.go create mode 100644 dto/muse_internal.go create mode 100644 middleware/muse_internal_auth.go create mode 100644 service/muse_internal_service.go diff --git a/controller/muse_internal.go b/controller/muse_internal.go new file mode 100644 index 00000000..6a002c1a --- /dev/null +++ b/controller/muse_internal.go @@ -0,0 +1,67 @@ +package controller + +import ( + "errors" + "strconv" + + "github.com/QuantumNous/new-api/common" + museDTO "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" +) + +func SyncMuseUser(c *gin.Context) { + var req museDTO.SyncMuseUserRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, errors.New("invalid request body")) + return + } + response, err := service.SyncMuseUser(req) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, response) +} + +func DisableMuseUser(c *gin.Context) { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, errors.New("invalid user id")) + return + } + response, err := service.DisableMuseUserByID(userID) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, response) +} + +func RevokeMuseToken(c *gin.Context) { + tokenID, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, errors.New("invalid token id")) + return + } + response, err := service.RevokeMuseToken(tokenID) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, response) +} + +func GetMuseUserStatus(c *gin.Context) { + userID, err := strconv.Atoi(c.Param("id")) + if err != nil { + common.ApiError(c, errors.New("invalid user id")) + return + } + response, err := service.GetMuseUserStatus(userID) + if err != nil { + common.ApiError(c, err) + return + } + common.ApiSuccess(c, response) +} diff --git a/controller/muse_internal_test.go b/controller/muse_internal_test.go new file mode 100644 index 00000000..c93be7c0 --- /dev/null +++ b/controller/muse_internal_test.go @@ -0,0 +1,151 @@ +package controller + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + museDTO "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/middleware" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type museInternalAPIResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data museDTO.SyncMuseUserResponse `json:"data"` +} + +func setupMuseInternalControllerTestDB(t *testing.T) *gorm.DB { + t.Helper() + + gin.SetMode(gin.TestMode) + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.RedisEnabled = false + common.GlobalApiRateLimitEnable = false + + t.Setenv("MUSE_INTERNAL_SECRET", "test-secret") + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + + model.DB = db + model.LOG_DB = db + require.NoError(t, db.AutoMigrate(&model.User{}, &model.Token{})) + + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + return db +} + +func decodeMuseInternalResponse(t *testing.T, recorder *httptest.ResponseRecorder) museInternalAPIResponse { + t.Helper() + + var response museInternalAPIResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + return response +} + +func newMuseInternalRouter() *gin.Engine { + engine := gin.New() + internal := engine.Group("/api/internal/muse") + internal.Use(middleware.MuseInternalAuth()) + { + internal.POST("/users/sync", SyncMuseUser) + internal.POST("/users/:id/disable", DisableMuseUser) + internal.POST("/tokens/:id/revoke", RevokeMuseToken) + internal.GET("/users/:id/status", GetMuseUserStatus) + } + return engine +} + +func TestMuseInternalSyncUserCreatesMirrorUserAndToken(t *testing.T) { + setupMuseInternalControllerTestDB(t) + + req := httptest.NewRequest(http.MethodPost, "/api/internal/muse/users/sync", strings.NewReader(`{"muse_user_id":"u-1","username":"alice","display_name":"Alice","status":"active"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Muse-Service-Secret", "test-secret") + rr := httptest.NewRecorder() + + newMuseInternalRouter().ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + response := decodeMuseInternalResponse(t, rr) + require.True(t, response.Success) + require.Equal(t, "active", response.Data.Status) + require.NotZero(t, response.Data.NewAPIUserID) + require.NotZero(t, response.Data.TokenID) + require.NotEmpty(t, response.Data.TokenKey) + + user, err := model.GetUserById(response.Data.NewAPIUserID, false) + require.NoError(t, err) + require.Equal(t, common.UserStatusEnabled, user.Status) + require.Equal(t, "alice", user.Username) + require.Equal(t, "muse_user_id:u-1", user.Remark) + + token, err := model.GetTokenById(response.Data.TokenID) + require.NoError(t, err) + require.Equal(t, user.Id, token.UserId) + require.Equal(t, common.TokenStatusEnabled, token.Status) + require.True(t, token.UnlimitedQuota) +} + +func TestMuseInternalSyncUserRejectsInvalidSecret(t *testing.T) { + setupMuseInternalControllerTestDB(t) + + req := httptest.NewRequest(http.MethodPost, "/api/internal/muse/users/sync", strings.NewReader(`{"muse_user_id":"u-1","username":"alice","display_name":"Alice","status":"active"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Muse-Service-Secret", "wrong-secret") + rr := httptest.NewRecorder() + + newMuseInternalRouter().ServeHTTP(rr, req) + + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestMuseInternalDisableUserDisablesUserAndToken(t *testing.T) { + setupMuseInternalControllerTestDB(t) + + syncReq := httptest.NewRequest(http.MethodPost, "/api/internal/muse/users/sync", strings.NewReader(`{"muse_user_id":"u-2","username":"bob","display_name":"Bob","status":"active"}`)) + syncReq.Header.Set("Content-Type", "application/json") + syncReq.Header.Set("X-Muse-Service-Secret", "test-secret") + syncRR := httptest.NewRecorder() + engine := newMuseInternalRouter() + engine.ServeHTTP(syncRR, syncReq) + + syncResponse := decodeMuseInternalResponse(t, syncRR) + require.True(t, syncResponse.Success) + + disableReq := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/internal/muse/users/%d/disable", syncResponse.Data.NewAPIUserID), nil) + disableReq.Header.Set("X-Muse-Service-Secret", "test-secret") + disableRR := httptest.NewRecorder() + engine.ServeHTTP(disableRR, disableReq) + + require.Equal(t, http.StatusOK, disableRR.Code) + disableResponse := decodeMuseInternalResponse(t, disableRR) + require.True(t, disableResponse.Success) + require.Equal(t, "disabled", disableResponse.Data.Status) + require.Equal(t, syncResponse.Data.NewAPIUserID, disableResponse.Data.NewAPIUserID) + + user, err := model.GetUserById(syncResponse.Data.NewAPIUserID, false) + require.NoError(t, err) + require.Equal(t, common.UserStatusDisabled, user.Status) + + token, err := model.GetTokenById(syncResponse.Data.TokenID) + require.NoError(t, err) + require.Equal(t, common.TokenStatusDisabled, token.Status) +} diff --git a/dto/muse_internal.go b/dto/muse_internal.go new file mode 100644 index 00000000..8fef6d4b --- /dev/null +++ b/dto/muse_internal.go @@ -0,0 +1,15 @@ +package dto + +type SyncMuseUserRequest struct { + MuseUserID string `json:"muse_user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Status string `json:"status"` +} + +type SyncMuseUserResponse struct { + NewAPIUserID int `json:"newapi_user_id"` + TokenID int `json:"token_id"` + TokenKey string `json:"token_key,omitempty"` + Status string `json:"status"` +} diff --git a/middleware/muse_internal_auth.go b/middleware/muse_internal_auth.go new file mode 100644 index 00000000..e9d84755 --- /dev/null +++ b/middleware/muse_internal_auth.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "crypto/subtle" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/gin-gonic/gin" +) + +func MuseInternalAuth() gin.HandlerFunc { + return func(c *gin.Context) { + secret := c.GetHeader("X-Muse-Service-Secret") + expected := common.GetEnvOrDefaultString("MUSE_INTERNAL_SECRET", "") + if expected == "" || subtle.ConstantTimeCompare([]byte(secret), []byte(expected)) != 1 { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": "invalid muse internal secret", + }) + c.Abort() + return + } + c.Next() + } +} diff --git a/router/api-router.go b/router/api-router.go index 35d11376..2fed3100 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -53,6 +53,15 @@ func SetApiRouter(router *gin.Engine) { // Universal secure verification routes apiRouter.POST("/verify", middleware.UserAuth(), middleware.CriticalRateLimit(), controller.UniversalVerify) + museInternalRoute := apiRouter.Group("/internal/muse") + museInternalRoute.Use(middleware.MuseInternalAuth()) + { + museInternalRoute.POST("/users/sync", controller.SyncMuseUser) + museInternalRoute.POST("/users/:id/disable", controller.DisableMuseUser) + museInternalRoute.POST("/tokens/:id/revoke", controller.RevokeMuseToken) + museInternalRoute.GET("/users/:id/status", controller.GetMuseUserStatus) + } + userRoute := apiRouter.Group("/user") { userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register) diff --git a/service/muse_internal_service.go b/service/muse_internal_service.go new file mode 100644 index 00000000..7eefbcf0 --- /dev/null +++ b/service/muse_internal_service.go @@ -0,0 +1,305 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + museDTO "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" + "gorm.io/gorm" +) + +const ( + museUserStatusActive = "active" + museUserStatusDisabled = "disabled" + museMirrorTokenName = "muse-long-lived" + museRemarkPrefix = "muse_user_id:" +) + +func SyncMuseUser(req museDTO.SyncMuseUserRequest) (*museDTO.SyncMuseUserResponse, error) { + req.MuseUserID = strings.TrimSpace(req.MuseUserID) + req.Username = strings.TrimSpace(req.Username) + req.DisplayName = strings.TrimSpace(req.DisplayName) + if req.MuseUserID == "" { + return nil, errors.New("muse_user_id is required") + } + + if normalizeMuseStatus(req.Status) == museUserStatusDisabled { + return disableMuseUserByMuseUserID(req.MuseUserID) + } + + user, err := getMirrorUserByMuseUserID(req.MuseUserID) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + user, err = createMirrorUser(req) + if err != nil { + return nil, err + } + } else if err = updateMirrorUser(user, req); err != nil { + return nil, err + } + + token, err := ensureMirrorToken(user.Id) + if err != nil { + return nil, err + } + return buildMirrorResponse(user, token, museUserStatusActive, true), nil +} + +func DisableMuseUserByID(newAPIUserID int) (*museDTO.SyncMuseUserResponse, error) { + if newAPIUserID <= 0 { + return nil, errors.New("newapi user id is required") + } + user, err := model.GetUserById(newAPIUserID, false) + if err != nil { + return nil, err + } + if err = disableUserAndTokens(user); err != nil { + return nil, err + } + token, _ := getMirrorToken(user.Id) + return buildMirrorResponse(user, token, museUserStatusDisabled, false), nil +} + +func RevokeMuseToken(tokenID int) (*museDTO.SyncMuseUserResponse, error) { + if tokenID <= 0 { + return nil, errors.New("token id is required") + } + token, err := model.GetTokenById(tokenID) + if err != nil { + return nil, err + } + token.Status = common.TokenStatusDisabled + if err = token.Update(); err != nil { + return nil, err + } + user, err := model.GetUserById(token.UserId, false) + if err != nil { + return nil, err + } + return buildMirrorResponse(user, token, museUserStatusDisabled, false), nil +} + +func GetMuseUserStatus(newAPIUserID int) (*museDTO.SyncMuseUserResponse, error) { + if newAPIUserID <= 0 { + return nil, errors.New("newapi user id is required") + } + user, err := model.GetUserById(newAPIUserID, false) + if err != nil { + return nil, err + } + token, _ := getMirrorToken(user.Id) + return buildMirrorResponse(user, token, userStatusToMuseStatus(user.Status), false), nil +} + +func disableMuseUserByMuseUserID(museUserID string) (*museDTO.SyncMuseUserResponse, error) { + user, err := getMirrorUserByMuseUserID(museUserID) + if err != nil { + return nil, err + } + if err = disableUserAndTokens(user); err != nil { + return nil, err + } + token, _ := getMirrorToken(user.Id) + return buildMirrorResponse(user, token, museUserStatusDisabled, false), nil +} + +func getMirrorUserByMuseUserID(museUserID string) (*model.User, error) { + user := &model.User{} + err := model.DB.Where("remark = ?", museRemarkPrefix+museUserID).First(user).Error + if err != nil { + return nil, err + } + return user, nil +} + +func createMirrorUser(req museDTO.SyncMuseUserRequest) (*model.User, error) { + hashedPassword, err := common.Password2Hash(common.GetRandomString(20)) + if err != nil { + return nil, err + } + user := &model.User{ + Username: chooseMirrorUsername(req.Username, req.MuseUserID, 0, ""), + Password: hashedPassword, + DisplayName: chooseDisplayName(req.DisplayName, req.Username), + Role: common.RoleCommonUser, + Status: common.UserStatusEnabled, + Group: "default", + Remark: museRemarkPrefix + req.MuseUserID, + } + if user.DisplayName == "" { + user.DisplayName = user.Username + } + if err = model.DB.Create(user).Error; err != nil { + return nil, err + } + return user, nil +} + +func updateMirrorUser(user *model.User, req museDTO.SyncMuseUserRequest) error { + username := chooseMirrorUsername(req.Username, req.MuseUserID, user.Id, user.Username) + displayName := chooseDisplayName(req.DisplayName, req.Username) + if displayName == "" { + displayName = user.DisplayName + } + if displayName == "" { + displayName = username + } + + user.Username = username + user.DisplayName = displayName + user.Status = common.UserStatusEnabled + user.Role = common.RoleCommonUser + user.Remark = museRemarkPrefix + req.MuseUserID + return user.Update(false) +} + +func ensureMirrorToken(userID int) (*model.Token, error) { + token, err := getMirrorToken(userID) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + key, keyErr := common.GenerateKey() + if keyErr != nil { + return nil, keyErr + } + token = &model.Token{ + UserId: userID, + Name: museMirrorTokenName, + Key: key, + Status: common.TokenStatusEnabled, + CreatedTime: common.GetTimestamp(), + AccessedTime: common.GetTimestamp(), + ExpiredTime: -1, + RemainQuota: 0, + UnlimitedQuota: true, + } + if err = token.Insert(); err != nil { + return nil, err + } + return token, nil + } + + token.Status = common.TokenStatusEnabled + token.ExpiredTime = -1 + token.UnlimitedQuota = true + token.RemainQuota = 0 + token.AccessedTime = common.GetTimestamp() + if err = token.Update(); err != nil { + return nil, err + } + return token, nil +} + +func getMirrorToken(userID int) (*model.Token, error) { + token := &model.Token{} + err := model.DB.Where("user_id = ? AND name = ?", userID, museMirrorTokenName).Order("id desc").First(token).Error + if err != nil { + return nil, err + } + return token, nil +} + +func disableUserAndTokens(user *model.User) error { + user.Status = common.UserStatusDisabled + if err := user.Update(false); err != nil { + return err + } + + tokens := make([]model.Token, 0) + if err := model.DB.Where("user_id = ?", user.Id).Find(&tokens).Error; err != nil { + return err + } + for i := range tokens { + tokens[i].Status = common.TokenStatusDisabled + if err := tokens[i].Update(); err != nil { + return err + } + } + return nil +} + +func buildMirrorResponse(user *model.User, token *model.Token, status string, includeTokenKey bool) *museDTO.SyncMuseUserResponse { + response := &museDTO.SyncMuseUserResponse{Status: status} + if user != nil { + response.NewAPIUserID = user.Id + } + if token != nil { + response.TokenID = token.Id + if includeTokenKey { + response.TokenKey = token.GetFullKey() + } + } + return response +} + +func normalizeMuseStatus(status string) string { + if strings.EqualFold(strings.TrimSpace(status), museUserStatusDisabled) { + return museUserStatusDisabled + } + return museUserStatusActive +} + +func chooseDisplayName(displayName string, username string) string { + if strings.TrimSpace(displayName) != "" { + return trimToLength(displayName, model.UserNameMaxLength) + } + return trimToLength(username, model.UserNameMaxLength) +} + +func chooseMirrorUsername(desired string, museUserID string, currentUserID int, currentUsername string) string { + desired = trimToLength(desired, model.UserNameMaxLength) + if desired != "" && usernameAvailable(desired, currentUserID) { + return desired + } + currentUsername = trimToLength(currentUsername, model.UserNameMaxLength) + if currentUsername != "" && usernameAvailable(currentUsername, currentUserID) { + return currentUsername + } + fallback := trimToLength(fmt.Sprintf("muse_%s", common.GenerateHMAC(museUserID)[:15]), model.UserNameMaxLength) + if usernameAvailable(fallback, currentUserID) { + return fallback + } + for i := 0; i < 3; i++ { + candidate := trimToLength("m"+common.GetRandomString(model.UserNameMaxLength-1), model.UserNameMaxLength) + if usernameAvailable(candidate, currentUserID) { + return candidate + } + } + return fallback +} + +func usernameAvailable(username string, currentUserID int) bool { + if username == "" { + return false + } + query := model.DB.Model(&model.User{}).Where("username = ?", username) + if currentUserID > 0 { + query = query.Where("id <> ?", currentUserID) + } + var count int64 + if err := query.Count(&count).Error; err != nil { + return false + } + return count == 0 +} + +func trimToLength(value string, limit int) string { + value = strings.TrimSpace(value) + if len(value) <= limit { + return value + } + return value[:limit] +} + +func userStatusToMuseStatus(status int) string { + if status == common.UserStatusDisabled { + return museUserStatusDisabled + } + return museUserStatusActive +} From 1612c30de657e0eb5782d97360a911d950e23a29 Mon Sep 17 00:00:00 2001 From: zizi Date: Thu, 16 Apr 2026 19:25:29 +0800 Subject: [PATCH 3/5] Preserve Muse relay tags without hijacking new-api request ids Muse now forwards business context alongside each per-user token request, so new-api needs to ingest those tags for relay selection, billing audit, and log correlation without breaking its own internal request-id semantics. This adds a dedicated Muse request-context middleware, carries Muse tags into RelayInfo, and appends them into consume/error log payloads. Muse request ids are stored as separate business metadata instead of overwriting the internal request id used for pre-consume idempotency. Constraint: The relay layer must keep working for both /v1 and /v1beta request paths Constraint: Client-supplied Muse request ids cannot replace the internal request id used by new-api billing/idempotency logic Rejected: Overwriting common.RequestIdKey with X-Request-Id | mixes external business ids with internal idempotency ids Rejected: Adding new log table columns in phase 1 | this task only needs relay/log propagation, so other JSON is the minimal compatible path Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep Muse business identifiers in dedicated context keys and log other fields; do not reuse internal request-id slots for external correlation ids Tested: go test ./middleware ./relay/common ./controller -run MuseRequestContext -count=1 Tested: go test ./middleware ./relay/common ./controller -count=1 Not-tested: Live relay call from Muse into a running new-api instance --- constant/context_key.go | 6 + controller/log.go | 2 + middleware/muse_request_context.go | 113 +++++++++++++++ middleware/muse_request_context_test.go | 176 ++++++++++++++++++++++++ model/log.go | 28 ++++ relay/common/relay_info.go | 25 ++-- relay/common/relay_info_test.go | 33 +++++ router/relay-router.go | 2 + 8 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 middleware/muse_request_context.go create mode 100644 middleware/muse_request_context_test.go diff --git a/constant/context_key.go b/constant/context_key.go index 2ba2fe27..cb4a1e3c 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -56,6 +56,12 @@ const ( ContextKeySystemPromptOverride ContextKey = "system_prompt_override" + ContextKeyMuseUserID ContextKey = "muse_user_id" + ContextKeyMuseWorkID ContextKey = "muse_work_id" + ContextKeyMuseRequestID ContextKey = "muse_request_id" + ContextKeyMuseScene ContextKey = "muse_scene" + ContextKeyMuseTraceID ContextKey = "muse_trace_id" + // ContextKeyFileSourcesToCleanup stores file sources that need cleanup when request ends ContextKeyFileSourcesToCleanup ContextKey = "file_sources_to_cleanup" diff --git a/controller/log.go b/controller/log.go index cf3825f1..a5088733 100644 --- a/controller/log.go +++ b/controller/log.go @@ -20,6 +20,7 @@ func GetAllLogs(c *gin.Context) { modelName := c.Query("model_name") channel, _ := strconv.Atoi(c.Query("channel")) group := c.Query("group") + // request_id filters the internal relay request id; muse_request_id is carried in log.other for drill-down. requestId := c.Query("request_id") logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId) if err != nil { @@ -41,6 +42,7 @@ func GetUserLogs(c *gin.Context) { tokenName := c.Query("token_name") modelName := c.Query("model_name") group := c.Query("group") + // request_id filters the internal relay request id; muse_request_id is carried in log.other for drill-down. requestId := c.Query("request_id") logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId) if err != nil { diff --git a/middleware/muse_request_context.go b/middleware/muse_request_context.go new file mode 100644 index 00000000..3d1cdd17 --- /dev/null +++ b/middleware/muse_request_context.go @@ -0,0 +1,113 @@ +package middleware + +import ( + "fmt" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/gin-gonic/gin" +) + +const ( + museHeaderUserID = "X-Muse-User-Id" + museHeaderWorkID = "X-Muse-Work-Id" + museHeaderRequest = "X-Request-Id" + museHeaderTraceID = "X-Trace-Id" + museHeaderScene = "X-Muse-Scene" + jsonContentType = "application/json" +) + +type museMetadataEnvelope struct { + Metadata map[string]any `json:"metadata"` +} + +func MuseRequestContext() gin.HandlerFunc { + return func(c *gin.Context) { + metadata := extractMuseMetadata(c) + + museUserID := firstNonBlank( + c.GetHeader(museHeaderUserID), + stringifyMetadata(metadata["muse_user_id"]), + ) + museWorkID := firstNonBlank( + c.GetHeader(museHeaderWorkID), + stringifyMetadata(metadata["work_id"]), + stringifyMetadata(metadata["muse_work_id"]), + ) + museScene := firstNonBlank( + c.GetHeader(museHeaderScene), + stringifyMetadata(metadata["scene"]), + ) + requestID := firstNonBlank( + c.GetHeader(museHeaderRequest), + stringifyMetadata(metadata["request_id"]), + ) + traceID := firstNonBlank( + c.GetHeader(museHeaderTraceID), + stringifyMetadata(metadata["trace_id"]), + ) + + if museUserID != "" { + common.SetContextKey(c, constant.ContextKeyMuseUserID, museUserID) + } + if museWorkID != "" { + common.SetContextKey(c, constant.ContextKeyMuseWorkID, museWorkID) + } + if requestID != "" { + common.SetContextKey(c, constant.ContextKeyMuseRequestID, requestID) + } + if museScene != "" { + common.SetContextKey(c, constant.ContextKeyMuseScene, museScene) + } + if traceID != "" { + common.SetContextKey(c, constant.ContextKeyMuseTraceID, traceID) + } + c.Next() + } +} + +func extractMuseMetadata(c *gin.Context) map[string]any { + if c == nil || c.Request == nil { + return nil + } + if !strings.Contains(strings.ToLower(c.GetHeader("Content-Type")), jsonContentType) { + return nil + } + if !needsMuseMetadataRead(c) { + return nil + } + var envelope museMetadataEnvelope + if err := common.UnmarshalBodyReusable(c, &envelope); err != nil { + return nil + } + return envelope.Metadata +} + +func needsMuseMetadataRead(c *gin.Context) bool { + return c.GetHeader(museHeaderUserID) == "" || + c.GetHeader(museHeaderWorkID) == "" || + c.GetHeader(museHeaderRequest) == "" || + c.GetHeader(museHeaderTraceID) == "" || + c.GetHeader(museHeaderScene) == "" +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func stringifyMetadata(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(v) + default: + return strings.TrimSpace(fmt.Sprintf("%v", v)) + } +} diff --git a/middleware/muse_request_context_test.go b/middleware/muse_request_context_test.go new file mode 100644 index 00000000..2ba4025b --- /dev/null +++ b/middleware/muse_request_context_test.go @@ -0,0 +1,176 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +type museRequestContextResponse struct { + MuseUserID string `json:"muse_user_id"` + MuseWorkID string `json:"muse_work_id"` + MuseScene string `json:"muse_scene"` + MuseRequestID string `json:"muse_request_id"` + TraceID string `json:"trace_id"` +} + +func setupMuseRequestContextTestDB(t *testing.T) *gorm.DB { + t.Helper() + + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.RedisEnabled = false + + db, err := gorm.Open(sqlite.Open("file:muse_request_context_test?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + model.DB = db + model.LOG_DB = db + require.NoError(t, db.AutoMigrate(&model.User{}, &model.Log{})) + require.NoError(t, db.Create(&model.User{ + Id: 1, + Username: "alice", + Password: "hashed", + Status: common.UserStatusEnabled, + }).Error) + + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + }) + return db +} + +func TestMuseRequestContextExtractsHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + + req := httptest.NewRequest( + http.MethodPost, + "/v1/chat/completions", + strings.NewReader(`{"metadata":{"work_id":"42","scene":"suggestion_generation","muse_user_id":"u-1"}}`), + ) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(museHeaderUserID, "u-1") + req.Header.Set(museHeaderWorkID, "42") + req.Header.Set(museHeaderRequest, "req-1") + req.Header.Set(museHeaderTraceID, "trace-1") + + recorder := httptest.NewRecorder() + router := gin.New() + router.Use(MuseRequestContext()) + router.POST("/v1/chat/completions", func(c *gin.Context) { + c.JSON(http.StatusOK, museRequestContextResponse{ + MuseUserID: common.GetContextKeyString(c, constant.ContextKeyMuseUserID), + MuseWorkID: common.GetContextKeyString(c, constant.ContextKeyMuseWorkID), + MuseScene: common.GetContextKeyString(c, constant.ContextKeyMuseScene), + MuseRequestID: common.GetContextKeyString(c, constant.ContextKeyMuseRequestID), + TraceID: common.GetContextKeyString(c, constant.ContextKeyMuseTraceID), + }) + }) + + router.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + + var response museRequestContextResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "u-1", response.MuseUserID) + require.Equal(t, "42", response.MuseWorkID) + require.Equal(t, "suggestion_generation", response.MuseScene) + require.Equal(t, "trace-1", response.TraceID) + require.Equal(t, "req-1", response.MuseRequestID) +} + +func TestMuseRequestContextFallsBackToMetadata(t *testing.T) { + gin.SetMode(gin.TestMode) + + req := httptest.NewRequest( + http.MethodPost, + "/v1/chat/completions", + strings.NewReader(`{"metadata":{"muse_user_id":"u-2","work_id":42,"scene":"suggestion_generation","request_id":"req-meta","trace_id":"trace-meta"}}`), + ) + req.Header.Set("Content-Type", "application/json") + + recorder := httptest.NewRecorder() + router := gin.New() + router.Use(MuseRequestContext()) + router.POST("/v1/chat/completions", func(c *gin.Context) { + c.JSON(http.StatusOK, museRequestContextResponse{ + MuseUserID: common.GetContextKeyString(c, constant.ContextKeyMuseUserID), + MuseWorkID: common.GetContextKeyString(c, constant.ContextKeyMuseWorkID), + MuseScene: common.GetContextKeyString(c, constant.ContextKeyMuseScene), + MuseRequestID: common.GetContextKeyString(c, constant.ContextKeyMuseRequestID), + TraceID: common.GetContextKeyString(c, constant.ContextKeyMuseTraceID), + }) + }) + + router.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + + var response museRequestContextResponse + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.Equal(t, "u-2", response.MuseUserID) + require.Equal(t, "42", response.MuseWorkID) + require.Equal(t, "suggestion_generation", response.MuseScene) + require.Equal(t, "trace-meta", response.TraceID) + require.Equal(t, "req-meta", response.MuseRequestID) +} + +func TestMuseRequestContextRecordConsumeLogIncludesMuseFields(t *testing.T) { + gin.SetMode(gin.TestMode) + setupMuseRequestContextTestDB(t) + + req := httptest.NewRequest( + http.MethodPost, + "/v1/chat/completions", + strings.NewReader(`{"metadata":{"scene":"suggestion_generation","request_id":"req-log","trace_id":"trace-log"}}`), + ) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(museHeaderUserID, "u-log") + req.Header.Set(museHeaderWorkID, "77") + recorder := httptest.NewRecorder() + + router := gin.New() + router.Use(MuseRequestContext()) + router.POST("/v1/chat/completions", func(c *gin.Context) { + model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{ + ModelName: "gpt-4o", + Content: "ok", + Group: "default", + Other: map[string]interface{}{"existing": true}, + }) + var logEntry model.Log + require.NoError(t, model.LOG_DB.Last(&logEntry).Error) + c.JSON(http.StatusOK, gin.H{ + "request_id": logEntry.RequestId, + "other": logEntry.Other, + }) + }) + + router.ServeHTTP(recorder, req) + + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + RequestID string `json:"request_id"` + Other string `json:"other"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.Empty(t, response.RequestID) + require.Contains(t, response.Other, `"muse_user_id":"u-log"`) + require.Contains(t, response.Other, `"muse_work_id":"77"`) + require.Contains(t, response.Other, `"muse_request_id":"req-log"`) + require.Contains(t, response.Other, `"muse_scene":"suggestion_generation"`) + require.Contains(t, response.Other, `"muse_trace_id":"trace-log"`) +} diff --git a/model/log.go b/model/log.go index 68bc6504..15d7eb29 100644 --- a/model/log.go +++ b/model/log.go @@ -7,6 +7,7 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/types" @@ -95,6 +96,7 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content)) username := c.GetString("username") requestId := c.GetString(common.RequestIdKey) + other = appendMuseContextToOther(c, other) otherStr := common.MapToJsonStr(other) // 判断是否需要记录 IP needRecordIp := false @@ -156,6 +158,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params))) username := c.GetString("username") requestId := c.GetString(common.RequestIdKey) + params.Other = appendMuseContextToOther(c, params.Other) otherStr := common.MapToJsonStr(params.Other) // 判断是否需要记录 IP needRecordIp := false @@ -200,6 +203,31 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams) } } +func appendMuseContextToOther(c *gin.Context, other map[string]interface{}) map[string]interface{} { + if c == nil { + return other + } + if other == nil { + other = make(map[string]interface{}) + } + if museUserID := common.GetContextKeyString(c, constant.ContextKeyMuseUserID); museUserID != "" { + other["muse_user_id"] = museUserID + } + if museWorkID := common.GetContextKeyString(c, constant.ContextKeyMuseWorkID); museWorkID != "" { + other["muse_work_id"] = museWorkID + } + if museRequestID := common.GetContextKeyString(c, constant.ContextKeyMuseRequestID); museRequestID != "" { + other["muse_request_id"] = museRequestID + } + if museScene := common.GetContextKeyString(c, constant.ContextKeyMuseScene); museScene != "" { + other["muse_scene"] = museScene + } + if museTraceID := common.GetContextKeyString(c, constant.ContextKeyMuseTraceID); museTraceID != "" { + other["muse_trace_id"] = museTraceID + } + return other +} + type RecordTaskBillingLogParams struct { UserId int LogType int diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index e4421fc1..c5739388 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -139,7 +139,12 @@ type RelayInfo struct { SubscriptionPlanId int SubscriptionPlanTitle string // RequestId is used for idempotent pre-consume/refund - RequestId string + RequestId string + MuseUserID string + MuseWorkID string + MuseRequestID string + MuseScene string + MuseTraceID string // SubscriptionAmountTotal / SubscriptionAmountUsedAfterPreConsume are used to compute remaining in logs. SubscriptionAmountTotal int64 SubscriptionAmountUsedAfterPreConsume int64 @@ -255,7 +260,6 @@ func (info *RelayInfo) ToString() string { latencyMs := info.FirstResponseTime.Sub(info.StartTime).Milliseconds() fmt.Fprintf(b, "Timing{ Start: %s, FirstResponse: %s, LatencyMs: %d }, ", info.StartTime.Format(time.RFC3339Nano), info.FirstResponseTime.Format(time.RFC3339Nano), latencyMs) - // Audio / realtime if info.InputAudioFormat != "" || info.OutputAudioFormat != "" || len(info.RealtimeTools) > 0 || info.AudioUsage { fmt.Fprintf(b, "Realtime{ AudioUsage: %t, InFmt: %q, OutFmt: %q, Tools: %d }, ", @@ -447,12 +451,17 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo { info := &RelayInfo{ Request: request, - RequestId: reqId, - UserId: common.GetContextKeyInt(c, constant.ContextKeyUserId), - UsingGroup: common.GetContextKeyString(c, constant.ContextKeyUsingGroup), - UserGroup: common.GetContextKeyString(c, constant.ContextKeyUserGroup), - UserQuota: common.GetContextKeyInt(c, constant.ContextKeyUserQuota), - UserEmail: common.GetContextKeyString(c, constant.ContextKeyUserEmail), + RequestId: reqId, + MuseUserID: common.GetContextKeyString(c, constant.ContextKeyMuseUserID), + MuseWorkID: common.GetContextKeyString(c, constant.ContextKeyMuseWorkID), + MuseRequestID: common.GetContextKeyString(c, constant.ContextKeyMuseRequestID), + MuseScene: common.GetContextKeyString(c, constant.ContextKeyMuseScene), + MuseTraceID: common.GetContextKeyString(c, constant.ContextKeyMuseTraceID), + UserId: common.GetContextKeyInt(c, constant.ContextKeyUserId), + UsingGroup: common.GetContextKeyString(c, constant.ContextKeyUsingGroup), + UserGroup: common.GetContextKeyString(c, constant.ContextKeyUserGroup), + UserQuota: common.GetContextKeyInt(c, constant.ContextKeyUserQuota), + UserEmail: common.GetContextKeyString(c, constant.ContextKeyUserEmail), OriginModelName: common.GetContextKeyString(c, constant.ContextKeyOriginalModel), diff --git a/relay/common/relay_info_test.go b/relay/common/relay_info_test.go index e53ec804..2c77319a 100644 --- a/relay/common/relay_info_test.go +++ b/relay/common/relay_info_test.go @@ -1,9 +1,15 @@ package common import ( + "net/http" + "net/http/httptest" "testing" + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -38,3 +44,30 @@ func TestRelayInfoGetFinalRequestRelayFormatNilReceiver(t *testing.T) { var info *RelayInfo require.Equal(t, types.RelayFormat(""), info.GetFinalRequestRelayFormat()) } + +func TestMuseRequestContextGenRelayInfoCopiesMuseFields(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + + ctx.Set(common.RequestIdKey, "req-1") + common.SetContextKey(ctx, constant.ContextKeyUserId, 11) + common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default") + common.SetContextKey(ctx, constant.ContextKeyUsingGroup, "default") + common.SetContextKey(ctx, constant.ContextKeyMuseUserID, "muse-user-1") + common.SetContextKey(ctx, constant.ContextKeyMuseWorkID, "42") + common.SetContextKey(ctx, constant.ContextKeyMuseRequestID, "muse-req-1") + common.SetContextKey(ctx, constant.ContextKeyMuseScene, "suggestion_generation") + common.SetContextKey(ctx, constant.ContextKeyMuseTraceID, "trace-1") + + info := GenRelayInfoOpenAI(ctx, &dto.GeneralOpenAIRequest{}) + + require.NotNil(t, info) + require.Equal(t, "req-1", info.RequestId) + require.Equal(t, "muse-user-1", info.MuseUserID) + require.Equal(t, "42", info.MuseWorkID) + require.Equal(t, "muse-req-1", info.MuseRequestID) + require.Equal(t, "suggestion_generation", info.MuseScene) + require.Equal(t, "trace-1", info.MuseTraceID) +} diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad..8dc6fab1 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -70,6 +70,7 @@ func SetRelayRouter(router *gin.Engine) { relayV1Router.Use(middleware.RouteTag("relay")) relayV1Router.Use(middleware.SystemPerformanceCheck()) relayV1Router.Use(middleware.TokenAuth()) + relayV1Router.Use(middleware.MuseRequestContext()) relayV1Router.Use(middleware.ModelRequestRateLimit()) { // WebSocket 路由(统一到 Relay) @@ -190,6 +191,7 @@ func SetRelayRouter(router *gin.Engine) { relayGeminiRouter.Use(middleware.RouteTag("relay")) relayGeminiRouter.Use(middleware.SystemPerformanceCheck()) relayGeminiRouter.Use(middleware.TokenAuth()) + relayGeminiRouter.Use(middleware.MuseRequestContext()) relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) relayGeminiRouter.Use(middleware.Distribute()) { From 0427cd067a7d234ed405910c5ab4f83e908f0851 Mon Sep 17 00:00:00 2001 From: zizi Date: Thu, 16 Apr 2026 21:05:22 +0800 Subject: [PATCH 4/5] Let Muse scene policy drive relay retries and model fallback Muse now sends scene, routing policy, and degrade policy with each per-user request, so new-api should interpret those hints instead of leaving retry scope and fallback behavior implicit in static token settings alone. This introduces a small Muse routing-policy interpreter, lets distributor switch quality-first traffic onto auto-group retry, and retries against lower-tier model candidates when the degrade policy allows it. The policy stays lightweight and still respects user-usable groups. Constraint: Routing decisions must remain compatible with existing channel/group selection and not require schema changes Constraint: Muse can hint policy, but new-api must still enforce only user-usable groups and channel availability Rejected: Hardcoding physical channel/group choices inside Muse | would keep routing ownership outside new-api Rejected: Expanding the policy into a full declarative DSL in phase 1 | overdesigned for the immediate routing/degrade handoff Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Keep Muse routing policy interpretation small and observable; if more scenes appear, extend the helper rather than scattering policy branches across middleware Tested: go test ./service -run MuseRoutingPolicy -count=1 Tested: go test ./service -count=1 Not-tested: Live relay selection against production channel inventory --- middleware/distributor.go | 68 ++++++++++++- service/channel_select.go | 2 +- service/muse_routing_policy.go | 146 ++++++++++++++++++++++++++++ service/muse_routing_policy_test.go | 43 ++++++++ 4 files changed, 253 insertions(+), 6 deletions(-) create mode 100644 service/muse_routing_policy.go create mode 100644 service/muse_routing_policy_test.go diff --git a/middleware/distributor.go b/middleware/distributor.go index d6269414..238eb033 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -23,8 +23,15 @@ import ( ) type ModelRequest struct { - Model string `json:"model"` - Group string `json:"group,omitempty"` + Model string `json:"model"` + Group string `json:"group,omitempty"` + Metadata *MuseRoutingPolicyInput `json:"metadata,omitempty"` +} + +type MuseRoutingPolicyInput struct { + Scene string `json:"scene,omitempty"` + RoutingPolicy string `json:"routing_policy,omitempty"` + DegradePolicy string `json:"degrade_policy,omitempty"` } func Distribute() func(c *gin.Context) { @@ -36,6 +43,12 @@ func Distribute() func(c *gin.Context) { abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()})) return } + policy := service.ResolveMuseRoutingPolicy( + firstNonBlank(common.GetContextKeyString(c, constant.ContextKeyMuseScene), modelRequest.MetadataValue("scene")), + modelRequest.MetadataValue("routing_policy"), + modelRequest.MetadataValue("degrade_policy"), + ) + service.SetMuseRoutingPolicy(c, policy) if ok { id, err := strconv.Atoi(channelId.(string)) if err != nil { @@ -98,6 +111,7 @@ func Distribute() func(c *gin.Context) { common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) } } + usingGroup = service.ApplyMuseRoutingPolicy(c, usingGroup) if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { preferred, err := model.CacheGetChannel(preferredChannelID) @@ -109,7 +123,7 @@ func Distribute() func(c *gin.Context) { } } else if usingGroup == "auto" { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) - autoGroups := service.GetUserAutoGroup(userGroup) + autoGroups := service.ResolveMusePreferredGroups(c, userGroup) for _, g := range autoGroups { if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) { selectGroup = g @@ -128,12 +142,31 @@ func Distribute() func(c *gin.Context) { } if channel == nil { - channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{ + retryParam := &service.RetryParam{ Ctx: c, ModelName: modelRequest.Model, TokenGroup: usingGroup, Retry: common.GetPointer(0), - }) + } + channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(retryParam) + if channel == nil && err == nil { + candidates := service.MuseFallbackModelCandidates(modelRequest.Model, policy) + for _, candidateModel := range candidates[1:] { + candidateParam := &service.RetryParam{ + Ctx: c, + ModelName: candidateModel, + TokenGroup: usingGroup, + Retry: common.GetPointer(0), + } + channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(candidateParam) + if channel != nil || err != nil { + if channel != nil { + modelRequest.Model = candidateModel + } + break + } + } + } if err != nil { showGroup := usingGroup if usingGroup == "auto" { @@ -164,6 +197,31 @@ func Distribute() func(c *gin.Context) { } } +func (r *ModelRequest) MetadataValue(name string) string { + if r == nil || r.Metadata == nil { + return "" + } + switch name { + case "scene": + return r.Metadata.Scene + case "routing_policy": + return r.Metadata.RoutingPolicy + case "degrade_policy": + return r.Metadata.DegradePolicy + default: + return "" + } +} + +func firstNonBlank(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + // getModelFromRequest 从请求中读取模型信息 // 根据 Content-Type 自动处理: // - application/json diff --git a/service/channel_select.go b/service/channel_select.go index a3710ef8..e87f02d7 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -90,7 +90,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, if len(setting.GetAutoGroups()) == 0 { return nil, selectGroup, errors.New("auto groups is not enabled") } - autoGroups := GetUserAutoGroup(userGroup) + autoGroups := ResolveMusePreferredGroups(param.Ctx, userGroup) // startGroupIndex: the group index to start searching from // startGroupIndex: 开始搜索的分组索引 diff --git a/service/muse_routing_policy.go b/service/muse_routing_policy.go new file mode 100644 index 00000000..860d1036 --- /dev/null +++ b/service/muse_routing_policy.go @@ -0,0 +1,146 @@ +package service + +import ( + "slices" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/gin-gonic/gin" +) + +const ginKeyMuseRoutingPolicy = "muse_routing_policy" + +type MuseRoutingPolicy struct { + Scene string + AllowCrossGroupRetry bool + AllowModelDegrade bool + PreferredGroups []string +} + +func ResolveMuseRoutingPolicy(scene string, routingPolicy string, degradePolicy string) MuseRoutingPolicy { + scene = normalizeMusePolicyValue(scene) + routingPolicy = normalizeMusePolicyValue(routingPolicy) + degradePolicy = normalizeMusePolicyValue(degradePolicy) + + policy := MuseRoutingPolicy{ + Scene: scene, + PreferredGroups: defaultMusePreferredGroups(scene, routingPolicy), + } + + switch routingPolicy { + case "quality_first", "balanced": + policy.AllowCrossGroupRetry = true + } + + if degradePolicy == "allow_lower_tier" { + policy.AllowModelDegrade = true + } + + return policy +} + +func SetMuseRoutingPolicy(c *gin.Context, policy MuseRoutingPolicy) { + if c == nil { + return + } + if policy.Scene == "" && !policy.AllowCrossGroupRetry && !policy.AllowModelDegrade && len(policy.PreferredGroups) == 0 { + return + } + c.Set(ginKeyMuseRoutingPolicy, policy) +} + +func GetMuseRoutingPolicy(c *gin.Context) (MuseRoutingPolicy, bool) { + if c == nil { + return MuseRoutingPolicy{}, false + } + value, ok := c.Get(ginKeyMuseRoutingPolicy) + if !ok { + return MuseRoutingPolicy{}, false + } + policy, ok := value.(MuseRoutingPolicy) + if !ok { + return MuseRoutingPolicy{}, false + } + return policy, true +} + +func ApplyMuseRoutingPolicy(c *gin.Context, usingGroup string) string { + policy, ok := GetMuseRoutingPolicy(c) + if !ok { + return usingGroup + } + if policy.AllowCrossGroupRetry { + common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, true) + if usingGroup != "auto" { + usingGroup = "auto" + common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) + } + } + return usingGroup +} + +func ResolveMusePreferredGroups(c *gin.Context, userGroup string) []string { + policy, ok := GetMuseRoutingPolicy(c) + if !ok || len(policy.PreferredGroups) == 0 { + return GetUserAutoGroup(userGroup) + } + usableGroups := GetUserUsableGroups(userGroup) + filtered := make([]string, 0, len(policy.PreferredGroups)) + for _, group := range policy.PreferredGroups { + if _, allowed := usableGroups[group]; allowed { + filtered = append(filtered, group) + } + } + if len(filtered) == 0 { + return GetUserAutoGroup(userGroup) + } + return filtered +} + +func MuseFallbackModelCandidates(modelName string, policy MuseRoutingPolicy) []string { + candidates := []string{modelName} + if !policy.AllowModelDegrade { + return candidates + } + + appendUnique := func(candidate string) { + candidate = strings.TrimSpace(candidate) + if candidate == "" { + return + } + if !slices.Contains(candidates, candidate) { + candidates = append(candidates, candidate) + } + } + + switch { + case strings.HasPrefix(modelName, "gpt-4o") && modelName != "gpt-4o-mini": + appendUnique("gpt-4o-mini") + case strings.Contains(modelName, "gemini") && strings.Contains(modelName, "pro"): + appendUnique(strings.Replace(modelName, "pro", "flash", 1)) + case strings.Contains(modelName, "claude") && strings.Contains(modelName, "opus"): + appendUnique(strings.Replace(modelName, "opus", "sonnet", 1)) + case strings.Contains(modelName, "claude") && strings.Contains(modelName, "sonnet"): + appendUnique(strings.Replace(modelName, "sonnet", "haiku", 1)) + } + + return candidates +} + +func defaultMusePreferredGroups(scene string, routingPolicy string) []string { + switch routingPolicy { + case "quality_first": + return []string{"svip", "vip", "default"} + case "balanced": + return []string{"vip", "default"} + } + if scene == "suggestion_generation" { + return []string{"default"} + } + return nil +} + +func normalizeMusePolicyValue(value string) string { + return strings.TrimSpace(strings.ToLower(value)) +} diff --git a/service/muse_routing_policy_test.go b/service/muse_routing_policy_test.go new file mode 100644 index 00000000..d5e25772 --- /dev/null +++ b/service/muse_routing_policy_test.go @@ -0,0 +1,43 @@ +package service + +import ( + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestResolveMuseRoutingPolicy_QualityFirstAllowsFallback(t *testing.T) { + policy := ResolveMuseRoutingPolicy("suggestion_generation", "quality_first", "allow_lower_tier") + + require.Equal(t, "suggestion_generation", policy.Scene) + require.True(t, policy.AllowCrossGroupRetry) + require.True(t, policy.AllowModelDegrade) + require.Equal(t, []string{"svip", "vip", "default"}, policy.PreferredGroups) +} + +func TestResolveMuseRoutingPolicy_DefaultsWithoutPolicies(t *testing.T) { + policy := ResolveMuseRoutingPolicy("suggestion_generation", "", "") + + require.False(t, policy.AllowCrossGroupRetry) + require.False(t, policy.AllowModelDegrade) + require.Equal(t, []string{"default"}, policy.PreferredGroups) +} + +func TestMuseFallbackModelCandidates_AllowLowerTierAddsFallback(t *testing.T) { + policy := ResolveMuseRoutingPolicy("suggestion_generation", "quality_first", "allow_lower_tier") + + candidates := MuseFallbackModelCandidates("gpt-4o", policy) + + require.Equal(t, []string{"gpt-4o", "gpt-4o-mini"}, candidates) +} + +func TestResolveMusePreferredGroups_UsesSceneDrivenDefaultsFromContext(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx, _ := gin.CreateTestContext(nil) + SetMuseRoutingPolicy(ctx, ResolveMuseRoutingPolicy("suggestion_generation", "", "")) + + groups := ResolveMusePreferredGroups(ctx, "default") + + require.Equal(t, []string{"default"}, groups) +} From 4eb7373033e8e4133ed1e994c935febea7389bda Mon Sep 17 00:00:00 2001 From: zizi Date: Thu, 16 Apr 2026 22:14:05 +0800 Subject: [PATCH 5/5] Unblock the merged Muse relay path by separating helper names The local merge brought Task 4 and Task 6 helpers into the same middleware package, and both introduced a generic firstNonBlank helper. Renaming the Muse-specific helper keeps the merged branch buildable without changing behavior. Constraint: This fix is on top of the local merge to home and must avoid altering the request-context logic itself Rejected: Rolling back the Muse relay context feature | merge target should stay functional after the requested local merge Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep package-local helper names specific enough to survive future feature merges Tested: go test ./service ./middleware ./relay/common ./controller -count=1 Not-tested: Live relay request after the local merge --- middleware/muse_request_context.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/middleware/muse_request_context.go b/middleware/muse_request_context.go index 3d1cdd17..1002bf15 100644 --- a/middleware/muse_request_context.go +++ b/middleware/muse_request_context.go @@ -26,24 +26,24 @@ func MuseRequestContext() gin.HandlerFunc { return func(c *gin.Context) { metadata := extractMuseMetadata(c) - museUserID := firstNonBlank( + museUserID := museFirstNonBlank( c.GetHeader(museHeaderUserID), stringifyMetadata(metadata["muse_user_id"]), ) - museWorkID := firstNonBlank( + museWorkID := museFirstNonBlank( c.GetHeader(museHeaderWorkID), stringifyMetadata(metadata["work_id"]), stringifyMetadata(metadata["muse_work_id"]), ) - museScene := firstNonBlank( + museScene := museFirstNonBlank( c.GetHeader(museHeaderScene), stringifyMetadata(metadata["scene"]), ) - requestID := firstNonBlank( + requestID := museFirstNonBlank( c.GetHeader(museHeaderRequest), stringifyMetadata(metadata["request_id"]), ) - traceID := firstNonBlank( + traceID := museFirstNonBlank( c.GetHeader(museHeaderTraceID), stringifyMetadata(metadata["trace_id"]), ) @@ -92,7 +92,7 @@ func needsMuseMetadataRead(c *gin.Context) bool { c.GetHeader(museHeaderScene) == "" } -func firstNonBlank(values ...string) string { +func museFirstNonBlank(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value)