diff --git a/.env.example b/.env.example
index a63ed7668e98..3020f089ca12 100644
--- a/.env.example
+++ b/.env.example
@@ -1,7 +1,27 @@
# 端口号
# PORT=3000
-# 前端基础URL
-# FRONTEND_BASE_URL=https://your-frontend-url.com
+# 前端交付模式(与 RUN_MODE / APP_PLANE 正交):
+# auto - 默认兼容:slave + FRONTEND_BASE_URL 时跳转,否则使用嵌入资源
+# embedded - 强制嵌入双主题;frontend_external 构建会启动失败
+# redirect - 强制把未知页面 301 到 FRONTEND_BASE_URL(含 master)
+# disabled - 纯后端,不注册前端 NoRoute;配合独立 Nginx 前端镜像
+# FRONTEND_MODE=auto
+# 前端基础 URL:仅 redirect/auto(slave) 使用;必须是无路径/查询/凭据的 HTTP(S) origin
+# FRONTEND_BASE_URL=https://console.example.com
+# 允许浏览器跨域访问 API 的可信来源,默认不允许跨域;多个来源用英文逗号分隔
+# 同源 Nginx 反代时通常无需配置 CORS_ALLOWED_ORIGINS
+# CORS_ALLOWED_ORIGINS=https://console.example.com,https://admin.example.com
+# HTTP 慢连接防护;流式响应不设置全局 WriteTimeout
+# HTTP_READ_HEADER_TIMEOUT_SECONDS=10
+# HTTP_IDLE_TIMEOUT_SECONDS=120
+# HTTP_MAX_HEADER_BYTES=1048576
+# 进程职责与 HTTP 平面;默认 all 保持单进程兼容
+# RUN_MODE=all # all|serve|worker|scheduler|migrate
+# APP_PLANE=all # all|relay|management
+# Prometheus 指标默认关闭;启用后建议同时配置随机 Bearer token
+# 独立前端边缘默认不代理 /metrics,请在后端网络内抓取
+# METRICS_ENABLED=false
+# METRICS_TOKEN=
# 调试相关配置
@@ -33,6 +53,7 @@
# SQL_MAX_OPEN_CONNS=1000
# 数据库连接最大生命周期(秒)
# SQL_MAX_LIFETIME=60
+# READINESS_TIMEOUT_SECONDS=3
# 缓存相关配置
@@ -58,6 +79,15 @@
# RELAY_TIMEOUT=0
# Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制
# RELAY_IDLE_CONN_TIMEOUT=90
+# RELAY_DIAL_TIMEOUT=10
+# RELAY_TLS_HANDSHAKE_TIMEOUT=10
+# RELAY_RESPONSE_HEADER_TIMEOUT=120
+# RELAY_EXPECT_CONTINUE_TIMEOUT=1
+# Comma-separated proxy IPs/CIDRs whose forwarding headers may be trusted.
+# Leave unset to ignore X-Forwarded-For and X-Real-IP. For a local reverse proxy,
+# use TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 only when port 3000 is not directly exposed.
+# Separated frontend Nginx in Docker usually needs the compose bridge CIDR, e.g. 172.16.0.0/12.
+# TRUSTED_PROXY_CIDRS=
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
# STREAMING_TIMEOUT=300
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
new file mode 100644
index 000000000000..6c32c198c177
--- /dev/null
+++ b/.github/workflows/quality.yml
@@ -0,0 +1,192 @@
+name: Quality Gate
+
+on:
+ workflow_call:
+ pull_request:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+concurrency:
+ group: quality-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ go-quality:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - name: Set up Go
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
+ with:
+ go-version: 1.26.5
+ cache: true
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: 1.3.14
+
+ - name: Build embedded frontend assets
+ working-directory: web
+ env:
+ DISABLE_ESLINT_PLUGIN: 'true'
+ run: |
+ bun install --frozen-lockfile
+ (cd default && bun run build)
+ (cd classic && bun run build)
+
+ - name: Validate build metadata
+ run: |
+ test -s VERSION
+ test "$(go env GOVERSION)" = "go1.26.5"
+
+ - name: Build
+ run: go build -trimpath -buildvcs=true ./...
+
+ - name: Build pure backend without embedded frontend
+ # frontend_external excludes //go:embed web/*/dist so the backend can ship without Bun assets.
+ run: go build -trimpath -buildvcs=true -tags frontend_external .
+
+ - name: Test
+ env:
+ GOSUMDB: sum.golang.org
+ run: go test -count=1 ./...
+
+ - name: Test pure backend package with frontend_external
+ env:
+ GOSUMDB: sum.golang.org
+ run: go test -count=1 -tags frontend_external .
+
+ - name: Vet
+ run: go vet ./...
+
+ - name: Vulnerability scan
+ run: |
+ go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
+ govulncheck ./...
+
+ web-quality:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - name: Set up Bun
+ uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ with:
+ bun-version: 1.3.14
+
+ - name: Install locked dependencies
+ working-directory: web
+ run: bun install --frozen-lockfile
+
+ - name: Audit dependencies
+ working-directory: web
+ run: bun audit
+
+ - name: Typecheck default frontend
+ working-directory: web/default
+ run: bun run typecheck
+
+ - name: Test default frontend
+ working-directory: web/default
+ run: bun test
+
+ - name: Build default frontend
+ working-directory: web/default
+ env:
+ DISABLE_ESLINT_PLUGIN: 'true'
+ run: bun run build
+
+ - name: Enforce entry bundle budget
+ working-directory: web/default
+ run: bun run bundle:check
+
+ - name: Test classic frontend
+ working-directory: web/classic
+ run: bun test
+
+ - name: Build classic frontend
+ working-directory: web/classic
+ run: bun run build
+
+ - name: Enforce classic bundle budget
+ working-directory: web/classic
+ run: bun run bundle:check
+
+ - name: Test shared web tooling
+ working-directory: web
+ run: bun run test:tooling
+
+ image-reproducibility:
+ needs: [go-quality, web-quality]
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - name: Build pinned integrated image
+ run: docker build --tag new-api:quality .
+
+ - name: Build pure backend image
+ run: docker build -f Dockerfile.backend --tag new-api-backend:quality .
+
+ - name: Resolve nginx unprivileged base digest
+ # Pin the frontend runtime base at build time so floating tags cannot silently drift.
+ id: nginx_base
+ run: |
+ set -euo pipefail
+ IMAGE_REF='nginxinc/nginx-unprivileged:1.27-alpine'
+ docker pull "$IMAGE_REF"
+ DIGEST_REF="$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE_REF")"
+ test -n "$DIGEST_REF"
+ echo "image=${DIGEST_REF}" >> "$GITHUB_OUTPUT"
+ echo "Pinned frontend nginx base: ${DIGEST_REF}" >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Build separated frontend image
+ run: |
+ docker build \
+ -f deploy/separated/Dockerfile.frontend \
+ --build-arg "NGINX_IMAGE=${{ steps.nginx_base.outputs.image }}" \
+ --tag new-api-frontend:quality .
+
+ - name: Validate frontend Nginx configuration
+ # Variable proxy_pass + resolver defers DNS; use loopback upstream so nginx -t
+ # does not require a live backend hostname in this single-container job.
+ run: |
+ docker run --rm --entrypoint /bin/sh new-api-frontend:quality -c '
+ set -eu
+ export BACKEND_UPSTREAM=127.0.0.1:3000
+ export DNS_RESOLVER=127.0.0.1
+ export NGINX_PORT=8080
+ export SERVER_NAME=_
+ export CLIENT_MAX_BODY_SIZE=100m
+ export PROXY_CONNECT_TIMEOUT=60s
+ export PROXY_SEND_TIMEOUT=3600s
+ export PROXY_READ_TIMEOUT=3600s
+ envsubst '\''${BACKEND_UPSTREAM} ${NGINX_PORT} ${SERVER_NAME} ${CLIENT_MAX_BODY_SIZE} ${PROXY_CONNECT_TIMEOUT} ${PROXY_SEND_TIMEOUT} ${PROXY_READ_TIMEOUT} ${DNS_RESOLVER}'\'' \
+ < /etc/nginx/templates/nginx.conf.template > /tmp/nginx-test.conf
+ nginx -t -c /tmp/nginx-test.conf
+ '
+
+ - name: Record separated image digests
+ if: always()
+ run: |
+ {
+ echo '### Separated delivery images'
+ for image in new-api:quality new-api-backend:quality new-api-frontend:quality; do
+ if docker image inspect "$image" >/dev/null 2>&1; then
+ id="$(docker image inspect --format='{{.Id}}' "$image")"
+ echo "- \`${image}\`: \`${id}\`"
+ else
+ echo "- \`${image}\`: missing"
+ fi
+ done
+ echo "- nginx base: \`${{ steps.nginx_base.outputs.image }}\`"
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 6e519749794f..d719ec9e255f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,8 +14,15 @@ on:
- '!*-alpha*'
jobs:
+ quality-gate:
+ name: Quality Gate
+ uses: ./.github/workflows/quality.yml
+ permissions:
+ contents: read
+
linux:
name: Linux Release
+ needs: quality-gate
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -28,7 +35,7 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- bun-version: latest
+ bun-version: 1.3.14
- name: Build Frontend (default)
env:
CI: ""
@@ -50,16 +57,16 @@ jobs:
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
- go-version: '>=1.25.1'
+ go-version: 1.26.5
- name: Build Backend (amd64)
run: |
go mod download
- go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
+ go build -trimpath -buildvcs=true -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
- name: Build Backend (arm64)
run: |
sudo apt-get update
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
- CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
+ CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -trimpath -buildvcs=true -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
- name: Generate checksums
run: sha256sum new-api-* > checksums-linux.txt
@@ -75,6 +82,7 @@ jobs:
macos:
name: macOS Release
+ needs: quality-gate
runs-on: macos-latest
steps:
- name: Checkout
@@ -87,7 +95,7 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- bun-version: latest
+ bun-version: 1.3.14
- name: Build Frontend (default)
env:
CI: ""
@@ -110,11 +118,11 @@ jobs:
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
- go-version: '>=1.25.1'
+ go-version: 1.26.5
- name: Build Backend
run: |
go mod download
- go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
+ go build -trimpath -buildvcs=true -ldflags "-X 'github.com/QuantumNous/new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION
- name: Generate checksums
run: shasum -a 256 new-api-macos-* > checksums-macos.txt
@@ -130,6 +138,7 @@ jobs:
windows:
name: Windows Release
+ needs: quality-gate
runs-on: windows-latest
defaults:
run:
@@ -145,7 +154,7 @@ jobs:
echo "VERSION=$VERSION" >> $GITHUB_ENV
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
- bun-version: latest
+ bun-version: 1.3.14
- name: Build Frontend (default)
env:
CI: ""
@@ -167,11 +176,11 @@ jobs:
- name: Set up Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
- go-version: '>=1.25.1'
+ go-version: 1.26.5
- name: Build Backend
run: |
go mod download
- go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
+ go build -trimpath -buildvcs=true -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe
- name: Generate checksums
run: sha256sum new-api-*.exe > checksums-windows.txt
diff --git a/.gitignore b/.gitignore
index c3afceb021f1..0250c5e88643 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,6 +36,7 @@ data/
token_estimator_test.go
skills-lock.json
.playwright-mcp
+artifacts/
# Local-only live probes and scratch test workspaces.
.local-tests/
diff --git a/Dockerfile b/Dockerfile
index e2788f55b2bd..402e98bc35f8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -7,7 +7,9 @@ COPY web/classic/package.json ./classic/package.json
RUN bun install --frozen-lockfile
COPY ./web/default ./default
COPY ./VERSION /build/VERSION
-RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
+RUN test -s /build/VERSION \
+ && cd default \
+ && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
@@ -15,12 +17,14 @@ WORKDIR /build/web
COPY web/package.json web/bun.lock ./
COPY web/default/package.json ./default/package.json
COPY web/classic/package.json ./classic/package.json
-RUN bun install --filter ./classic --frozen-lockfile
+RUN bun install --frozen-lockfile
COPY ./web/classic ./classic
COPY ./VERSION /build/VERSION
-RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
+RUN test -s /build/VERSION \
+ && cd classic \
+ && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
-FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
+FROM golang:1.26.5-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS builder2
ENV GO111MODULE=on CGO_ENABLED=0
ARG TARGETOS
@@ -36,7 +40,8 @@ RUN go mod download
COPY . .
COPY --from=builder /build/web/default/dist ./web/default/dist
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
-RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
+RUN test -s VERSION \
+ && go build -trimpath -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
diff --git a/Dockerfile.backend b/Dockerfile.backend
new file mode 100644
index 000000000000..063be25e9f25
--- /dev/null
+++ b/Dockerfile.backend
@@ -0,0 +1,39 @@
+# Backend-only image: builds without embedding frontend assets.
+# Requires go build tag frontend_external and runtime FRONTEND_MODE=disabled|redirect.
+
+FROM golang:1.26.5-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS builder
+
+ENV GO111MODULE=on CGO_ENABLED=0
+ARG TARGETOS
+ARG TARGETARCH
+ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64}
+ENV GOEXPERIMENT=greenteagc
+
+WORKDIR /build
+
+ADD go.mod go.sum ./
+RUN go mod download
+
+COPY . .
+# Pure backend build: do not copy or synthesize web/*/dist; frontend_external excludes embed.
+RUN test -s VERSION \
+ && go build -trimpath -buildvcs=true -tags frontend_external \
+ -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" \
+ -o new-api
+
+FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \
+ && rm -rf /var/lib/apt/lists/* \
+ && update-ca-certificates
+
+COPY --from=builder /build/new-api /
+COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
+
+# Default pure-backend delivery; override to redirect when a separate console origin is used.
+ENV FRONTEND_MODE=disabled
+
+EXPOSE 3000
+WORKDIR /data
+ENTRYPOINT ["/new-api"]
diff --git a/Dockerfile.dev b/Dockerfile.dev
index 81c221bf113c..72c78821084f 100644
--- a/Dockerfile.dev
+++ b/Dockerfile.dev
@@ -1,7 +1,8 @@
-# Backend-only build for frontend development
-# Skips frontend build, uses a placeholder for //go:embed web/dist
+# Backend-only build for frontend development.
+# Uses frontend_external so web/*/dist is not required or embedded.
+# Pair with bun web dev server (API proxied to :3000) or FRONTEND_MODE=redirect.
-FROM golang:1.26.1-alpine AS builder
+FROM golang:1.26.5-alpine@sha256:0178a641fbb4858c5f1b48e34bdaabe0350a330a1b1149aabd498d0699ff5fb2 AS builder
ENV GO111MODULE=on CGO_ENABLED=0
ARG TARGETOS
@@ -16,13 +17,12 @@ RUN go mod download
COPY . .
-RUN mkdir -p web/default/dist web/classic/dist && \
- echo '
devuse frontend dev server' > web/default/dist/index.html && \
- echo 'devuse frontend dev server' > web/classic/dist/index.html
+RUN test -s VERSION \
+ && go build -trimpath -buildvcs=true -tags frontend_external \
+ -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" \
+ -o new-api
-RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
-
-FROM debian:bookworm-slim
+FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates tzdata wget \
@@ -31,6 +31,9 @@ RUN apt-get update \
COPY --from=builder /build/new-api /
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
+
+ENV FRONTEND_MODE=disabled
+
EXPOSE 3000
WORKDIR /data
ENTRYPOINT ["/new-api"]
diff --git a/README.md b/README.md
index 65e3facdb24e..165c399a3264 100644
--- a/README.md
+++ b/README.md
@@ -319,8 +319,8 @@ docker run --name new-api -d --restart always \
| `REDIS_CONN_STRING` | Redis connection string | - |
| `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` |
| `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` |
+| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `128` |
+| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `128` |
| `AZURE_DEFAULT_API_VERSION` | Azure API version | `2025-04-01-preview` |
| `ERROR_LOG_ENABLED` | Error log switch | `false` |
| `PYROSCOPE_URL` | Pyroscope server address | - |
diff --git a/VERSION b/VERSION
index e69de29bb2d1..ffbebf54dddd 100644
--- a/VERSION
+++ b/VERSION
@@ -0,0 +1 @@
+v1.0.0-rc.12-dev
diff --git a/common/constants.go b/common/constants.go
index 87d212f99732..96a983fbc1c8 100644
--- a/common/constants.go
+++ b/common/constants.go
@@ -22,7 +22,7 @@ var TopUpLink = ""
var themeValue atomic.Value // stores string; safe for concurrent read/write
func init() {
- themeValue.Store("classic")
+ themeValue.Store("default")
}
func GetTheme() string {
@@ -186,6 +186,10 @@ var BatchUpdateInterval int
var RelayTimeout int // unit is second
var RelayIdleConnTimeout int // unit is second
+var RelayDialTimeout int
+var RelayTLSHandshakeTimeout int
+var RelayResponseHeaderTimeout int
+var RelayExpectContinueTimeout int
var RelayMaxIdleConns int
var RelayMaxIdleConnsPerHost int
diff --git a/common/custom-event.go b/common/custom-event.go
index 1bea2fd72b17..8e25ade4bba9 100644
--- a/common/custom-event.go
+++ b/common/custom-event.go
@@ -9,7 +9,6 @@ import (
"io"
"net/http"
"strings"
- "sync"
)
type stringWriter interface {
@@ -53,8 +52,6 @@ type CustomEvent struct {
Id string
Retry uint
Data interface{}
-
- Mutex sync.Mutex
}
func encode(writer io.Writer, event CustomEvent) error {
@@ -63,9 +60,13 @@ func encode(writer io.Writer, event CustomEvent) error {
}
func writeData(w stringWriter, data interface{}) error {
- dataReplacer.WriteString(w, fmt.Sprint(data))
- if strings.HasPrefix(data.(string), "data") {
- w.writeString("\n\n")
+ value := fmt.Sprint(data)
+ if _, err := dataReplacer.WriteString(w, value); err != nil {
+ return err
+ }
+ if strings.HasPrefix(value, "data") {
+ _, err := w.writeString("\n\n")
+ return err
}
return nil
}
@@ -76,8 +77,6 @@ func (r CustomEvent) Render(w http.ResponseWriter) error {
}
func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
- r.Mutex.Lock()
- defer r.Mutex.Unlock()
header := w.Header()
header["Content-Type"] = writeContentType
diff --git a/common/email_test.go b/common/email_test.go
index 47916fdfb995..7ae45ae3d70a 100644
--- a/common/email_test.go
+++ b/common/email_test.go
@@ -364,7 +364,7 @@ func TestSMTPPlainAuthRejectsRemotePlaintextConnection(t *testing.T) {
SMTPFrom = "sender@example.com"
SMTPToken = "secret"
- conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", server.host, server.port))
+ conn, err := net.Dial("tcp", net.JoinHostPort(server.host, strconv.Itoa(server.port)))
require.NoError(t, err)
client, err := smtp.NewClient(conn, SMTPServer)
require.NoError(t, err)
diff --git a/common/env.go b/common/env.go
index 1aa340f85ea1..2ee085b6f7aa 100644
--- a/common/env.go
+++ b/common/env.go
@@ -36,3 +36,15 @@ func GetEnvOrDefaultBool(env string, defaultValue bool) bool {
}
return b
}
+
+func GetEnvOrDefaultFloat(env string, defaultValue float64) float64 {
+ if env == "" || os.Getenv(env) == "" {
+ return defaultValue
+ }
+ f, err := strconv.ParseFloat(os.Getenv(env), 64)
+ if err != nil {
+ SysError(fmt.Sprintf("failed to parse %s: %s, using default value: %.2f", env, err.Error(), defaultValue))
+ return defaultValue
+ }
+ return f
+}
diff --git a/common/http_client.go b/common/http_client.go
new file mode 100644
index 000000000000..bb6b5b0efe0b
--- /dev/null
+++ b/common/http_client.go
@@ -0,0 +1,39 @@
+package common
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+type DialContextFunc func(context.Context, string, string) (net.Conn, error)
+
+// NewOutboundHTTPTransport applies the shared connection lifecycle policy.
+// ResponseHeaderTimeout bounds an upstream that never responds while leaving
+// response-body streaming governed by request context and streaming timeouts.
+func NewOutboundHTTPTransport(proxy func(*http.Request) (*url.URL, error), dialContext DialContextFunc) *http.Transport {
+ if dialContext == nil {
+ dialer := &net.Dialer{
+ Timeout: time.Duration(RelayDialTimeout) * time.Second,
+ KeepAlive: 30 * time.Second,
+ }
+ dialContext = dialer.DialContext
+ }
+ transport := &http.Transport{
+ Proxy: proxy,
+ DialContext: dialContext,
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: RelayMaxIdleConns,
+ MaxIdleConnsPerHost: RelayMaxIdleConnsPerHost,
+ IdleConnTimeout: time.Duration(RelayIdleConnTimeout) * time.Second,
+ TLSHandshakeTimeout: time.Duration(RelayTLSHandshakeTimeout) * time.Second,
+ ResponseHeaderTimeout: time.Duration(RelayResponseHeaderTimeout) * time.Second,
+ ExpectContinueTimeout: time.Duration(RelayExpectContinueTimeout) * time.Second,
+ }
+ if TLSInsecureSkipVerify {
+ transport.TLSClientConfig = InsecureTLSConfig.Clone()
+ }
+ return transport
+}
diff --git a/common/http_client_test.go b/common/http_client_test.go
new file mode 100644
index 000000000000..9fd60b902ea2
--- /dev/null
+++ b/common/http_client_test.go
@@ -0,0 +1,99 @@
+package common
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewOutboundHTTPTransportUsesLifecycleTimeouts(t *testing.T) {
+ previousDial := RelayDialTimeout
+ previousTLS := RelayTLSHandshakeTimeout
+ previousHeader := RelayResponseHeaderTimeout
+ previousExpect := RelayExpectContinueTimeout
+ previousIdle := RelayIdleConnTimeout
+ previousMaxIdle := RelayMaxIdleConns
+ previousMaxIdleHost := RelayMaxIdleConnsPerHost
+ t.Cleanup(func() {
+ RelayDialTimeout = previousDial
+ RelayTLSHandshakeTimeout = previousTLS
+ RelayResponseHeaderTimeout = previousHeader
+ RelayExpectContinueTimeout = previousExpect
+ RelayIdleConnTimeout = previousIdle
+ RelayMaxIdleConns = previousMaxIdle
+ RelayMaxIdleConnsPerHost = previousMaxIdleHost
+ })
+
+ RelayDialTimeout = 7
+ RelayTLSHandshakeTimeout = 8
+ RelayResponseHeaderTimeout = 9
+ RelayExpectContinueTimeout = 2
+ RelayIdleConnTimeout = 90
+ RelayMaxIdleConns = 200
+ RelayMaxIdleConnsPerHost = 50
+
+ transport := NewOutboundHTTPTransport(http.ProxyFromEnvironment, nil)
+ require.Equal(t, 8*time.Second, transport.TLSHandshakeTimeout)
+ require.Equal(t, 9*time.Second, transport.ResponseHeaderTimeout)
+ require.Equal(t, 2*time.Second, transport.ExpectContinueTimeout)
+ require.Equal(t, 90*time.Second, transport.IdleConnTimeout)
+ require.Equal(t, 200, transport.MaxIdleConns)
+ require.Equal(t, 50, transport.MaxIdleConnsPerHost)
+ require.NotNil(t, transport.DialContext)
+}
+
+func TestOutboundTransportTimesOutWaitingForResponseHeaders(t *testing.T) {
+ previousHeader := RelayResponseHeaderTimeout
+ previousDial := RelayDialTimeout
+ RelayResponseHeaderTimeout = 1
+ RelayDialTimeout = 2
+ t.Cleanup(func() {
+ RelayResponseHeaderTimeout = previousHeader
+ RelayDialTimeout = previousDial
+ })
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ time.Sleep(1500 * time.Millisecond)
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+
+ client := &http.Client{Transport: NewOutboundHTTPTransport(nil, nil)}
+ started := time.Now()
+ _, err := client.Get(server.URL)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "timeout awaiting response headers")
+ require.Less(t, time.Since(started), 1400*time.Millisecond)
+}
+
+func TestOutboundTransportHonorsRequestCancellation(t *testing.T) {
+ previousHeader := RelayResponseHeaderTimeout
+ RelayResponseHeaderTimeout = 0
+ t.Cleanup(func() { RelayResponseHeaderTimeout = previousHeader })
+
+ started := make(chan struct{})
+ server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
+ close(started)
+ <-request.Context().Done()
+ }))
+ defer server.Close()
+
+ ctx, cancel := context.WithCancel(context.Background())
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
+ require.NoError(t, err)
+ result := make(chan error, 1)
+ client := &http.Client{Transport: NewOutboundHTTPTransport(nil, nil)}
+ go func() {
+ _, requestErr := client.Do(request)
+ result <- requestErr
+ }()
+ <-started
+ cancel()
+ require.ErrorIs(t, <-result, context.Canceled)
+ require.True(t, errors.Is(ctx.Err(), context.Canceled))
+}
diff --git a/common/init.go b/common/init.go
index 88b2dc3e62e1..dadd289aee8b 100644
--- a/common/init.go
+++ b/common/init.go
@@ -108,6 +108,10 @@ func InitEnv() {
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
RelayIdleConnTimeout = GetEnvOrDefault("RELAY_IDLE_CONN_TIMEOUT", 90)
+ RelayDialTimeout = GetEnvOrDefault("RELAY_DIAL_TIMEOUT", 10)
+ RelayTLSHandshakeTimeout = GetEnvOrDefault("RELAY_TLS_HANDSHAKE_TIMEOUT", 10)
+ RelayResponseHeaderTimeout = GetEnvOrDefault("RELAY_RESPONSE_HEADER_TIMEOUT", 120)
+ RelayExpectContinueTimeout = GetEnvOrDefault("RELAY_EXPECT_CONTINUE_TIMEOUT", 1)
RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)
@@ -185,4 +189,13 @@ func initConstantEnv() {
}
}
constant.TrustedRedirectDomains = trustedDomains
+
+ // Adaptive channel balance
+ constant.AdaptiveBalanceEnabled = GetEnvOrDefaultBool("ADAPTIVE_BALANCE_ENABLED", false)
+ constant.AdaptiveBalanceShadowMode = GetEnvOrDefaultBool("ADAPTIVE_BALANCE_SHADOW_MODE", false)
+ constant.ChannelCircuitBreakerEnabled = GetEnvOrDefaultBool("CHANNEL_CIRCUIT_BREAKER_ENABLED", false)
+ constant.MaxRetryChannels = GetEnvOrDefault("MAX_RETRY_CHANNELS", 3)
+ constant.ChannelCooldownSeconds = GetEnvOrDefault("CHANNEL_COOLDOWN_SECONDS", 30)
+ constant.EwmaAlpha = GetEnvOrDefaultFloat("EWMA_ALPHA", 0.1)
+ constant.MaxChannelConcurrency = GetEnvOrDefault("MAX_CHANNEL_CONCURRENCY", 10)
}
diff --git a/common/quota.go b/common/quota.go
index dfd65d273ee5..0270606454be 100644
--- a/common/quota.go
+++ b/common/quota.go
@@ -1,5 +1,15 @@
package common
+import "os"
+
+// GetTrustQuota returns the balance threshold above which pre-consume may be
+// skipped. Disabled by default — concurrent settle can overdraft without a
+// floor on the trust path. Opt in with TRUST_PRECONSUME_ENABLED=true|1.
func GetTrustQuota() int {
- return int(10 * QuotaPerUnit)
+ switch os.Getenv("TRUST_PRECONSUME_ENABLED") {
+ case "1", "true", "TRUE", "yes", "on":
+ return int(10 * QuotaPerUnit)
+ default:
+ return 0
+ }
}
diff --git a/constant/context_key.go b/constant/context_key.go
index b856bc3dda14..de9c309b4786 100644
--- a/constant/context_key.go
+++ b/constant/context_key.go
@@ -72,4 +72,9 @@ const (
// fallback in authHelper (finishAdminAudit) skips its record to avoid
// duplicate entries.
ContextKeyAuditLogged ContextKey = "audit_logged"
+
+ // ContextKeyThreadId / ContextKeyTraceId hold AxonHub-compatible
+ // conversation observability IDs (AH-Thread-Id / AH-Trace-Id).
+ ContextKeyThreadId ContextKey = "thread_id"
+ ContextKeyTraceId ContextKey = "trace_id"
)
diff --git a/constant/env.go b/constant/env.go
index 512bfc31126b..0dfd794bb590 100644
--- a/constant/env.go
+++ b/constant/env.go
@@ -25,3 +25,12 @@ var TaskPricePatches []string
// TrustedRedirectDomains is a list of trusted domains for redirect URL validation.
// Domains support subdomain matching (e.g., "example.com" matches "sub.example.com").
var TrustedRedirectDomains []string
+
+// Adaptive channel balance settings
+var AdaptiveBalanceEnabled bool
+var AdaptiveBalanceShadowMode bool
+var ChannelCircuitBreakerEnabled bool
+var MaxRetryChannels int
+var ChannelCooldownSeconds int
+var EwmaAlpha float64
+var MaxChannelConcurrency int
diff --git a/controller/channel-test.go b/controller/channel-test.go
index 4ba3698bd54c..1e99775d0fee 100644
--- a/controller/channel-test.go
+++ b/controller/channel-test.go
@@ -20,6 +20,7 @@ import (
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/pkg/billingexpr"
+ perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/relay"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
@@ -39,6 +40,12 @@ type testResult struct {
context *gin.Context
localErr error
newAPIError *types.NewAPIError
+ // Probe data for perf_metrics (populated on success).
+ relayInfo *relaycommon.RelayInfo
+ usage *dto.Usage
+ latencyMs int64
+ outputTokens int64
+ testModel string
}
func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointType string) string {
@@ -52,9 +59,156 @@ func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointTyp
if channel != nil && channel.Type == constant.ChannelTypeCodex {
return string(constant.EndpointTypeOpenAIResponse)
}
+ // Infer non-chat endpoints so auto-test does not force image/audio models through /chat/completions.
+ if kind := detectProbeModelKind(modelName); kind != "" {
+ return kind
+ }
+ if channel != nil && channel.Type == constant.ChannelTypeMokaAI {
+ return string(constant.EndpointTypeEmbeddings)
+ }
+ if channel != nil && channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(strings.ToLower(modelName), "seedream") {
+ return string(constant.EndpointTypeImageGeneration)
+ }
return normalized
}
+// detectProbeModelKind returns a constant.EndpointType string for known non-chat models.
+// Empty string means default chat completions path.
+func detectProbeModelKind(modelName string) string {
+ name := strings.ToLower(strings.TrimSpace(modelName))
+ if name == "" {
+ return ""
+ }
+ if strings.HasSuffix(name, ratio_setting.CompactModelSuffix) {
+ return string(constant.EndpointTypeOpenAIResponseCompact)
+ }
+ if strings.Contains(name, "codex") {
+ return string(constant.EndpointTypeOpenAIResponse)
+ }
+ if strings.Contains(name, "rerank") {
+ return string(constant.EndpointTypeJinaRerank)
+ }
+ if strings.Contains(name, "embedding") ||
+ strings.Contains(name, "embed") ||
+ strings.HasPrefix(name, "m3e") ||
+ strings.Contains(name, "bge-") ||
+ strings.Contains(name, "text-embedding") {
+ return string(constant.EndpointTypeEmbeddings)
+ }
+ // Image generation / edit models — never chat-test these (#6121).
+ if isImageProbeModel(name) {
+ return string(constant.EndpointTypeImageGeneration)
+ }
+ return ""
+}
+
+func isImageProbeModel(name string) bool {
+ name = strings.ToLower(strings.TrimSpace(name))
+ if name == "" {
+ return false
+ }
+ imageHints := []string{
+ "gpt-image", "dall-e", "dalle", "seedream", "flux", "imagen",
+ "stable-diffusion", "sdxl", "midjourney", "mj-", "image-gen",
+ "text-to-image", "t2i", "cogview", "kolors", "playground-v",
+ }
+ for _, h := range imageHints {
+ if strings.Contains(name, h) {
+ return true
+ }
+ }
+ // Bare suffixes like "*-image" / "image-*" when not embedding/vision chat.
+ if strings.Contains(name, "image") &&
+ !strings.Contains(name, "vision") &&
+ !strings.Contains(name, "chat") &&
+ !strings.Contains(name, "embedding") {
+ return true
+ }
+ return false
+}
+
+func isAudioOrVideoProbeModel(name string) bool {
+ name = strings.ToLower(strings.TrimSpace(name))
+ if name == "" {
+ return false
+ }
+ hints := []string{
+ "whisper", "tts-", "tts_", "-tts", "speech", "audio-", "-audio",
+ "sora", "kling", "runway", "luma", "hailuo", "vidu", "cogvideo",
+ "text-to-video", "t2v", "minimax-video",
+ }
+ for _, h := range hints {
+ if strings.Contains(name, h) {
+ return true
+ }
+ }
+ return false
+}
+
+// isChatCapableProbeModel reports whether auto-test can safely use chat completions.
+func isChatCapableProbeModel(name string) bool {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return false
+ }
+ kind := detectProbeModelKind(name)
+ if kind == string(constant.EndpointTypeImageGeneration) ||
+ kind == string(constant.EndpointTypeEmbeddings) ||
+ kind == string(constant.EndpointTypeJinaRerank) {
+ return false
+ }
+ if isAudioOrVideoProbeModel(name) {
+ return false
+ }
+ return true
+}
+
+// pickAutoTestModel chooses a chat-capable model for batch auto-test.
+// Prefers channel.TestModel when chat-capable; otherwise first chat-capable model
+// in the channel list. Empty means skip auto probe for this channel.
+func pickAutoTestModel(channel *model.Channel) string {
+ if channel == nil {
+ return ""
+ }
+ if channel.TestModel != nil {
+ if name := strings.TrimSpace(*channel.TestModel); name != "" && isChatCapableProbeModel(name) {
+ return name
+ }
+ }
+ for _, m := range channel.GetModels() {
+ if name := strings.TrimSpace(m); name != "" && isChatCapableProbeModel(name) {
+ return name
+ }
+ }
+ return ""
+}
+
+func shouldSkipAutoChannelTest(channel *model.Channel) bool {
+ if channel == nil {
+ return true
+ }
+ if channel.Status == common.ChannelStatusManuallyDisabled {
+ return true
+ }
+ if channel.GetSetting().SkipAutoTest {
+ return true
+ }
+ // Channel types without chat/completion test support.
+ unsupported := []int{
+ constant.ChannelTypeMidjourney,
+ constant.ChannelTypeMidjourneyPlus,
+ constant.ChannelTypeSunoAPI,
+ constant.ChannelTypeKling,
+ constant.ChannelTypeJimeng,
+ constant.ChannelTypeDoubaoVideo,
+ constant.ChannelTypeVidu,
+ }
+ if lo.Contains(unsupported, channel.Type) {
+ return true
+ }
+ return false
+}
+
func resolveChannelTestUserID(c *gin.Context) (int, error) {
if c != nil {
if userID := c.GetInt("id"); userID > 0 {
@@ -120,34 +274,29 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
requestPath = endpointInfo.Path
}
} else {
- // 如果没有指定端点类型,使用原有的自动检测逻辑
-
- if strings.Contains(strings.ToLower(testModel), "rerank") {
- requestPath = "/v1/rerank"
- }
-
- // 先判断是否为 Embedding 模型
- if strings.Contains(strings.ToLower(testModel), "embedding") ||
- strings.HasPrefix(testModel, "m3e") || // m3e 系列模型
- strings.Contains(testModel, "bge-") || // bge 系列模型
- strings.Contains(testModel, "embed") ||
- channel.Type == constant.ChannelTypeMokaAI { // 其他 embedding 模型
- requestPath = "/v1/embeddings" // 修改请求路径
+ // 如果没有指定端点类型,使用统一的模型种类检测
+ kind := detectProbeModelKind(testModel)
+ if kind == "" && channel.Type == constant.ChannelTypeMokaAI {
+ kind = string(constant.EndpointTypeEmbeddings)
}
-
- // VolcEngine 图像生成模型
- if channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(testModel, "seedream") {
- requestPath = "/v1/images/generations"
- }
-
- // responses-only models
- if strings.Contains(strings.ToLower(testModel), "codex") {
- requestPath = "/v1/responses"
+ if kind == "" && channel.Type == constant.ChannelTypeVolcEngine && strings.Contains(strings.ToLower(testModel), "seedream") {
+ kind = string(constant.EndpointTypeImageGeneration)
}
-
- // responses compaction models (must use /v1/responses/compact)
- if strings.HasSuffix(testModel, ratio_setting.CompactModelSuffix) {
- requestPath = "/v1/responses/compact"
+ if endpointInfo, ok := common.GetDefaultEndpointInfo(constant.EndpointType(kind)); ok {
+ requestPath = endpointInfo.Path
+ } else {
+ switch kind {
+ case string(constant.EndpointTypeJinaRerank):
+ requestPath = "/v1/rerank"
+ case string(constant.EndpointTypeEmbeddings):
+ requestPath = "/v1/embeddings"
+ case string(constant.EndpointTypeImageGeneration):
+ requestPath = "/v1/images/generations"
+ case string(constant.EndpointTypeOpenAIResponse):
+ requestPath = "/v1/responses"
+ case string(constant.EndpointTypeOpenAIResponseCompact):
+ requestPath = "/v1/responses/compact"
+ }
}
}
if strings.HasPrefix(requestPath, "/v1/responses/compact") {
@@ -512,9 +661,14 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
})
common.SysLog(fmt.Sprintf("testing channel #%d, response: \n%s", channel.Id, string(respBody)))
return testResult{
- context: c,
- localErr: nil,
- newAPIError: nil,
+ context: c,
+ localErr: nil,
+ newAPIError: nil,
+ relayInfo: info,
+ usage: usage,
+ latencyMs: milliseconds,
+ outputTokens: int64(usage.CompletionTokens),
+ testModel: info.OriginModelName,
}
}
@@ -757,37 +911,33 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel,
}
}
- // 自动检测逻辑(保持原有行为)
- if strings.Contains(strings.ToLower(model), "rerank") {
+ // 自动检测逻辑(与 detectProbeModelKind / normalizeChannelTestEndpoint 对齐)
+ switch detectProbeModelKind(model) {
+ case string(constant.EndpointTypeJinaRerank):
return &dto.RerankRequest{
Model: model,
Query: "What is Deep Learning?",
Documents: []any{"Deep Learning is a subset of machine learning.", "Machine learning is a field of artificial intelligence."},
TopN: lo.ToPtr(2),
}
- }
-
- // 先判断是否为 Embedding 模型
- if strings.Contains(strings.ToLower(model), "embedding") ||
- strings.HasPrefix(model, "m3e") ||
- strings.Contains(model, "bge-") {
- // 返回 EmbeddingRequest
+ case string(constant.EndpointTypeEmbeddings):
return &dto.EmbeddingRequest{
Model: model,
Input: []any{"hello world"},
}
- }
-
- // Responses compaction models (must use /v1/responses/compact)
- if strings.HasSuffix(model, ratio_setting.CompactModelSuffix) {
+ case string(constant.EndpointTypeImageGeneration):
+ return &dto.ImageRequest{
+ Model: model,
+ Prompt: "a cute cat",
+ N: lo.ToPtr(uint(1)),
+ Size: "1024x1024",
+ }
+ case string(constant.EndpointTypeOpenAIResponseCompact):
return &dto.OpenAIResponsesCompactionRequest{
Model: model,
Input: testResponsesInput,
}
- }
-
- // Responses-only models (e.g. codex series)
- if strings.Contains(strings.ToLower(model), "codex") {
+ case string(constant.EndpointTypeOpenAIResponse):
return &dto.OpenAIResponsesRequest{
Model: model,
Input: json.RawMessage(`[{"role":"user","content":"hi"}]`),
@@ -825,6 +975,52 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel,
return testRequest
}
+func resolveProbeModelName(result testResult, channel *model.Channel, requested string) string {
+ if result.testModel != "" {
+ return result.testModel
+ }
+ if requested = strings.TrimSpace(requested); requested != "" {
+ return requested
+ }
+ if channel != nil && channel.TestModel != nil {
+ if name := strings.TrimSpace(*channel.TestModel); name != "" {
+ return name
+ }
+ }
+ if channel != nil {
+ models := channel.GetModels()
+ if len(models) > 0 {
+ if name := strings.TrimSpace(models[0]); name != "" {
+ return name
+ }
+ }
+ }
+ return ""
+}
+
+func recordChannelProbeMetric(result testResult, channel *model.Channel, requested string, latencyMs int64) {
+ modelName := resolveProbeModelName(result, channel, requested)
+ if modelName == "" {
+ return
+ }
+ probeGroup := "probe"
+ if result.relayInfo != nil && result.relayInfo.UsingGroup != "" {
+ probeGroup = result.relayInfo.UsingGroup
+ }
+ generationMs := int64(0)
+ if result.outputTokens > 0 && latencyMs > 0 {
+ generationMs = latencyMs
+ }
+ perfmetrics.Record(perfmetrics.Sample{
+ Model: modelName,
+ Group: probeGroup,
+ LatencyMs: latencyMs,
+ Success: result.localErr == nil && result.newAPIError == nil,
+ OutputTokens: result.outputTokens,
+ GenerationMs: generationMs,
+ })
+}
+
func TestChannel(c *gin.Context) {
channelId, err := strconv.Atoi(c.Param("id"))
if err != nil {
@@ -858,11 +1054,15 @@ func TestChannel(c *gin.Context) {
requestCtx = c.Request.Context()
}
result := testChannel(requestCtx, channel, testUserID, testModel, endpointType, isStream)
+ tok := time.Now()
+ milliseconds := tok.Sub(tik).Milliseconds()
+ // Always record success and failure probes so success_rate stays honest.
+ go recordChannelProbeMetric(result, channel, testModel, milliseconds)
if result.localErr != nil {
resp := gin.H{
"success": false,
"message": result.localErr.Error(),
- "time": 0.0,
+ "time": float64(milliseconds) / 1000.0,
}
if result.newAPIError != nil {
resp["error_code"] = result.newAPIError.GetErrorCode()
@@ -870,10 +1070,9 @@ func TestChannel(c *gin.Context) {
c.JSON(http.StatusOK, resp)
return
}
- tok := time.Now()
- milliseconds := tok.Sub(tik).Milliseconds()
go channel.UpdateResponseTime(milliseconds)
consumedTime := float64(milliseconds) / 1000.0
+
if result.newAPIError != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -922,9 +1121,18 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse
if channel.Status == common.ChannelStatusManuallyDisabled {
continue
}
+ if shouldSkipAutoChannelTest(channel) {
+ continue
+ }
+ // Only auto-probe chat-capable models so image/audio/video do not pollute perf_metrics.
+ autoModel := pickAutoTestModel(channel)
+ if autoModel == "" {
+ common.SysLog(fmt.Sprintf("skip auto test channel %d (%s): no chat-capable test model", channel.Id, channel.Name))
+ continue
+ }
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
tik := time.Now()
- result := testChannel(ctx, channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
+ result := testChannel(ctx, channel, testUserID, autoModel, "", shouldUseStreamForAutomaticChannelTest(channel))
tok := time.Now()
milliseconds := tok.Sub(tik).Milliseconds()
if ctx != nil && ctx.Err() != nil {
@@ -968,6 +1176,8 @@ func performChannelTests(ctx context.Context, channels []*model.Channel, testUse
}
channel.UpdateResponseTime(milliseconds)
+ // Record success and failure probes for model-square health badges.
+ recordChannelProbeMetric(result, channel, autoModel, milliseconds)
if common.RequestInterval > 0 {
if ctx == nil {
time.Sleep(common.RequestInterval)
diff --git a/controller/channel.go b/controller/channel.go
index a2b5687ab319..1ef3a9acf5e7 100644
--- a/controller/channel.go
+++ b/controller/channel.go
@@ -233,7 +233,7 @@ func FetchUpstreamModels(c *gin.Context) {
return
}
- ids, err := fetchChannelUpstreamModelIDs(channel)
+ ids, err := fetchChannelUpstreamModelIDs(c.Request.Context(), channel)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -870,6 +870,12 @@ type ChannelBatch struct {
Tag *string `json:"tag"`
}
+// ChannelSkipAutoTestBatch toggles skip_auto_test on channel settings.
+type ChannelSkipAutoTestBatch struct {
+ Ids []int `json:"ids"`
+ Skip bool `json:"skip"`
+}
+
func DeleteChannelBatch(c *gin.Context) {
channelBatch := ChannelBatch{}
err := c.ShouldBindJSON(&channelBatch)
@@ -1181,7 +1187,7 @@ func FetchModels(c *gin.Context) {
key = strings.Split(key, "\n")[0]
if req.Type == constant.ChannelTypeOllama {
- models, err := ollama.FetchOllamaModels(baseURL, key)
+ models, err := ollama.FetchOllamaModels(c.Request.Context(), baseURL, key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
@@ -1219,10 +1225,10 @@ func FetchModels(c *gin.Context) {
return
}
- client := &http.Client{}
+ client := service.GetHttpClientWithTimeout(30 * time.Second)
url := fmt.Sprintf("%s/v1/models", baseURL)
- request, err := http.NewRequest("GET", url, nil)
+ request, err := http.NewRequestWithContext(c.Request.Context(), "GET", url, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
@@ -1303,6 +1309,48 @@ func BatchSetChannelTag(c *gin.Context) {
return
}
+// BatchSetChannelSkipAutoTest sets skip_auto_test on selected channels.
+// Manual channel tests remain available; only AutomaticallyTestChannels is gated.
+func BatchSetChannelSkipAutoTest(c *gin.Context) {
+ req := ChannelSkipAutoTestBatch{}
+ if err := c.ShouldBindJSON(&req); err != nil || len(req.Ids) == 0 {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": "invalid parameters",
+ })
+ return
+ }
+ updated := 0
+ for _, id := range req.Ids {
+ channel, err := model.GetChannelById(id, true)
+ if err != nil || channel == nil {
+ continue
+ }
+ setting := channel.GetSetting()
+ if setting.SkipAutoTest == req.Skip {
+ updated++
+ continue
+ }
+ setting.SkipAutoTest = req.Skip
+ channel.SetSetting(setting)
+ if err := channel.Update(); err != nil {
+ common.SysLog(fmt.Sprintf("batch skip_auto_test update failed id=%d: %v", id, err))
+ continue
+ }
+ updated++
+ }
+ model.InitChannelCache()
+ recordManageAudit(c, "channel.skip_auto_test_batch", map[string]interface{}{
+ "count": updated,
+ "skip": req.Skip,
+ })
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "",
+ "data": updated,
+ })
+}
+
func GetTagModels(c *gin.Context) {
tag := c.Query("tag")
if tag == "" {
@@ -1958,7 +2006,7 @@ func OllamaPullModel(c *gin.Context) {
}
key := strings.Split(channel.Key, "\n")[0]
- err = ollama.PullOllamaModel(baseURL, key, req.ModelName)
+ err = ollama.PullOllamaModel(c.Request.Context(), baseURL, key, req.ModelName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
@@ -2036,7 +2084,7 @@ func OllamaPullModelStream(c *gin.Context) {
}
// 执行拉取
- err = ollama.PullOllamaModelStream(baseURL, key, req.ModelName, progressCallback)
+ err = ollama.PullOllamaModelStream(c.Request.Context(), baseURL, key, req.ModelName, progressCallback)
if err != nil {
errorData, _ := json.Marshal(gin.H{
@@ -2103,7 +2151,7 @@ func OllamaDeleteModel(c *gin.Context) {
}
key := strings.Split(channel.Key, "\n")[0]
- err = ollama.DeleteOllamaModel(baseURL, key, req.ModelName)
+ err = ollama.DeleteOllamaModel(c.Request.Context(), baseURL, key, req.ModelName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
@@ -2152,7 +2200,7 @@ func OllamaVersion(c *gin.Context) {
}
key := strings.Split(channel.Key, "\n")[0]
- version, err := ollama.FetchOllamaVersion(baseURL, key)
+ version, err := ollama.FetchOllamaVersion(c.Request.Context(), baseURL, key)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
diff --git a/controller/channel_auto_test_helpers_test.go b/controller/channel_auto_test_helpers_test.go
new file mode 100644
index 000000000000..c88e17055980
--- /dev/null
+++ b/controller/channel_auto_test_helpers_test.go
@@ -0,0 +1,93 @@
+package controller
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/dto"
+ "github.com/QuantumNous/new-api/model"
+)
+
+func TestDetectProbeModelKind(t *testing.T) {
+ cases := []struct {
+ model string
+ want string
+ }{
+ {"gpt-image-2", string(constant.EndpointTypeImageGeneration)},
+ {"dall-e-3", string(constant.EndpointTypeImageGeneration)},
+ {"seedream-3.0", string(constant.EndpointTypeImageGeneration)},
+ {"text-embedding-3-small", string(constant.EndpointTypeEmbeddings)},
+ {"bge-m3", string(constant.EndpointTypeEmbeddings)},
+ {"jina-rerank-v2", string(constant.EndpointTypeJinaRerank)},
+ {"gpt-5-codex", string(constant.EndpointTypeOpenAIResponse)},
+ {"gpt-4o-mini", ""},
+ {"claude-sonnet-4", ""},
+ }
+ for _, tc := range cases {
+ if got := detectProbeModelKind(tc.model); got != tc.want {
+ t.Fatalf("detectProbeModelKind(%q)=%q want %q", tc.model, got, tc.want)
+ }
+ }
+}
+
+func TestIsChatCapableProbeModel(t *testing.T) {
+ if !isChatCapableProbeModel("gpt-4o-mini") {
+ t.Fatal("gpt-4o-mini should be chat capable")
+ }
+ for _, name := range []string{"gpt-image-2", "whisper-1", "text-embedding-3-large", "sora-2"} {
+ if isChatCapableProbeModel(name) {
+ t.Fatalf("%s should not be chat capable", name)
+ }
+ }
+}
+
+func TestPickAutoTestModel(t *testing.T) {
+ imageOnly := "gpt-image-2"
+ chat := "gpt-4o-mini"
+ ch := &model.Channel{Models: "gpt-image-2,gpt-4o-mini"}
+ if got := pickAutoTestModel(ch); got != chat {
+ t.Fatalf("pickAutoTestModel=%q want %q", got, chat)
+ }
+ ch.TestModel = &imageOnly
+ if got := pickAutoTestModel(ch); got != chat {
+ t.Fatalf("with image TestModel pick=%q want %q", got, chat)
+ }
+ ch.TestModel = &chat
+ if got := pickAutoTestModel(ch); got != chat {
+ t.Fatalf("with chat TestModel pick=%q want %q", got, chat)
+ }
+ ch.Models = "gpt-image-2,dall-e-3"
+ ch.TestModel = &imageOnly
+ if got := pickAutoTestModel(ch); got != "" {
+ t.Fatalf("image-only channel pick=%q want empty", got)
+ }
+}
+
+func TestShouldSkipAutoChannelTest(t *testing.T) {
+ ch := &model.Channel{Status: common.ChannelStatusEnabled}
+ if shouldSkipAutoChannelTest(ch) {
+ t.Fatal("enabled channel should not skip")
+ }
+ ch.Status = common.ChannelStatusManuallyDisabled
+ if !shouldSkipAutoChannelTest(ch) {
+ t.Fatal("manually disabled should skip")
+ }
+ ch.Status = common.ChannelStatusEnabled
+ ch.SetSetting(dto.ChannelSettings{SkipAutoTest: true})
+ if !shouldSkipAutoChannelTest(ch) {
+ t.Fatal("SkipAutoTest setting should skip")
+ }
+}
+
+func TestNormalizeChannelTestEndpointInfersImage(t *testing.T) {
+ if got := normalizeChannelTestEndpoint(nil, "gpt-image-2", ""); got != string(constant.EndpointTypeImageGeneration) {
+ t.Fatalf("image endpoint=%q", got)
+ }
+ if got := normalizeChannelTestEndpoint(nil, "gpt-4o-mini", ""); got != "" {
+ t.Fatalf("chat endpoint should be empty, got %q", got)
+ }
+ if got := normalizeChannelTestEndpoint(nil, "gpt-4o-mini", "openai"); got != "openai" {
+ t.Fatalf("explicit endpoint not preserved: %q", got)
+ }
+}
diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go
index 122a9f6bf9e0..83d8e728eb76 100644
--- a/controller/channel_upstream_update.go
+++ b/controller/channel_upstream_update.go
@@ -231,7 +231,7 @@ func collectPendingUpstreamModelChangesFromModels(
}
func collectPendingUpstreamModelChanges(channel *model.Channel, settings dto.ChannelOtherSettings) (pendingAddModels []string, pendingRemoveModels []string, err error) {
- upstreamModels, err := fetchChannelUpstreamModelIDs(channel)
+ upstreamModels, err := fetchChannelUpstreamModelIDs(context.Background(), channel)
if err != nil {
return nil, nil, err
}
@@ -255,7 +255,7 @@ func getUpstreamModelUpdateMinCheckIntervalSeconds() int64 {
return interval
}
-func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
+func fetchChannelUpstreamModelIDs(ctx context.Context, channel *model.Channel) ([]string, error) {
baseURL := constant.ChannelBaseURLs[channel.Type]
if channel.GetBaseURL() != "" {
baseURL = channel.GetBaseURL()
@@ -263,7 +263,7 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
if channel.Type == constant.ChannelTypeOllama {
key := strings.TrimSpace(strings.Split(channel.Key, "\n")[0])
- models, err := ollama.FetchOllamaModels(baseURL, key)
+ models, err := ollama.FetchOllamaModels(ctx, baseURL, key)
if err != nil {
return nil, err
}
diff --git a/controller/custom_oauth.go b/controller/custom_oauth.go
index 8172e29718f3..045a5956362f 100644
--- a/controller/custom_oauth.go
+++ b/controller/custom_oauth.go
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
+ "github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
@@ -176,7 +177,7 @@ func FetchCustomOAuthDiscovery(c *gin.Context) {
}
httpReq.Header.Set("Accept", "application/json")
- client := &http.Client{Timeout: 20 * time.Second}
+ client := service.GetSSRFProtectedHTTPClientWithTimeout(20 * time.Second)
resp, err := client.Do(httpReq)
if err != nil {
common.ApiErrorMsg(c, "获取 Discovery 配置失败: "+err.Error())
diff --git a/controller/group.go b/controller/group.go
index 6ba339a3f9bd..10ae1f57f478 100644
--- a/controller/group.go
+++ b/controller/group.go
@@ -27,6 +27,13 @@ func GetUserGroups(c *gin.Context) {
usableGroups := make(map[string]map[string]interface{})
userGroup := ""
userId := c.GetInt("id")
+ if userId <= 0 {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "success": false,
+ "message": "登录后查看可用分组",
+ })
+ return
+ }
userGroup, _ = model.GetUserGroup(userId, false)
userUsableGroups := service.GetUserUsableGroups(userGroup)
for groupName, _ := range ratio_setting.GetGroupRatioCopy() {
diff --git a/controller/health.go b/controller/health.go
new file mode 100644
index 000000000000..1e4d18da6a7d
--- /dev/null
+++ b/controller/health.go
@@ -0,0 +1,52 @@
+package controller
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/gin-gonic/gin"
+)
+
+type readinessProbe func(context.Context) error
+
+func checkReadiness(ctx context.Context, databasePing readinessProbe, redisPing readinessProbe) (string, error) {
+ if err := databasePing(ctx); err != nil {
+ return "database", err
+ }
+ if redisPing != nil {
+ if err := redisPing(ctx); err != nil {
+ return "redis", err
+ }
+ }
+ return "", nil
+}
+
+func GetReadiness(c *gin.Context) {
+ timeoutSeconds := common.GetEnvOrDefault("READINESS_TIMEOUT_SECONDS", 3)
+ if timeoutSeconds <= 0 {
+ timeoutSeconds = 3
+ }
+ ctx, cancel := context.WithTimeout(c.Request.Context(), time.Duration(timeoutSeconds)*time.Second)
+ defer cancel()
+
+ var redisPing readinessProbe
+ if common.RedisEnabled {
+ redisPing = func(ctx context.Context) error {
+ if common.RDB == nil {
+ return errors.New("redis client is not initialized")
+ }
+ return common.RDB.Ping(ctx).Err()
+ }
+ }
+
+ component, err := checkReadiness(ctx, model.PingDBContext, redisPing)
+ if err != nil {
+ c.JSON(http.StatusServiceUnavailable, gin.H{"status": "unavailable", "component": component})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"status": "ok"})
+}
diff --git a/controller/health_test.go b/controller/health_test.go
new file mode 100644
index 000000000000..0ab6b22781cf
--- /dev/null
+++ b/controller/health_test.go
@@ -0,0 +1,42 @@
+package controller
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestCheckReadinessSucceedsWhenDependenciesAreHealthy(t *testing.T) {
+ component, err := checkReadiness(
+ context.Background(),
+ func(context.Context) error { return nil },
+ func(context.Context) error { return nil },
+ )
+
+ require.NoError(t, err)
+ require.Empty(t, component)
+}
+
+func TestCheckReadinessReportsDatabaseFailure(t *testing.T) {
+ component, err := checkReadiness(
+ context.Background(),
+ func(context.Context) error { return errors.New("database unavailable") },
+ nil,
+ )
+
+ require.Error(t, err)
+ require.Equal(t, "database", component)
+}
+
+func TestCheckReadinessReportsRedisFailure(t *testing.T) {
+ component, err := checkReadiness(
+ context.Background(),
+ func(context.Context) error { return nil },
+ func(context.Context) error { return errors.New("redis unavailable") },
+ )
+
+ require.Error(t, err)
+ require.Equal(t, "redis", component)
+}
diff --git a/controller/log.go b/controller/log.go
index ce9b4666fa5e..3fd5e31e8697 100644
--- a/controller/log.go
+++ b/controller/log.go
@@ -1,6 +1,7 @@
package controller
import (
+ "errors"
"net/http"
"strconv"
@@ -12,17 +13,24 @@ import (
func GetAllLogs(c *gin.Context) {
pageInfo := common.GetPageQuery(c)
- logType, _ := strconv.Atoi(c.Query("type"))
- startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
- endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
- username := c.Query("username")
- tokenName := c.Query("token_name")
- modelName := c.Query("model_name")
- channel, _ := strconv.Atoi(c.Query("channel"))
- group := c.Query("group")
- requestId := c.Query("request_id")
- upstreamRequestId := c.Query("upstream_request_id")
- logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, upstreamRequestId)
+ query := getLogQuery(c)
+ if isLogCursorPagination(c) {
+ logs, nextCursor, hasMore, err := model.GetAllLogsByCursor(query, c.Query("cursor"), pageInfo.GetPageSize(), pageInfo.GetStartIdx())
+ if err != nil {
+ writeLogQueryError(c, err)
+ return
+ }
+ common.ApiSuccess(c, gin.H{
+ "items": logs,
+ "page": pageInfo.GetPage(),
+ "page_size": pageInfo.GetPageSize(),
+ "has_more": hasMore,
+ "next_cursor": nextCursor,
+ })
+ return
+ }
+
+ logs, total, err := model.GetAllLogs(query, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
@@ -30,21 +38,61 @@ func GetAllLogs(c *gin.Context) {
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
- return
}
-func GetUserLogs(c *gin.Context) {
- pageInfo := common.GetPageQuery(c)
- userId := c.GetInt("id")
+func getLogQuery(c *gin.Context) model.LogQuery {
logType, _ := strconv.Atoi(c.Query("type"))
startTimestamp, _ := strconv.ParseInt(c.Query("start_timestamp"), 10, 64)
endTimestamp, _ := strconv.ParseInt(c.Query("end_timestamp"), 10, 64)
- tokenName := c.Query("token_name")
- modelName := c.Query("model_name")
- group := c.Query("group")
- requestId := c.Query("request_id")
- upstreamRequestId := c.Query("upstream_request_id")
- logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId, upstreamRequestId)
+ channel, _ := strconv.Atoi(c.Query("channel"))
+ return model.LogQuery{
+ LogType: logType,
+ StartTimestamp: startTimestamp,
+ EndTimestamp: endTimestamp,
+ ModelName: c.Query("model_name"),
+ Username: c.Query("username"),
+ TokenName: c.Query("token_name"),
+ Channel: channel,
+ Group: c.Query("group"),
+ RequestId: c.Query("request_id"),
+ UpstreamRequestId: c.Query("upstream_request_id"),
+ TraceId: c.Query("trace_id"),
+ }
+}
+
+func isLogCursorPagination(c *gin.Context) bool {
+ return c.Query("pagination") == "cursor" || c.Request.URL.Query().Has("cursor")
+}
+
+func writeLogQueryError(c *gin.Context, err error) {
+ if errors.Is(err, model.ErrInvalidLogCursor) {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid log cursor"})
+ return
+ }
+ common.ApiError(c, err)
+}
+
+func GetUserLogs(c *gin.Context) {
+ pageInfo := common.GetPageQuery(c)
+ userId := c.GetInt("id")
+ query := getLogQuery(c)
+ if isLogCursorPagination(c) {
+ logs, nextCursor, hasMore, err := model.GetUserLogsByCursor(userId, query, c.Query("cursor"), pageInfo.GetPageSize(), pageInfo.GetStartIdx())
+ if err != nil {
+ writeLogQueryError(c, err)
+ return
+ }
+ common.ApiSuccess(c, gin.H{
+ "items": logs,
+ "page": pageInfo.GetPage(),
+ "page_size": pageInfo.GetPageSize(),
+ "has_more": hasMore,
+ "next_cursor": nextCursor,
+ })
+ return
+ }
+
+ logs, total, err := model.GetUserLogs(userId, query, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
common.ApiError(c, err)
return
@@ -52,7 +100,6 @@ func GetUserLogs(c *gin.Context) {
pageInfo.SetTotal(int(total))
pageInfo.SetItems(logs)
common.ApiSuccess(c, pageInfo)
- return
}
// Deprecated: SearchAllLogs 已废弃,前端未使用该接口。
diff --git a/controller/misc.go b/controller/misc.go
index fb2029878747..fe61ed6dcce8 100644
--- a/controller/misc.go
+++ b/controller/misc.go
@@ -14,6 +14,7 @@ import (
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
+ "github.com/QuantumNous/new-api/pkg/observability"
"github.com/QuantumNous/new-api/setting"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
@@ -93,6 +94,7 @@ func GetStatus(c *gin.Context) {
"password_login_enabled": common.PasswordLoginEnabled,
"password_register_enabled": common.PasswordRegisterEnabled,
"default_use_auto_group": setting.DefaultUseAutoGroup,
+ "rum_enabled": observability.Enabled(),
"usd_exchange_rate": operation_setting.USDExchangeRate,
"price": operation_setting.Price,
diff --git a/controller/option.go b/controller/option.go
index a97f07b841b7..072cc8c12d46 100644
--- a/controller/option.go
+++ b/controller/option.go
@@ -232,6 +232,15 @@ func UpdateOption(c *gin.Context) {
})
return
}
+ case "GroupGroupRatio":
+ err = ratio_setting.CheckGroupGroupRatio(option.Value.(string))
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ return
+ }
case "ImageRatio":
err = ratio_setting.UpdateImageRatioByJSONString(option.Value.(string))
if err != nil {
diff --git a/controller/perf_metrics.go b/controller/perf_metrics.go
index 66d0787f2a92..f90775cb9ece 100644
--- a/controller/perf_metrics.go
+++ b/controller/perf_metrics.go
@@ -19,7 +19,17 @@ func GetPerfMetricsSummary(c *gin.Context) {
}
}
- activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto")
+ // Prefer configured groups; always keep probe/auto/default so channel tests
+ // and the common default group are never filtered out. When no group ratio
+ // is configured at all, query every group rather than returning an empty set.
+ groupRatio := ratio_setting.GetGroupRatioCopy()
+ var activeGroups []string
+ if len(groupRatio) == 0 {
+ activeGroups = nil
+ } else {
+ activeGroups = append(lo.Keys(groupRatio), "auto", "probe", "default")
+ activeGroups = lo.Uniq(activeGroups)
+ }
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
@@ -75,8 +85,12 @@ func GetPerfMetrics(c *gin.Context) {
func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupResult {
activeRatios := ratio_setting.GetGroupRatioCopy()
+ // Empty group ratio means "don't filter" — same policy as summary.
+ if len(activeRatios) == 0 {
+ return groups
+ }
return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool {
_, ok := activeRatios[g.Group]
- return ok || g.Group == "auto"
+ return ok || g.Group == "auto" || g.Group == "probe" || g.Group == "default"
})
}
diff --git a/controller/relay.go b/controller/relay.go
index 6e91ccb60506..2e01d339dad1 100644
--- a/controller/relay.go
+++ b/controller/relay.go
@@ -6,6 +6,7 @@ import (
"io"
"log"
"net/http"
+ "net/url"
"strings"
"time"
@@ -200,6 +201,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
addUsedChannel(c, channel.Id)
bodyStorage, bodyErr := common.GetBodyStorage(c)
if bodyErr != nil {
+ service.ReleaseAdaptiveCircuitPermit(c, channel.Id)
// Ensure consistent 413 for oversized bodies even when error occurs later (e.g., retry path)
if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) {
newAPIError = types.NewErrorWithStatusCode(bodyErr, types.ErrorCodeReadRequestBodyFailed, http.StatusRequestEntityTooLarge, types.ErrOptionWithSkipRetry())
@@ -210,15 +212,46 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}
c.Request.Body = io.NopCloser(bodyStorage)
- switch relayFormat {
- case types.RelayFormatOpenAIRealtime:
- newAPIError = relay.WssHelper(c, relayInfo)
- case types.RelayFormatClaude:
- newAPIError = relay.ClaudeHelper(c, relayInfo)
- case types.RelayFormatGemini:
- newAPIError = geminiRelayHandler(c, relayInfo)
- default:
- newAPIError = relayHandler(c, relayInfo)
+ attemptStart := time.Now()
+ service.IncChannelConcurrency(channel.Id)
+ // Always dec even if helper panics (CustomRecovery still runs after).
+ func() {
+ defer service.DecChannelConcurrency(channel.Id)
+ switch relayFormat {
+ case types.RelayFormatOpenAIRealtime:
+ newAPIError = relay.WssHelper(c, relayInfo)
+ case types.RelayFormatClaude:
+ newAPIError = relay.ClaudeHelper(c, relayInfo)
+ case types.RelayFormatGemini:
+ newAPIError = geminiRelayHandler(c, relayInfo)
+ default:
+ newAPIError = relayHandler(c, relayInfo)
+ }
+ }()
+ {
+ statusCode := http.StatusOK
+ var recErr error
+ if newAPIError != nil {
+ statusCode = newAPIError.StatusCode
+ if statusCode == 0 {
+ statusCode = http.StatusInternalServerError
+ }
+ recErr = newAPIError
+ }
+ // Prefer UsingGroup (resolved auto group) so score buckets match selection.
+ metricGroup := relayInfo.UsingGroup
+ if metricGroup == "" {
+ metricGroup = relayInfo.TokenGroup
+ }
+ service.RecordAdaptiveResult(
+ c,
+ channel.Id,
+ metricGroup,
+ relayInfo.OriginModelName,
+ statusCode,
+ time.Since(attemptStart),
+ recErr,
+ )
}
if newAPIError == nil {
@@ -250,12 +283,30 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
var upgrader = websocket.Upgrader{
Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol
- CheckOrigin: func(r *http.Request) bool {
- return true // 允许跨域
- },
+ CheckOrigin: isRealtimeWebSocketOriginAllowed,
+}
+
+func isRealtimeWebSocketOriginAllowed(r *http.Request) bool {
+ if r == nil {
+ return false
+ }
+ originValue := strings.TrimSpace(r.Header.Get("Origin"))
+ if originValue == "" {
+ return true
+ }
+
+ origin, err := url.Parse(originValue)
+ if err != nil || origin.Host == "" || (origin.Scheme != "http" && origin.Scheme != "https") {
+ return false
+ }
+ if strings.EqualFold(origin.Host, r.Host) {
+ return true
+ }
+ return common.ValidateRedirectURL(originValue) == nil
}
func addUsedChannel(c *gin.Context, channelId int) {
+ service.MarkChannelUsed(c, channelId)
useChannel := c.GetStringSlice("use_channel")
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
c.Set("use_channel", useChannel)
@@ -317,6 +368,7 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, info.OriginModelName)
if newAPIError != nil {
+ service.ReleaseAdaptiveCircuitPermit(c, channel.Id)
return nil, newAPIError
}
return channel, nil
@@ -329,16 +381,22 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
return false
}
- if types.IsChannelError(openaiErr) {
- return true
+ if retryTimes <= 0 {
+ return false
}
- if types.IsSkipRetryError(openaiErr) {
+ if _, ok := c.Get("specific_channel_id"); ok {
return false
}
- if retryTimes <= 0 {
+ if openaiErr.GetErrorCode() == types.ErrorCodeGetChannelFailed {
return false
}
- if _, ok := c.Get("specific_channel_id"); ok {
+ if isUpstreamChannelQuotaError(openaiErr) {
+ return true
+ }
+ if types.IsChannelError(openaiErr) {
+ return true
+ }
+ if types.IsSkipRetryError(openaiErr) {
return false
}
code := openaiErr.StatusCode
@@ -354,6 +412,44 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
return operation_setting.ShouldRetryByStatusCode(code)
}
+func isUpstreamChannelQuotaError(err *types.NewAPIError) bool {
+ if err == nil {
+ return false
+ }
+ code := strings.ToLower(strings.TrimSpace(string(err.GetErrorCode())))
+ if code == string(types.ErrorCodeInsufficientUserQuota) || code == string(types.ErrorCodePreConsumeTokenQuotaFailed) {
+ return false
+ }
+ if err.StatusCode == http.StatusPaymentRequired {
+ return true
+ }
+ for _, marker := range []string{
+ "insufficient_quota",
+ "quota_exceeded",
+ "billing_hard_limit_reached",
+ "insufficient_balance",
+ "insufficient_credits",
+ } {
+ if strings.Contains(code, marker) {
+ return true
+ }
+ }
+ message := strings.ToLower(err.Error())
+ for _, marker := range []string{
+ "insufficient quota",
+ "quota exceeded",
+ "insufficient balance",
+ "insufficient credit",
+ "额度不足",
+ "余额不足",
+ } {
+ if strings.Contains(message, marker) {
+ return true
+ }
+ }
+ return false
+}
+
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
diff --git a/controller/relay_origin_test.go b/controller/relay_origin_test.go
new file mode 100644
index 000000000000..ef0e44c072fa
--- /dev/null
+++ b/controller/relay_origin_test.go
@@ -0,0 +1,43 @@
+package controller
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRealtimeWebSocketOriginAllowed(t *testing.T) {
+ originalDomains := append([]string(nil), constant.TrustedRedirectDomains...)
+ constant.TrustedRedirectDomains = []string{"example.com"}
+ t.Cleanup(func() {
+ constant.TrustedRedirectDomains = originalDomains
+ })
+
+ tests := []struct {
+ name string
+ origin string
+ host string
+ want bool
+ }{
+ {name: "missing origin", host: "api.internal", want: true},
+ {name: "same origin", origin: "https://api.internal", host: "api.internal", want: true},
+ {name: "trusted exact domain", origin: "https://example.com", host: "api.internal", want: true},
+ {name: "trusted subdomain", origin: "https://console.example.com", host: "api.internal", want: true},
+ {name: "untrusted domain", origin: "https://evil.example.net", host: "api.internal", want: false},
+ {name: "suffix spoof", origin: "https://fakeexample.com", host: "api.internal", want: false},
+ {name: "invalid scheme", origin: "file://example.com", host: "api.internal", want: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ request := httptest.NewRequest(http.MethodGet, "https://"+tt.host+"/v1/realtime", nil)
+ if tt.origin != "" {
+ request.Header.Set("Origin", tt.origin)
+ }
+ require.Equal(t, tt.want, isRealtimeWebSocketOriginAllowed(request))
+ })
+ }
+}
diff --git a/controller/relay_retry_test.go b/controller/relay_retry_test.go
new file mode 100644
index 000000000000..53d6f9b0ba4d
--- /dev/null
+++ b/controller/relay_retry_test.go
@@ -0,0 +1,37 @@
+package controller
+
+import (
+ "errors"
+ "net/http"
+ "testing"
+
+ "github.com/QuantumNous/new-api/types"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestShouldRetryStopsAfterChannelSelectionFailure(t *testing.T) {
+ ctx, _ := gin.CreateTestContext(nil)
+ err := types.NewError(errors.New("no eligible channel"), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
+ require.False(t, shouldRetry(ctx, err, 2))
+}
+
+func TestShouldRetrySwitchesChannelOnUpstreamQuotaExhaustion(t *testing.T) {
+ ctx, _ := gin.CreateTestContext(nil)
+ err := types.WithOpenAIError(types.OpenAIError{
+ Message: "upstream account has insufficient balance",
+ Code: "insufficient_quota",
+ }, http.StatusTooManyRequests)
+ require.True(t, shouldRetry(ctx, err, 2))
+}
+
+func TestShouldRetryDoesNotSwitchForLocalUserQuota(t *testing.T) {
+ ctx, _ := gin.CreateTestContext(nil)
+ err := types.NewErrorWithStatusCode(
+ errors.New("user quota insufficient"),
+ types.ErrorCodeInsufficientUserQuota,
+ http.StatusForbidden,
+ types.ErrOptionWithSkipRetry(),
+ )
+ require.False(t, shouldRetry(ctx, err, 2))
+}
diff --git a/controller/rum.go b/controller/rum.go
new file mode 100644
index 000000000000..3897fe06f225
--- /dev/null
+++ b/controller/rum.go
@@ -0,0 +1,56 @@
+package controller
+
+import (
+ "math"
+ "net/http"
+ "strings"
+
+ "github.com/QuantumNous/new-api/pkg/observability"
+ "github.com/gin-gonic/gin"
+)
+
+type webVitalSample struct {
+ Name string `json:"name"`
+ Value float64 `json:"value"`
+ Rating string `json:"rating"`
+}
+
+func validateWebVitalSample(sample webVitalSample) (webVitalSample, bool) {
+ sample.Name = strings.ToUpper(strings.TrimSpace(sample.Name))
+ sample.Rating = strings.ToLower(strings.TrimSpace(sample.Rating))
+ if sample.Name != "CLS" && sample.Name != "INP" && sample.Name != "LCP" {
+ return sample, false
+ }
+ if sample.Rating != "good" && sample.Rating != "needs-improvement" && sample.Rating != "poor" {
+ return sample, false
+ }
+ if math.IsNaN(sample.Value) || math.IsInf(sample.Value, 0) || sample.Value < 0 {
+ return sample, false
+ }
+ if sample.Name == "CLS" && sample.Value > 100 {
+ return sample, false
+ }
+ if sample.Name != "CLS" && sample.Value > 600_000 {
+ return sample, false
+ }
+ return sample, true
+}
+
+func RecordWebVital(c *gin.Context) {
+ if !observability.Enabled() {
+ c.Status(http.StatusNotFound)
+ return
+ }
+ var sample webVitalSample
+ if err := c.ShouldBindJSON(&sample); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid web vital sample"})
+ return
+ }
+ sample, ok := validateWebVitalSample(sample)
+ if !ok {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid web vital sample"})
+ return
+ }
+ observability.ObserveWebVital(sample.Name, sample.Rating, sample.Value)
+ c.Status(http.StatusNoContent)
+}
diff --git a/controller/rum_test.go b/controller/rum_test.go
new file mode 100644
index 000000000000..496dba37c471
--- /dev/null
+++ b/controller/rum_test.go
@@ -0,0 +1,41 @@
+package controller
+
+import (
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestValidateWebVitalSample(t *testing.T) {
+ sample, ok := validateWebVitalSample(webVitalSample{Name: "lcp", Value: 2450, Rating: "good"})
+ require.True(t, ok)
+ require.Equal(t, "LCP", sample.Name)
+
+ for _, invalid := range []webVitalSample{
+ {Name: "FID", Value: 10, Rating: "good"},
+ {Name: "INP", Value: -1, Rating: "poor"},
+ {Name: "CLS", Value: math.NaN(), Rating: "good"},
+ {Name: "LCP", Value: 1000, Rating: "unknown"},
+ } {
+ _, ok := validateWebVitalSample(invalid)
+ require.False(t, ok)
+ }
+}
+
+func TestRecordWebVitalIsDisabledWithMetrics(t *testing.T) {
+ t.Setenv("METRICS_ENABLED", "false")
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.POST("/api/rum", RecordWebVital)
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodPost, "/api/rum", strings.NewReader(`{"name":"LCP","value":1000,"rating":"good"}`))
+ request.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusNotFound, recorder.Code)
+}
diff --git a/controller/token.go b/controller/token.go
index 836e9b2952ac..c5cb4e542883 100644
--- a/controller/token.go
+++ b/controller/token.go
@@ -4,7 +4,6 @@ import (
"fmt"
"net/http"
"strconv"
- "strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
@@ -116,28 +115,9 @@ func GetTokenStatus(c *gin.Context) {
}
func GetTokenUsage(c *gin.Context) {
- authHeader := c.GetHeader("Authorization")
- if authHeader == "" {
- c.JSON(http.StatusUnauthorized, gin.H{
- "success": false,
- "message": "No Authorization header",
- })
- return
- }
-
- parts := strings.Split(authHeader, " ")
- if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
- c.JSON(http.StatusUnauthorized, gin.H{
- "success": false,
- "message": "Invalid Bearer token",
- })
- return
- }
- tokenKey := parts[1]
-
- token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
+ token, err := model.GetTokenById(c.GetInt("token_id"))
if err != nil {
- common.SysError("failed to get token by key: " + err.Error())
+ common.SysError("failed to get token by id: " + err.Error())
common.ApiErrorI18n(c, i18n.MsgTokenGetInfoFailed)
return
}
diff --git a/controller/token_test.go b/controller/token_test.go
index 12b1cbdd84fb..153184a94e00 100644
--- a/controller/token_test.go
+++ b/controller/token_test.go
@@ -16,6 +16,7 @@ import (
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/require"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
@@ -417,6 +418,32 @@ func TestGetAllTokensMasksKeyInResponse(t *testing.T) {
}
}
+func TestGetTokenUsageUsesAuthenticatedTokenID(t *testing.T) {
+ db := setupTokenControllerTestDB(t)
+ token := seedToken(t, db, 7, "usage-token", "usage-key-with-suffix")
+
+ recorder := httptest.NewRecorder()
+ ctx, _ := gin.CreateTestContext(recorder)
+ ctx.Request = httptest.NewRequest(http.MethodGet, "/api/usage/token/", nil)
+ ctx.Request.Header.Set("Authorization", "Bearer sk-wrong-key-wrong-suffix")
+ ctx.Set("token_id", token.Id)
+
+ GetTokenUsage(ctx)
+
+ require.Equal(t, http.StatusOK, recorder.Code)
+ var response struct {
+ Code bool `json:"code"`
+ Data struct {
+ Name string `json:"name"`
+ TotalGranted int `json:"total_granted"`
+ } `json:"data"`
+ }
+ require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
+ require.True(t, response.Code)
+ require.Equal(t, token.Name, response.Data.Name)
+ require.Equal(t, token.RemainQuota+token.UsedQuota, response.Data.TotalGranted)
+}
+
func TestSearchTokensMasksKeyInResponse(t *testing.T) {
db := setupTokenControllerTestDB(t)
token := seedToken(t, db, 1, "searchable-token", "ijkl1234mnop5678")
diff --git a/controller/topup_creem.go b/controller/topup_creem.go
index 7472690e22fb..2bb26c2aafef 100644
--- a/controller/topup_creem.go
+++ b/controller/topup_creem.go
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting"
"io"
"net/http"
@@ -420,9 +421,7 @@ func genCreemLink(ctx context.Context, referenceId string, product *CreemProduct
logger.LogInfo(ctx, fmt.Sprintf("Creem 支付请求已发送 api_url=%s product_id=%s email=%q trade_no=%s", apiUrl, product.ProductId, email, referenceId))
// 发送请求
- client := &http.Client{
- Timeout: 30 * time.Second,
- }
+ client := service.GetHttpClientWithTimeout(30 * time.Second)
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("发送HTTP请求失败: %v", err)
diff --git a/controller/trace.go b/controller/trace.go
new file mode 100644
index 000000000000..5b9feaff0e1c
--- /dev/null
+++ b/controller/trace.go
@@ -0,0 +1,65 @@
+package controller
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/gin-gonic/gin"
+)
+
+// GetTraceLogs returns logs for an AxonHub-style trace_id (admin).
+// GET /api/log/trace/:trace_id
+func GetTraceLogs(c *gin.Context) {
+ traceId := strings.TrimSpace(c.Param("trace_id"))
+ if traceId == "" {
+ traceId = strings.TrimSpace(c.Query("trace_id"))
+ }
+ if traceId == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "trace_id required"})
+ return
+ }
+ logs, err := model.GetLogsByTraceId(traceId, 200)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
+ return
+ }
+ // strip nothing extra for admin; ensure Other is parseable map for UI
+ items := make([]gin.H, 0, len(logs))
+ for _, l := range logs {
+ if l == nil {
+ continue
+ }
+ other, _ := common.StrToMap(l.Other)
+ traceId := l.TraceId
+ if traceId == "" {
+ traceId, _ = other["trace_id"].(string)
+ }
+ items = append(items, gin.H{
+ "id": l.Id,
+ "created_at": l.CreatedAt,
+ "type": l.Type,
+ "model_name": l.ModelName,
+ "channel": l.ChannelId,
+ "token_name": l.TokenName,
+ "quota": l.Quota,
+ "use_time": l.UseTime,
+ "is_stream": l.IsStream,
+ "group": l.Group,
+ "request_id": l.RequestId,
+ "content": l.Content,
+ "other": other,
+ "thread_id": other["thread_id"],
+ "trace_id": traceId,
+ })
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "data": gin.H{
+ "trace_id": traceId,
+ "count": len(items),
+ "logs": items,
+ },
+ })
+}
diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go
index 2beceb426f8d..a1ad472da46c 100644
--- a/controller/uptime_kuma.go
+++ b/controller/uptime_kuma.go
@@ -9,6 +9,7 @@ import (
"strings"
"time"
+ "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/console_setting"
"github.com/gin-gonic/gin"
@@ -138,7 +139,7 @@ func GetUptimeKumaStatus(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), requestTimeout)
defer cancel()
- client := &http.Client{Timeout: httpTimeout}
+ client := service.GetHttpClientWithTimeout(httpTimeout)
results := make([]UptimeGroupResult, len(groups))
g, gCtx := errgroup.WithContext(ctx)
diff --git a/controller/user.go b/controller/user.go
index 6316fd13121a..47d1116a4a35 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -171,7 +171,16 @@ func setupLogin(user *model.User, c *gin.Context) {
func Logout(c *gin.Context) {
session := sessions.Default(c)
+ // 清空会话数据,并强制 MaxAge=-1,确保浏览器删除 Secure session cookie。
+ // 仅 Clear()+Save() 在部分客户端上会留下空 cookie,导致登出后仍被当成半登录态。
session.Clear()
+ session.Options(sessions.Options{
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ Secure: common.SessionCookieSecure,
+ SameSite: http.SameSiteStrictMode,
+ })
err := session.Save()
if err != nil {
c.JSON(http.StatusOK, gin.H{
diff --git a/controller/wechat.go b/controller/wechat.go
index 8889daca77db..93b085f35f12 100644
--- a/controller/wechat.go
+++ b/controller/wechat.go
@@ -11,6 +11,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"
+ "github.com/QuantumNous/new-api/service"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
@@ -31,9 +32,7 @@ func getWeChatIdByCode(code string) (string, error) {
return "", err
}
req.Header.Set("Authorization", common.WeChatServerToken)
- client := http.Client{
- Timeout: 5 * time.Second,
- }
+ client := service.GetHttpClientWithTimeout(5 * time.Second)
httpResponse, err := client.Do(req)
if err != nil {
return "", err
diff --git a/deploy/prometheus/new-api-alerts.yml b/deploy/prometheus/new-api-alerts.yml
new file mode 100644
index 000000000000..4a085c6362f2
--- /dev/null
+++ b/deploy/prometheus/new-api-alerts.yml
@@ -0,0 +1,45 @@
+groups:
+ - name: new-api-slo
+ rules:
+ - alert: NewAPIRelayHighErrorRate
+ expr: |
+ sum(rate(newapi_http_requests_total{route_class="relay",status=~"5.."}[5m]))
+ /
+ clamp_min(sum(rate(newapi_http_requests_total{route_class="relay"}[5m])), 0.001)
+ > 0.01
+ for: 10m
+ labels:
+ severity: page
+ annotations:
+ summary: Relay 5xx ratio is above 1%
+
+ - alert: NewAPIManagementLatencyHigh
+ expr: |
+ histogram_quantile(0.95,
+ sum by (le) (rate(newapi_http_request_duration_seconds_bucket{route_class="api"}[10m]))
+ ) > 0.5
+ for: 15m
+ labels:
+ severity: warning
+ annotations:
+ summary: Management API P95 latency is above 500ms
+
+ - alert: NewAPIInFlightRequestsHigh
+ expr: sum(newapi_http_requests_in_flight) > 800
+ for: 10m
+ labels:
+ severity: warning
+ annotations:
+ summary: In-flight request count is unusually high
+
+ - alert: NewAPIWebVitalsPoor
+ expr: |
+ sum(rate(newapi_web_vital_value_count{rating="poor"}[15m]))
+ /
+ clamp_min(sum(rate(newapi_web_vital_value_count[15m])), 0.001)
+ > 0.25
+ for: 30m
+ labels:
+ severity: warning
+ annotations:
+ summary: More than 25% of reported Web Vitals are poor
diff --git a/deploy/separated/Dockerfile.frontend b/deploy/separated/Dockerfile.frontend
new file mode 100644
index 000000000000..9345f1f8972c
--- /dev/null
+++ b/deploy/separated/Dockerfile.frontend
@@ -0,0 +1,52 @@
+# Frontend-only image: builds web/default and serves via non-root Nginx with same-origin API proxy.
+# Build context MUST be the authoritative repository root (D:\newapi\src).
+#
+# NGINX_IMAGE may be a tag or repo@sha256 digest. Quality CI resolves the tag to a digest
+# at build time so the runtime base is pinned even when the Dockerfile default is a tag.
+
+# NGINX_IMAGE may be a tag or repo@sha256 digest. Quality CI re-resolves the floating tag
+# at build time; the default below is the digest last pinned by CI on 2026-07-18.
+ARG NGINX_IMAGE=nginxinc/nginx-unprivileged@sha256:65e3e85dbaed8ba248841d9d58a899b6197106c23cb0ff1a132b7bfe0547e4c0
+
+FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
+
+WORKDIR /build/web
+COPY web/package.json web/bun.lock ./
+COPY web/default/package.json ./default/package.json
+COPY web/classic/package.json ./classic/package.json
+RUN bun install --frozen-lockfile
+COPY ./web/default ./default
+COPY ./VERSION /build/VERSION
+RUN test -s /build/VERSION \
+ && cd default \
+ && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
+
+# nginxinc unprivileged image listens on 8080 and runs as non-root by default.
+FROM ${NGINX_IMAGE}
+
+USER root
+RUN apk add --no-cache curl gettext \
+ && mkdir -p /etc/nginx/templates /var/log/nginx \
+ && chown -R nginx:nginx /etc/nginx /var/cache/nginx /var/log/nginx /usr/share/nginx/html
+
+COPY --from=builder /build/web/default/dist /usr/share/nginx/html
+COPY deploy/separated/nginx.conf.template /etc/nginx/templates/nginx.conf.template
+COPY deploy/separated/docker-entrypoint.sh /docker-entrypoint.sh
+RUN chmod 755 /docker-entrypoint.sh \
+ && chown -R nginx:nginx /usr/share/nginx/html
+
+USER nginx
+ENV BACKEND_UPSTREAM=backend:3000 \
+ DNS_RESOLVER=127.0.0.11 \
+ NGINX_PORT=8080 \
+ SERVER_NAME=_ \
+ CLIENT_MAX_BODY_SIZE=100m \
+ PROXY_CONNECT_TIMEOUT=60s \
+ PROXY_SEND_TIMEOUT=3600s \
+ PROXY_READ_TIMEOUT=3600s
+
+EXPOSE 8080
+HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
+ CMD curl -fsS "http://127.0.0.1:${NGINX_PORT}/frontend-healthz" | grep -q '"status":"ok"'
+
+ENTRYPOINT ["/docker-entrypoint.sh"]
diff --git a/deploy/separated/README.md b/deploy/separated/README.md
new file mode 100644
index 000000000000..c9e283a4642d
--- /dev/null
+++ b/deploy/separated/README.md
@@ -0,0 +1,134 @@
+# Separated frontend/backend delivery
+
+This directory packages the recommended same-origin split:
+
+1. **Backend image** (`Dockerfile.backend` at repo root) — Go binary built with `-tags frontend_external`, no Bun, no embedded `web/*/dist`.
+2. **Frontend image** (`Dockerfile.frontend` here) — builds only `web/default`, serves static files on non-root port 8080, reverse-proxies API/Relay/SSE/WebSocket to the backend.
+
+The monorepo and the default embedded Docker image remain fully supported.
+
+## Why same-origin
+
+Prefer:
+
+```text
+browser --> frontend Nginx (:8080)
+ |-- /assets, SPA
+ +-- /api /v1 /v1beta /mj /pg /suno /kling /jimeng /healthz /livez /readyz --> backend (:3000)
+```
+
+Benefits:
+
+- Session cookies stay first-party (no extra CORS credential surface).
+- CSRF and OAuth callback hosts stay on a single public origin.
+- SSE and WebSocket upgrade stay on the same host the SPA already uses.
+
+Cross-origin `FRONTEND_MODE=redirect` is supported by the backend for multi-host layouts, but expands Cookie/CORS/OAuth risk and is not the default recommendation.
+
+## Build
+
+From the authoritative repository root (`D:\newapi\src`, never `_qn_tmp`):
+
+```bash
+# Pure backend (no frontend dist required)
+docker build -f Dockerfile.backend -t new-api-backend:local .
+
+# Default SPA frontend + Nginx proxy
+docker build -f deploy/separated/Dockerfile.frontend -t new-api-frontend:local .
+
+# Or compose
+docker compose -f deploy/separated/docker-compose.yml build
+```
+
+Local Go equivalent:
+
+```bash
+go build -trimpath -buildvcs=true -tags frontend_external -o new-api-backend .
+FRONTEND_MODE=disabled ./new-api-backend
+```
+
+## Runtime configuration
+
+| Variable | Component | Notes |
+|---|---|---|
+| `FRONTEND_MODE` | backend | `disabled` (pure API) or `redirect` (jump unknown pages to another origin). Backend image defaults to `disabled`. |
+| `FRONTEND_BASE_URL` | backend | Required for `redirect`; must be an absolute HTTP(S) origin with no path/query/userinfo. |
+| `BACKEND_UPSTREAM` | frontend | Nginx upstream host:port, default `backend:3000`. Resolved at request time. |
+| `DNS_RESOLVER` | frontend | Resolver for deferred upstream DNS; default Docker DNS `127.0.0.11`. |
+| `NGINX_PORT` | frontend | Listen port inside container, default `8080`. |
+| `TRUSTED_PROXY_CIDRS` | backend | Must include the frontend/proxy network so client IPs from `X-Forwarded-For` are trusted. |
+| `SESSION_COOKIE_SECURE` / `SESSION_COOKIE_TRUSTED_URL` | backend | Configure for HTTPS production entries. |
+
+Do **not** publish `/metrics` on the public frontend edge. Scrape metrics on the backend network with `METRICS_TOKEN`.
+
+## Nginx coverage
+
+Proxied with original path + query preserved:
+
+- `/api`, `/v1`, `/v1beta`, `/mj`, `/:mode/mj`, `/pg`, `/suno`, `/kling`, `/jimeng`
+- `/healthz`, `/livez`, `/readyz`
+- `/v1/realtime` WebSocket (`Upgrade` / `Connection`)
+- Streaming endpoints (`proxy_buffering off`, long read/send timeouts)
+
+Local SPA health: `GET /frontend-healthz`.
+
+SPA rules:
+
+- `try_files` → `index.html`
+- `/assets/*` long-cache immutable
+- `index.html` `Cache-Control: no-cache`
+
+## Validate Nginx config
+
+Inside a running frontend container (or a one-shot build):
+
+```bash
+docker run --rm --entrypoint /bin/sh new-api-frontend:local -c 'nginx -t -c /etc/nginx/nginx.conf'
+# or after entrypoint substitution during start
+```
+
+The entrypoint always runs `nginx -t` before `daemon off`.
+
+Quality CI pulls `nginxinc/nginx-unprivileged:1.27-alpine`, resolves its registry digest, and builds with
+`--build-arg NGINX_IMAGE=`. Local builds may still use the tag default; prefer the CI-resolved
+digest when freezing a production frontend image.
+
+## Makefile shortcuts
+
+From repository root:
+
+```bash
+make build-backend # go build -tags frontend_external
+make docker-backend # Dockerfile.backend
+make docker-frontend # deploy/separated/Dockerfile.frontend
+make docker-separated # backend + frontend images
+```
+
+## Compose smoke checklist
+
+Automated:
+
+```bash
+# bash
+FRONTEND_BASE=http://127.0.0.1:8080 ./deploy/separated/smoke.sh
+
+# Windows PowerShell
+powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy\separated\smoke.ps1
+```
+
+Manual extras:
+
+1. Confirm SSE is not buffered (chat stream).
+2. Confirm WebSocket upgrade works for `/v1/realtime`.
+3. Confirm `/metrics` remains 404 on the public edge.
+
+## Rollback
+
+- Configuration-only: point traffic back to the monolithic image (`Dockerfile`) or a single binary with default embedded assets and unset/override `FRONTEND_MODE`.
+- Image-only: redeploy the previous integrated `new-api` image; database migrations remain additive.
+
+See also:
+
+- `docs/operations/runtime-separation.md`
+- `docs/operations/build-and-release.md`
+- `docs/adr/0001-frontend-backend-delivery-seam.md`
diff --git a/deploy/separated/docker-compose.yml b/deploy/separated/docker-compose.yml
new file mode 100644
index 000000000000..0d0ed42b363d
--- /dev/null
+++ b/deploy/separated/docker-compose.yml
@@ -0,0 +1,78 @@
+# Same-origin frontend + pure backend example stack.
+# Build from the authoritative repository root:
+# docker compose -f deploy/separated/docker-compose.yml build
+# Required secrets must be provided via environment or an env file (never commit real values).
+
+services:
+ backend:
+ image: ${NEW_API_BACKEND_IMAGE:-new-api-backend:local}
+ build:
+ context: ../..
+ dockerfile: Dockerfile.backend
+ container_name: new-api-backend
+ restart: always
+ command: --log-dir /app/logs
+ environment:
+ - FRONTEND_MODE=disabled
+ - RUN_MODE=${RUN_MODE:-all}
+ - APP_PLANE=${APP_PLANE:-all}
+ - SQL_DSN=${SQL_DSN:?Set SQL_DSN}
+ - REDIS_CONN_STRING=${REDIS_CONN_STRING:-}
+ - TZ=Asia/Shanghai
+ - ERROR_LOG_ENABLED=true
+ - BATCH_UPDATE_ENABLED=true
+ - NODE_NAME=new-api-backend-1
+ - TRUSTED_PROXY_CIDRS=${TRUSTED_PROXY_CIDRS:-172.16.0.0/12}
+ - METRICS_ENABLED=${METRICS_ENABLED:-false}
+ - METRICS_TOKEN=${METRICS_TOKEN:-}
+ - SESSION_SECRET=${SESSION_SECRET:-}
+ - SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-}
+ - SESSION_COOKIE_TRUSTED_URL=${SESSION_COOKIE_TRUSTED_URL:-}
+ volumes:
+ - backend_data:/data
+ - backend_logs:/app/logs
+ networks:
+ - new-api-separated
+ healthcheck:
+ test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:3000/readyz | grep -q '\"status\":\"ok\"'"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ # Backend is intentionally not published; only the frontend edge is exposed.
+
+ frontend:
+ image: ${NEW_API_FRONTEND_IMAGE:-new-api-frontend:local}
+ build:
+ context: ../..
+ dockerfile: deploy/separated/Dockerfile.frontend
+ container_name: new-api-frontend
+ restart: always
+ depends_on:
+ backend:
+ condition: service_healthy
+ environment:
+ - BACKEND_UPSTREAM=backend:3000
+ - DNS_RESOLVER=127.0.0.11
+ - NGINX_PORT=8080
+ - SERVER_NAME=_
+ - CLIENT_MAX_BODY_SIZE=100m
+ - PROXY_CONNECT_TIMEOUT=60s
+ - PROXY_SEND_TIMEOUT=3600s
+ - PROXY_READ_TIMEOUT=3600s
+ ports:
+ - "${FRONTEND_PUBLISH_PORT:-8080}:8080"
+ networks:
+ - new-api-separated
+ healthcheck:
+ test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8080/frontend-healthz | grep -q '\"status\":\"ok\"'"]
+ interval: 30s
+ timeout: 5s
+ retries: 3
+
+networks:
+ new-api-separated:
+ driver: bridge
+
+volumes:
+ backend_data:
+ backend_logs:
diff --git a/deploy/separated/docker-entrypoint.sh b/deploy/separated/docker-entrypoint.sh
new file mode 100755
index 000000000000..f4d84652a8c0
--- /dev/null
+++ b/deploy/separated/docker-entrypoint.sh
@@ -0,0 +1,30 @@
+#!/bin/sh
+# 渲染 nginx 模板并以前台模式启动,供独立前端镜像 entrypoint 使用。
+set -eu
+
+export BACKEND_UPSTREAM="${BACKEND_UPSTREAM:-backend:3000}"
+export NGINX_PORT="${NGINX_PORT:-8080}"
+export SERVER_NAME="${SERVER_NAME:-_}"
+export CLIENT_MAX_BODY_SIZE="${CLIENT_MAX_BODY_SIZE:-100m}"
+export PROXY_CONNECT_TIMEOUT="${PROXY_CONNECT_TIMEOUT:-60s}"
+export PROXY_SEND_TIMEOUT="${PROXY_SEND_TIMEOUT:-3600s}"
+export PROXY_READ_TIMEOUT="${PROXY_READ_TIMEOUT:-3600s}"
+# Docker 内置 DNS;非 Docker 环境可改为可用解析器。
+export DNS_RESOLVER="${DNS_RESOLVER:-127.0.0.11}"
+
+TEMPLATE="/etc/nginx/templates/nginx.conf.template"
+TARGET="/etc/nginx/nginx.conf"
+
+if [ ! -f "$TEMPLATE" ]; then
+ echo "missing nginx template: $TEMPLATE" >&2
+ exit 1
+fi
+
+# 只替换已知占位符,避免误伤 nginx 变量($host、$uri 等)。
+envsubst '${BACKEND_UPSTREAM} ${NGINX_PORT} ${SERVER_NAME} ${CLIENT_MAX_BODY_SIZE} ${PROXY_CONNECT_TIMEOUT} ${PROXY_SEND_TIMEOUT} ${PROXY_READ_TIMEOUT} ${DNS_RESOLVER}' \
+ < "$TEMPLATE" > "$TARGET"
+
+# 启动前做配置语法检查,失败直接退出容器。
+nginx -t -c "$TARGET"
+
+exec nginx -g 'daemon off;' -c "$TARGET"
diff --git a/deploy/separated/nginx.conf.template b/deploy/separated/nginx.conf.template
new file mode 100644
index 000000000000..05e295cf35bc
--- /dev/null
+++ b/deploy/separated/nginx.conf.template
@@ -0,0 +1,235 @@
+# Nginx SPA + same-origin reverse proxy for new-api frontend delivery.
+# Placeholders are substituted by docker-entrypoint.sh via envsubst.
+#
+# Upstream uses a variable + resolver so:
+# 1) nginx -t works without the backend hostname existing (CI / cold start)
+# 2) Docker Compose service names are resolved at request time via 127.0.0.11
+
+worker_processes auto;
+error_log /var/log/nginx/error.log warn;
+pid /tmp/nginx.pid;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ sendfile on;
+ tcp_nopush on;
+ keepalive_timeout 65;
+ server_tokens off;
+ client_max_body_size ${CLIENT_MAX_BODY_SIZE};
+
+ # Streaming / SSE / WebSocket-friendly upgrade map.
+ map $http_upgrade $connection_upgrade {
+ default upgrade;
+ '' close;
+ }
+
+ # Long-lived relay streams and realtime sockets need extended idle timeouts.
+ proxy_connect_timeout ${PROXY_CONNECT_TIMEOUT};
+ proxy_send_timeout ${PROXY_SEND_TIMEOUT};
+ proxy_read_timeout ${PROXY_READ_TIMEOUT};
+
+ # Docker embedded DNS by default; override for non-Docker runtimes.
+ resolver ${DNS_RESOLVER} valid=10s ipv6=off;
+
+ server {
+ listen ${NGINX_PORT};
+ server_name ${SERVER_NAME};
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Independent frontend health for container orchestrators (does not probe backend).
+ location = /frontend-healthz {
+ access_log off;
+ default_type application/json;
+ return 200 '{"status":"ok","component":"frontend"}';
+ }
+
+ # Do not expose Prometheus metrics on the public frontend edge by default.
+ location = /metrics {
+ return 404;
+ }
+
+ # Fingerprinted static assets: long cache, immutable.
+ location /assets/ {
+ try_files $uri =404;
+ access_log off;
+ expires 1y;
+ add_header Cache-Control "public, max-age=31536000, immutable";
+ }
+
+ # Shared proxy settings for API, relay, SSE, and WebSocket paths.
+ # Variable proxy_pass defers DNS lookup to request time and keeps $request_uri intact.
+ location /api/ {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_request_buffering off;
+ proxy_cache off;
+ }
+
+ location /v1/ {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection $connection_upgrade;
+ proxy_buffering off;
+ proxy_request_buffering off;
+ proxy_cache off;
+ proxy_read_timeout ${PROXY_READ_TIMEOUT};
+ proxy_send_timeout ${PROXY_SEND_TIMEOUT};
+ }
+
+ location /v1beta/ {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_request_buffering off;
+ proxy_cache off;
+ }
+
+ location /mj/ {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ }
+
+ # Midjourney mode-prefixed paths: /:mode/mj/...
+ location ~ ^/[^/]+/mj(/|$) {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ }
+
+ location /pg/ {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ }
+
+ location /suno/ {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ }
+
+ location /kling/ {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ }
+
+ location /jimeng {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream$request_uri;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_set_header Connection "";
+ proxy_buffering off;
+ proxy_cache off;
+ }
+
+ # Backend liveness/readiness through the same origin (optional operational path).
+ location = /healthz {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream/healthz;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ access_log off;
+ }
+
+ location = /livez {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream/livez;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ access_log off;
+ }
+
+ location = /readyz {
+ set $backend_upstream ${BACKEND_UPSTREAM};
+ proxy_pass http://$backend_upstream/readyz;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ access_log off;
+ }
+
+ # SPA fallback: never cache the HTML shell so releases roll out immediately.
+ location / {
+ try_files $uri $uri/ /index.html;
+ add_header Cache-Control "no-cache" always;
+ }
+ }
+}
diff --git a/deploy/separated/smoke.ps1 b/deploy/separated/smoke.ps1
new file mode 100644
index 000000000000..cb03ef90575a
--- /dev/null
+++ b/deploy/separated/smoke.ps1
@@ -0,0 +1,80 @@
+# Same-origin separated stack smoke checks (Windows PowerShell).
+# Usage:
+# powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy\separated\smoke.ps1
+# powershell -NoProfile -ExecutionPolicy Bypass -File .\deploy\separated\smoke.ps1 -FrontendBase http://127.0.0.1:8080
+param(
+ [string]$FrontendBase = 'http://127.0.0.1:8080'
+)
+
+$ErrorActionPreference = 'Stop'
+$FrontendBase = $FrontendBase.TrimEnd('/')
+$pass = 0
+$fail = 0
+
+function Invoke-Check {
+ param(
+ [string]$Name,
+ [scriptblock]$Body
+ )
+ try {
+ & $Body
+ Write-Host "PASS $Name"
+ $script:pass++
+ } catch {
+ Write-Host "FAIL $Name : $($_.Exception.Message)"
+ $script:fail++
+ }
+}
+
+Write-Host "Smoke against $FrontendBase"
+
+Invoke-Check 'frontend-healthz' {
+ $r = Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/frontend-healthz" -TimeoutSec 15
+ if ($r.StatusCode -ne 200) { throw "status $($r.StatusCode)" }
+ if ($r.Content -notmatch '"status"\s*:\s*"ok"') { throw 'missing status ok' }
+}
+
+Invoke-Check 'spa index' {
+ $r = Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/" -TimeoutSec 15
+ if ($r.StatusCode -ne 200) { throw "status $($r.StatusCode)" }
+}
+
+Invoke-Check 'api status via proxy' {
+ $r = Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/api/status" -TimeoutSec 15
+ if ($r.StatusCode -ne 200) { throw "status $($r.StatusCode)" }
+ if ($r.Content -notmatch '\{') { throw 'non-json body' }
+}
+
+Invoke-Check 'v1 without token is 401' {
+ try {
+ Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/v1/models" -TimeoutSec 15 | Out-Null
+ throw 'expected 401'
+ } catch {
+ $resp = $_.Exception.Response
+ if (-not $resp) { throw $_ }
+ $code = [int]$resp.StatusCode
+ if ($code -ne 401) { throw "status $code" }
+ }
+}
+
+Invoke-Check 'readyz via proxy' {
+ $r = Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/readyz" -TimeoutSec 15
+ if ($r.StatusCode -ne 200 -and $r.StatusCode -ne 503) { throw "status $($r.StatusCode)" }
+ if ($r.Content -notmatch '"status"') { throw 'missing status field' }
+}
+
+Invoke-Check 'metrics blocked on edge' {
+ try {
+ Invoke-WebRequest -UseBasicParsing -Uri "$FrontendBase/metrics" -TimeoutSec 15 | Out-Null
+ throw 'expected 404'
+ } catch {
+ $resp = $_.Exception.Response
+ if (-not $resp) { throw $_ }
+ $code = [int]$resp.StatusCode
+ if ($code -ne 404) { throw "status $code" }
+ }
+}
+
+Write-Host ""
+Write-Host "passed=$pass failed=$fail"
+if ($fail -ne 0) { exit 1 }
diff --git a/deploy/separated/smoke.sh b/deploy/separated/smoke.sh
new file mode 100755
index 000000000000..f0ae4b2409d8
--- /dev/null
+++ b/deploy/separated/smoke.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+# Same-origin separated stack smoke checks.
+# Usage:
+# FRONTEND_BASE=http://127.0.0.1:8080 ./deploy/separated/smoke.sh
+set -euo pipefail
+
+FRONTEND_BASE="${FRONTEND_BASE:-http://127.0.0.1:8080}"
+FRONTEND_BASE="${FRONTEND_BASE%/}"
+
+pass=0
+fail=0
+
+check() {
+ local name="$1"
+ shift
+ if "$@"; then
+ echo "PASS ${name}"
+ pass=$((pass + 1))
+ else
+ echo "FAIL ${name}"
+ fail=$((fail + 1))
+ fi
+}
+
+body_has() {
+ local url="$1"
+ local needle="$2"
+ local code
+ local body
+ body="$(curl -fsS --max-time 15 "${url}")" || return 1
+ printf '%s' "${body}" | grep -q "${needle}"
+}
+
+http_code() {
+ local method="$1"
+ local url="$2"
+ local expect="$3"
+ local code
+ code="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 -X "${method}" "${url}")" || return 1
+ test "${code}" = "${expect}"
+}
+
+echo "Smoke against ${FRONTEND_BASE}"
+check "frontend-healthz" body_has "${FRONTEND_BASE}/frontend-healthz" '"status":"ok"'
+check "spa index" http_code GET "${FRONTEND_BASE}/" 200
+check "api status via proxy" body_has "${FRONTEND_BASE}/api/status" '{'
+check "v1 without token is 401" http_code GET "${FRONTEND_BASE}/v1/models" 401
+check "readyz via proxy" body_has "${FRONTEND_BASE}/readyz" '"status"'
+check "metrics blocked on edge" http_code GET "${FRONTEND_BASE}/metrics" 404
+
+echo
+echo "passed=${pass} failed=${fail}"
+test "${fail}" -eq 0
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index f98f4b0d8d1d..a9cbdca96027 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -1,10 +1,12 @@
-# Frontend Development - Backend built from local source
+# Frontend Development - Backend built from local source (pure API, no embedded SPA)
#
# Usage:
# 1. docker compose -f docker-compose.dev.yml up -d
# 2. cd web && bun install && bun run dev
# 3. Open http://localhost:3001 (Rsbuild dev server, API auto-proxied to :3000)
#
+# Backend image uses Dockerfile.dev (-tags frontend_external, FRONTEND_MODE=disabled).
+#
# Rebuild backend after Go code changes:
# docker compose -f docker-compose.dev.yml up -d --build new-api
#
@@ -13,7 +15,6 @@
#
# Reset data:
# docker compose -f docker-compose.dev.yml down -v
-
services:
new-api:
build:
@@ -31,10 +32,14 @@ services:
- REDIS_CONN_STRING=redis://redis
- TZ=Asia/Shanghai
- BATCH_UPDATE_ENABLED=true
+ # Pure API for local bun dev (web proxies to :3000). Override to embedded only with integrated image.
+ - FRONTEND_MODE=disabled
# Enable only when accessing the dev backend through HTTPS. SESSION_COOKIE_TRUSTED_URL is required when true.
# - SESSION_COOKIE_SECURE=true
# - SESSION_COOKIE_TRUSTED_URL=https://example.com,https://admin.example.com
- depends_on:
+ # When bun/vite proxies from another host, set CORS or use same-origin proxy only.
+ # - CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3001
+ # - TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128 depends_on:
redis:
condition: service_started
postgres:
diff --git a/docker-compose.yml b/docker-compose.yml
index f5881f4a24cc..1eee05027017 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,7 +1,7 @@
# New-API Docker Compose Configuration
#
# Quick Start:
-# 1. docker-compose up -d
+# 1. docker compose up -d
# 2. Access at http://localhost:3000
#
# Using MySQL instead of PostgreSQL:
@@ -10,13 +10,11 @@
# 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
+# Required environment: NEW_API_IMAGE, POSTGRES_PASSWORD, REDIS_PASSWORD.
services:
new-api:
- image: calciumion/new-api:latest
+ image: ${NEW_API_IMAGE:?Set NEW_API_IMAGE to a versioned image or digest}
container_name: new-api
restart: always
command: --log-dir /app/logs
@@ -26,15 +24,20 @@ 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
-# - LOG_SQL_DSN=postgresql://root:123456@postgres:5432/new-api-log # OPTIONAL: If you want a separate database for logging, uncomment and set this
-# - LOG_SQL_DSN=clickhouse://default:123456@clickhouse:9000/new_api_logs # OPTIONAL: Use ClickHouse for logs only; also uncomment clickhouse in depends_on and the clickhouse service below
+ - SQL_DSN=postgresql://${POSTGRES_USER:-newapi}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-new-api}
+# - SQL_DSN=${MYSQL_USER:-newapi}:${MYSQL_PASSWORD:?Set MYSQL_PASSWORD}@tcp(mysql:3306)/${MYSQL_DATABASE:-new-api} # Point to the mysql service, uncomment if using MySQL
+# - LOG_SQL_DSN=postgresql://${POSTGRES_USER:-newapi}:${POSTGRES_LOG_PASSWORD:?Set POSTGRES_LOG_PASSWORD}@postgres:5432/new-api-log # OPTIONAL: Use a separate database/user in production
+# - LOG_SQL_DSN=clickhouse://${CLICKHOUSE_USER:-newapi}:${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD}@clickhouse:9000/new_api_logs # OPTIONAL: Use ClickHouse for logs only
# - LOG_SQL_CLICKHOUSE_TTL_DAYS=0 # OPTIONAL: ClickHouse log retention days. Unset or 0 disables automatic deletion; set to e.g. 30 to keep 30 days
- - REDIS_CONN_STRING=redis://:123456@redis:6379 # ⚠️ IMPORTANT: Change the password in production!
+ - REDIS_CONN_STRING=redis://:${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379
- TZ=Asia/Shanghai
- ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording)
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
+ - RUN_MODE=${RUN_MODE:-all}
+ - APP_PLANE=${APP_PLANE:-all}
+ - METRICS_ENABLED=${METRICS_ENABLED:-false}
+ - METRICS_TOKEN=${METRICS_TOKEN:-}
+# - TRUSTED_PROXY_CIDRS=172.16.0.0/12 # Set only to the actual reverse-proxy network; unset ignores forwarded IP headers
- NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers)
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions)
# - RELAY_IDLE_CONN_TIMEOUT=90 # Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制 (Relay HTTP client idle keep-alive timeout in seconds, defaults to Go standard library; set 0 to disable)
@@ -54,27 +57,27 @@ services:
networks:
- new-api-network
healthcheck:
- test: ["CMD-SHELL", "wget -q -O - http://localhost:3000/api/status | grep -o '\"success\":\\s*true' || exit 1"]
+ test: ["CMD-SHELL", "if [ '${RUN_MODE:-all}' = 'all' ] || [ '${RUN_MODE:-all}' = 'serve' ]; then wget -q -O - http://localhost:3000/readyz | grep -q '\"status\":\"ok\"'; else kill -0 1; fi"]
interval: 30s
timeout: 10s
retries: 3
redis:
- image: redis:latest
+ image: redis:7.4.2-alpine
container_name: redis
restart: always
- command: ["redis-server", "--requirepass", "123456"] # ⚠️ IMPORTANT: Change this password in production!
+ command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?Set REDIS_PASSWORD}"]
networks:
- new-api-network
postgres:
- image: postgres:15
+ image: postgres:15.10-alpine
container_name: postgres
restart: always
environment:
- POSTGRES_USER: root
- POSTGRES_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
- POSTGRES_DB: new-api
+ POSTGRES_USER: ${POSTGRES_USER:-newapi}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB:-new-api}
volumes:
- pg_data:/var/lib/postgresql/data
networks:
@@ -83,12 +86,14 @@ services:
# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker
# mysql:
-# image: mysql:8.2
+# image: mysql:8.4.6
# container_name: mysql
# restart: always
# environment:
-# MYSQL_ROOT_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
-# MYSQL_DATABASE: new-api
+# MYSQL_USER: ${MYSQL_USER:-newapi}
+# MYSQL_PASSWORD: ${MYSQL_PASSWORD:?Set MYSQL_PASSWORD}
+# MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Set MYSQL_ROOT_PASSWORD}
+# MYSQL_DATABASE: ${MYSQL_DATABASE:-new-api}
# volumes:
# - mysql_data:/var/lib/mysql
# networks:
@@ -97,13 +102,13 @@ services:
# - "3306:3306" # Uncomment if you need to access MySQL from outside Docker
# clickhouse:
-# image: clickhouse/clickhouse-server:24.8
+# image: clickhouse/clickhouse-server:24.8.14.39-alpine
# container_name: clickhouse
# restart: always
# environment:
# CLICKHOUSE_DB: new_api_logs
-# CLICKHOUSE_USER: default
-# CLICKHOUSE_PASSWORD: 123456 # ⚠️ IMPORTANT: Change this password in production!
+# CLICKHOUSE_USER: ${CLICKHOUSE_USER:-newapi}
+# CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:?Set CLICKHOUSE_PASSWORD}
# CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
# volumes:
# - clickhouse_data:/var/lib/clickhouse
diff --git a/docs/adr/0001-frontend-backend-delivery-seam.md b/docs/adr/0001-frontend-backend-delivery-seam.md
new file mode 100644
index 000000000000..678df71bb364
--- /dev/null
+++ b/docs/adr/0001-frontend-backend-delivery-seam.md
@@ -0,0 +1,103 @@
+# ADR 0001: Frontend/backend delivery seam
+
+- Status: Accepted
+- Date: 2026-07-18
+- Context: monorepo `new-api` historically embeds dual React themes into a single Go binary
+
+## Decision summary
+
+Keep a **single Git repository**. Keep the **embedded dual-theme binary as the default compatibility path**. Add an explicit **delivery seam** so operators can also ship:
+
+1. a pure Go backend (`-tags frontend_external` + `FRONTEND_MODE=disabled|redirect`)
+2. a standalone default-theme SPA served by Nginx that reverse-proxies API/Relay traffic on the **same public origin**
+
+## Why not two repositories
+
+- Product, release tags, `VERSION`, CI quality gates, and operational docs already share one revision.
+- Dual-repo split would force synchronized versioning for SPA ↔ API contracts (`/api/status`, OAuth callbacks, cookie names) without removing the need for integration tests.
+- Contributors already navigate `router/`, `controller/`, and `web/default/` in one tree; the cost of monorepo coupling is lower than the cost of split release coordination for this product stage.
+
+## Why keep embedded by default
+
+- Existing Windows single-exe, Docker Compose, and Electron-style distributions depend on one artifact.
+- Rollback and incident response stay simple: replace one binary/image.
+- The new seam is opt-in; operators who do not set `FRONTEND_MODE` or the build tag keep prior behavior (`auto` + embed).
+
+## Build tag: `frontend_external`
+
+| File | Build constraint | Role |
+|---|---|---|
+| `frontend_assets_embedded.go` | `//go:build !frontend_external` | `//go:embed` both themes; inject analytics into index HTML |
+| `frontend_assets_external.go` | `//go:build frontend_external` | Returns empty `ThemeAssets` |
+
+`main` always calls `prepareFrontendAssets()` then `router.SetRouterForPlane(...)`. Embedded mode refuses empty assets via `ThemeAssets.Available()` so a pure-backend binary cannot panic inside `EmbedFolder`.
+
+## Runtime: `FRONTEND_MODE`
+
+| Value | Semantics |
+|---|---|
+| `auto` | Legacy: non-master + `FRONTEND_BASE_URL` → redirect; otherwise embed |
+| `embedded` | Force embed; error if assets missing |
+| `redirect` | Force redirect to origin `FRONTEND_BASE_URL` even on master |
+| `disabled` | No web `NoRoute`; pure API 404 for unknown paths |
+
+`FRONTEND_BASE_URL` in redirect mode must be an absolute HTTP(S) origin: no userinfo, path (except empty/`/`), query, or fragment. Redirects preserve `RequestURI` (path + query).
+
+## Why same-origin frontend→backend proxy is recommended
+
+Preferred production layout:
+
+```text
+Public origin (HTTPS)
+ └── Nginx frontend container
+ ├── static SPA (/ and /assets)
+ └── reverse proxy → backend:3000 for
+ /api /v1 /v1beta /mj /:mode/mj /pg /suno /kling /jimeng
+ /healthz /livez /readyz
+```
+
+Consequences:
+
+| Concern | Same-origin proxy | Cross-origin SPA + API |
+|---|---|---|
+| Session cookies | First-party; existing `SESSION_COOKIE_*` keys stay sufficient | Needs careful `SameSite`, domain, and often broader CORS allowlist with credentials |
+| CSRF | Same site model preserved | Cross-site form/fetch surface expands |
+| OAuth callbacks | Single public host | Must register extra redirect URIs and trusted URLs |
+| SSE | `proxy_buffering off` + long timeouts on one host | Browser CORS + buffering at each edge hop |
+| WebSocket `/v1/realtime` | `Upgrade` / `Connection` on same host | Extra origin checks and sticky proxy rules |
+
+`/metrics` is **not** proxied on the public frontend edge; scrape on the backend network with `METRICS_TOKEN`.
+
+## Images and CI
+
+- `Dockerfile` — integrated (unchanged default)
+- `Dockerfile.backend` — Go 1.26.5 builder, Debian runtime, `-tags frontend_external`, default `FRONTEND_MODE=disabled`, no Bun
+- `deploy/separated/Dockerfile.frontend` — Bun 1.3.14 (pinned digest) builds `web/default`; `nginxinc/nginx-unprivileged` on 8080
+- Quality workflow builds all three images and runs `nginx -t` on the rendered frontend config
+
+## Rollback
+
+1. **Config only:** point the edge back to an integrated binary/image; unset `FRONTEND_MODE` or set `auto`.
+2. **Artifact only:** redeploy the previous integrated image digest/tag from the release inventory.
+3. **DB:** migrations remain additive; no schema down-migration is required for this delivery change.
+
+## Alternatives considered
+
+1. **Two repositories** — rejected for release coupling (see above).
+2. **Backend serves SPA from volume mount without embed** — possible later; current seam prefers an immutable frontend image for cache headers and independent scaling.
+3. **Default to separated images** — rejected; would break existing single-artifact operators without notice.
+4. **Expand CORS as the primary multi-host strategy** — allowed via existing middleware for deliberate multi-origin setups, not recommended as the default path for cookie sessions.
+
+## Consequences
+
+- Positive: independent frontend deploys, smaller pure-backend images, clearer APP_PLANE/RUN_MODE + UI delivery matrix.
+- Negative: two more Dockerfiles and CI image builds; operators must set `TRUSTED_PROXY_CIDRS` correctly when Nginx sits in front.
+- Neutral: classic theme is still embedded in the integrated path; separated image currently ships default theme only (classic remains available via integrated build or a future sibling image).
+
+## References
+
+- `frontend_assets_embedded.go` / `frontend_assets_external.go`
+- `router/main.go` (`parseFrontendMode`, `setFrontendRouter`, …)
+- `deploy/separated/README.md`
+- `docs/operations/runtime-separation.md`
+- `docs/operations/build-and-release.md`
diff --git a/docs/operations/build-and-release.md b/docs/operations/build-and-release.md
new file mode 100644
index 000000000000..7015af99e231
--- /dev/null
+++ b/docs/operations/build-and-release.md
@@ -0,0 +1,71 @@
+# Build and release boundary
+
+## Authoritative source
+
+Build and release the customized application only from the Git repository at `D:\newapi\src`.
+The sibling `D:\newapi\_qn_tmp` directory is an upstream reference clone. It is not a release source
+and must not be mixed into the build context.
+
+## Version rules
+
+- `VERSION` is a non-empty development fallback.
+- Tagged release workflows use `git describe --tags` and inject the resolved value into
+ `github.com/QuantumNous/new-api/common.Version`.
+- Go VCS metadata must remain enabled so `go version -m ` records the source revision and
+ whether the source tree was modified.
+- Go and Bun versions are pinned in quality and release workflows.
+
+## Delivery artifacts
+
+| Artifact | How to build | Frontend assets |
+|---|---|---|
+| Integrated binary / `Dockerfile` | Default `go build` after building both web themes | Embedded dual theme |
+| Pure backend / `Dockerfile.backend` | `go build -tags frontend_external` | None; set `FRONTEND_MODE=disabled` or `redirect` |
+| Frontend SPA / `deploy/separated/Dockerfile.frontend` | Bun build of `web/default` + Nginx | Static only; proxies API to backend |
+
+Quality CI exercises all three image paths plus `nginx -t` on the rendered frontend config.
+The integrated image remains the default compatibility path.
+
+Local pure-backend example:
+
+```powershell
+go build -trimpath -buildvcs=true -tags frontend_external -o new-api-backend.exe .
+$env:FRONTEND_MODE = 'disabled'
+.\new-api-backend.exe
+```
+
+Separated compose example (from repo root):
+
+```bash
+make docker-separated
+docker compose -f deploy/separated/docker-compose.yml build
+FRONTEND_BASE=http://127.0.0.1:8080 ./deploy/separated/smoke.sh
+```
+
+Frontend runtime base pinning: quality CI resolves `nginxinc/nginx-unprivileged:1.27-alpine` to a
+digest and passes `NGINX_IMAGE` as a build-arg. Prefer that digest when freezing production images.
+
+## Windows release evidence
+
+From `D:\newapi\src`, build a release and generate its evidence files:
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-release.ps1
+```
+
+To inventory an existing binary without rebuilding it:
+
+```powershell
+powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-release.ps1 `
+ -ExistingBinary D:\newapi\new-api-fixed.exe `
+ -OutputDirectory D:\newapi\release-manifests `
+ -AllowDirty
+```
+
+The script writes a SHA-256 file, Go build/dependency inventory, and JSON manifest. It verifies the
+embedded VCS revision against the current authoritative repository HEAD. The Go inventory is useful
+traceability evidence but is not a standardized SBOM; release signing and a pinned SBOM generator
+remain separate release requirements.
+
+Official builds require a clean working tree. `-AllowDirty` exists only for diagnostics and current
+binary inventory.
diff --git a/docs/operations/cleanup-after-healthcheck-20260718.md b/docs/operations/cleanup-after-healthcheck-20260718.md
new file mode 100644
index 000000000000..eb4fa047ee38
--- /dev/null
+++ b/docs/operations/cleanup-after-healthcheck-20260718.md
@@ -0,0 +1,29 @@
+KEEP logout-fix2-20260718 135.6MB (current prod artifact)
+DEL 135.6MB D:\newapi\release-manifests\logout-fix-20260718
+DEL 135.6MB D:\newapi\release-manifests\hsts-prod-20260718
+DEL 135.6MB D:\newapi\release-manifests\noroute-fix-20260718
+DEL 135.9MB D:\newapi\release-manifests\zh-unwrap-20260718
+DEL 0.01MB D:\newapi\docs\_run_healthcheck.py
+DEL 0.00MB D:\newapi\docs\_healthcheck-raw.csv
+TOTAL_FREED_MB=542.8
+--- remaining top ---
+.agents 0.0 MB
+.env 0.0 MB
+.git 0.0 MB
+_qn_tmp 36.1 MB
+_qn_tmp.UPSTREAM-REFERENCE.md 0.0 MB
+backups 182.3 MB
+data 27.0 MB
+docs 0.3 MB
+logs 0.3 MB
+new-api-fixed.exe 135.6 MB
+overview.md 0.0 MB
+register-task-com.ps1 0.0 MB
+register-task.bat 0.0 MB
+release-manifests 135.6 MB
+run-tunnel.bat 0.0 MB
+scripts 0.0 MB
+src 2052.2 MB
+start-newapi-hidden.ps1 0.0 MB
+swap-restart-opt3.ps1 0.0 MB
+tools 10.1 MB
\ No newline at end of file
diff --git a/docs/operations/cloudflare-hsts-runbook.md b/docs/operations/cloudflare-hsts-runbook.md
new file mode 100644
index 000000000000..a7e3b7bc2c53
--- /dev/null
+++ b/docs/operations/cloudflare-hsts-runbook.md
@@ -0,0 +1,73 @@
+# Cloudflare HSTS 操作说明(控制台,不改应用代码)
+
+- **日期**:2026-07-18
+- **Zone**:`incc.qzz.io`(Zone ID `5809d3b745bd78542b59f0d852a66167`,Account `1f96bc464a296cbf14ed104073ccba08`)
+- **Tunnel**:`incc-newapi` → `http://localhost:3000`
+- **为何本机未自动打开 HSTS**:
+ - Wrangler OAuth token **无** `zone_settings:edit`(读 `security_header` 返回 403 Unauthorized)。
+ - 浏览器自动化打开 `dash.cloudflare.com` 被 **CF 人机验证**拦截。
+- **推荐**:你在已登录的浏览器中按下列点击开启(**不改 new-api 代码、不重启 exe**)。
+
+---
+
+## 推荐参数(对单域名生产站)
+
+| 项 | 建议值 | 说明 |
+|----|--------|------|
+| Enable HSTS (Strict-Transport-Security) | **On** | 浏览器强制 HTTPS |
+| Max Age Header | **6 months**(15768000)或 12 months | 首次可 6 个月,稳定后 12 个月 |
+| Apply HSTS policy to subdomains (includeSubDomains) | **Off**(若只有 `incc.qzz.io` 且无子域业务) | 有 `www` 且均 HTTPS 才考虑 On |
+| Preload | **Off**(除非你明确要提交 HSTS preload 列表) | 误开会很难撤销 |
+| No-Sniff header | 可 On | 与 HSTS 同页常见选项 |
+
+同时确认:
+
+- SSL/TLS → Overview:**Full (strict)**
+- SSL/TLS → Edge Certificates:**Always Use HTTPS = On**(你侧 HTTP 已 301,多半已开)
+
+---
+
+## 点击路径
+
+1. 登录 https://dash.cloudflare.com
+2. 选择站点 **incc.qzz.io**
+3. 左侧 **SSL/TLS** → **Edge Certificates**
+4. 找到 **HTTP Strict Transport Security (HSTS)** → **Enable HSTS**
+5. 按上表设置 → Save
+6. 验证(任选):
+
+```bash
+curl -sSI https://incc.qzz.io/ | findstr /i strict
+```
+
+期望类似:
+
+```text
+strict-transport-security: max-age=15768000
+```
+
+(具体 max-age 以你选的为准;若在 CF 开,头由 CF 注入,源站 new-api 无需改。)
+
+---
+
+## 回滚
+
+同一页面关闭 HSTS 或把 max-age 调为 0;已缓存 HSTS 的浏览器会保留到 max-age 到期。
+
+---
+
+## 自动化缺口(可选后续)
+
+若希望脚本开关 HSTS,创建 **API Token** 权限至少:
+
+- Zone → Zone Settings → Edit
+- Zone → Zone → Read
+
+绑定 zone `incc.qzz.io` 后:
+
+```http
+PATCH /zones/{zone_id}/settings/security_header
+```
+
+body 使用 CF 文档中的 `strict_transport_security` 结构。
+**不要**把 token 写进仓库或聊天记录。
diff --git a/docs/operations/cookie-https-readonly-checklist.md b/docs/operations/cookie-https-readonly-checklist.md
new file mode 100644
index 000000000000..ebbd1924ee23
--- /dev/null
+++ b/docs/operations/cookie-https-readonly-checklist.md
@@ -0,0 +1,301 @@
+# Cookie / HTTPS 只读核对清单
+
+- **日期**:2026-07-18
+- **环境**:生产 `D:\newapi` + 公网 `https://incc.qzz.io`
+- **运行版本**:`v1.0.0-rc.21-34-gbbddd729` / PID **32544**
+- **方法**:只读;**不输出** `.env` 密钥/SESSION_SECRET 值;不改配置、不重启服务、不登录真实账号写 Cookie
+- **权威源码行为**:
+ - `common/session_cookie.go` — `SESSION_COOKIE_SECURE` + `SESSION_COOKIE_TRUSTED_URL` 启动校验
+ - `main.go` — `sessions.Options{ Path:/, MaxAge:30d, HttpOnly:true, Secure:env, SameSite:Strict }`
+ - `trusted_proxy.go` — `TRUSTED_PROXY_CIDRS` 控制 Gin 是否信任 `X-Forwarded-For`
+
+---
+
+## 0. 执行摘要
+
+| 项 | 结论 | 等级 |
+|----|------|------|
+| Cookie 配置键名 | 正确使用 `SESSION_COOKIE_SECURE` / `SESSION_COOKIE_TRUSTED_URL`(无错误 `SESSION_SECURE`) | 通过 |
+| Secure Cookie 已启用 | `SESSION_COOKIE_SECURE=TRUE`;近期启动日志**无** “Session cookie is not secure” 警告 | 通过 |
+| Trusted URL 形态 | 1 条:`https://incc.qzz.io`(https、有 host、无 userinfo/query) | 通过 |
+| 与公网入口一致性 | 公网 host = `incc.qzz.io`,与 Trusted URL host 一致 | 通过 |
+| HTTPS 可达 | `https://incc.qzz.io/*` 200;应用版本头匹配生产 | 通过 |
+| HTTP→HTTPS | `http://incc.qzz.io/` → **301** `https://incc.qzz.io/`(Cloudflare) | 通过 |
+| TLS 证书 | Let's Encrypt,CN=`incc.qzz.io`,有效至 **2026-09-25** | 通过(注意续期) |
+| HSTS | 响应中 **无** `Strict-Transport-Security` | **缺口** |
+| 直连 3000 | 监听 `::`;防火墙规则 **Block direct TCP 3000** 已启用 | 通过 |
+| Tunnel | 服务 `Cloudflared` Running;进程存在 | 通过 |
+| Trusted Proxy CIDR | 仅 `127.0.0.1/32`、`::1/128`(适合本机 cloudflared 回源) | 通过(在「仅本机回源」假设下) |
+| 登录后 Set-Cookie 实测 | **未做**(避免真实登录);属性依赖源码常量 | 残余风险 |
+
+**总体**:生产 Cookie/HTTPS **配置与入口对齐良好**,可认为 Secure Cookie 链路在配置层已闭环。剩余主要是 **HSTS 未开**、**登录后 Cookie 属性需浏览器人工点验**、以及 **CF Access/WAF 策略未导出**。
+
+---
+
+## 1. 源码契约(应如何配)
+
+### 1.1 环境变量
+
+| 变量 | 约束(源码) |
+|------|----------------|
+| `SESSION_COOKIE_SECURE` | 仅 `true` / `false` / 空。空或 `false` 时 **禁止** 再设 Trusted URL |
+| `SESSION_COOKIE_TRUSTED_URL` | `SECURE=true` 时**必填**;逗号分隔;每项必须是 **https + host** |
+| `SESSION_SECRET` | 会话签名密钥(本清单不读值;仅确认 nonEmpty) |
+| `TRUSTED_PROXY_CIDRS` | 逗号分隔 CIDR;空 = 完全不信任转发头 |
+
+### 1.2 运行时 Cookie 选项(`main.go`)
+
+| 属性 | 值 |
+|------|-----|
+| Name | `session` |
+| Path | `/` |
+| MaxAge | 2592000(30 天) |
+| HttpOnly | **true** |
+| Secure | **`SESSION_COOKIE_SECURE`** |
+| SameSite | **Strict** |
+
+### 1.3 Trusted URL 的作用边界
+
+启动时校验 Trusted URL 列表并写入 `SessionCookieTrustedURLs`。
+本审计范围内:**Secure 开关本身由 `SESSION_COOKIE_SECURE` 全局决定**,不是按请求 Host 动态切换。Trusted URL 用于强制「开 Secure 时必须声明可信 HTTPS 入口」。
+
+---
+
+## 2. 生产 `.env` 只读结果(无密钥)
+
+### 2.1 键存在性
+
+| 键 | present | nonEmpty |
+|----|---------|----------|
+| `SESSION_SECRET` | 是 | 是 |
+| `SESSION_COOKIE_SECURE` | 是 | 是 |
+| `SESSION_COOKIE_TRUSTED_URL` | 是 | 是 |
+| `TRUSTED_PROXY_CIDRS` | 是 | 是 |
+| `PORT` | 是 | 是 |
+| `SQLITE_PATH` | 是 | 是 |
+| `FRONTEND_BASE_URL` | **否** | — |
+| `CORS_ALLOWED_ORIGINS` | **否** | — |
+| `METRICS_ENABLED` | **否** | — |
+| `METRICS_TOKEN` | **否** | — |
+
+说明:
+
+- 未设 `FRONTEND_BASE_URL` / `CORS_ALLOWED_ORIGINS` 符合**同源一体化**部署,合理。
+- Metrics 默认关,符合当前 `/metrics` 404 行为。
+
+### 2.2 布尔与形态(安全可展示)
+
+| 项 | 结果 |
+|----|------|
+| `SESSION_COOKIE_SECURE` | **TRUE** |
+| `SESSION_COOKIE_TRUSTED_URL` 条数 | **1** |
+| 条目 1 | scheme=`https` host=`incc.qzz.io` port=443 path=`/` userinfo=否 query=否 absolute=是 |
+| `TRUSTED_PROXY_CIDRS` | `127.0.0.1/32`,`::1/128` |
+
+### 2.3 与错误配置对照
+
+| 历史问题 | 本环境 |
+|----------|--------|
+| 脚本写 `SESSION_SECURE`(源码不读) | `.env` **无**该键 |
+| Secure=true 但缺 Trusted URL | **未出现**(两者皆 nonEmpty) |
+| Trusted URL 用 http | **未出现**(仅 https) |
+
+---
+
+## 3. 传输与入口
+
+### 3.1 HTTPS / HTTP
+
+| 检查 | 结果 |
+|------|------|
+| `https://incc.qzz.io/livez` | 200 JSON;`x-new-api-version=v1.0.0-rc.21-34-gbbddd729` |
+| `https://incc.qzz.io/` | 200 HTML;`Cache-Control: no-cache` |
+| `https://incc.qzz.io/api/status` | 200 JSON |
+| `https://incc.qzz.io/sign-in` | 200 HTML |
+| `http://incc.qzz.io/` | **301** → `https://incc.qzz.io/`(Cloudflare) |
+| `http://incc.qzz.io/livez` | 502(非 HTTPS 路径;以 301 首页为准) |
+| `Strict-Transport-Security` | **响应中缺失** |
+
+### 3.2 TLS 证书
+
+| 项 | 值 |
+|----|-----|
+| Subject | CN=`incc.qzz.io` |
+| Issuer | Let's Encrypt (YE2) |
+| 有效期 | 2026-06-27 → **2026-09-25** |
+| 算法 | sha384ECDSA |
+| 剩余天数(审计日 2026-07-18) | 约 **69 天** |
+
+建议:确认 cloudflared/源站或 CF 自动续期;到期前 14 天再查一次。
+
+### 3.3 边缘与进程
+
+| 项 | 结果 |
+|----|------|
+| Cloudflare | `Server: cloudflare`,有 `CF-RAY` |
+| 本机 Tunnel | 服务 `Cloudflared` = Running;进程 cloudflared 存在 |
+| 应用监听 | `:: :3000` Listen(PID 32544) |
+| 防火墙 | 规则 **「New API - Block direct TCP 3000」Inbound Block Enabled** |
+| 本机回环 | `127.0.0.1:3000` 可连(预期,供 Tunnel/本机) |
+
+---
+
+## 4. Cookie 属性(代码 + 运行时缺口)
+
+### 4.1 代码保证(生产已 SECURE=true)
+
+登录成功后发出的 `session` Cookie **应**为:
+
+- `HttpOnly`
+- `Secure`
+- `SameSite=Strict`
+- `Path=/`
+- Max-Age ≈ 30 天
+
+### 4.2 本清单未完成的实测
+
+| 检查 | 状态 | 原因 |
+|------|------|------|
+| 真实登录后 DevTools 看 `Set-Cookie` | **未做** | 避免使用真实账号/写入会话 |
+| 错误密码是否 Set-Cookie | **未做** | 同上(可选用一次性测试号) |
+
+**推荐人工 2 分钟验证(你本机浏览器):**
+
+1. 打开 `https://incc.qzz.io/sign-in`(无痕窗口)。
+2. 登录成功后 F12 → Application → Cookies → `https://incc.qzz.io`。
+3. 核对 `session`:
+ - Secure = ✅
+ - HttpOnly = ✅
+ - SameSite = **Strict**
+ - Path = `/`
+4. 用 `http://` 无法保留该 Cookie(浏览器应拒绝 Secure Cookie)。
+
+### 4.3 未认证请求
+
+| URL | Set-Cookie |
+|-----|------------|
+| `https://incc.qzz.io/api/user/self` 401 | 无 |
+| `http://127.0.0.1:3000/api/user/self` 401 | 无 |
+| `/livez` `/api/status` `/` | 无 |
+
+符合「未建会话不写 Cookie」。
+
+---
+
+## 5. 代理信任与客户端 IP
+
+| 项 | 结果 |
+|----|------|
+| `TRUSTED_PROXY_CIDRS` | 仅 loopback |
+| 含义 | 仅当请求来自 127.0.0.1/::1 时,Gin 才采信 `X-Forwarded-For` / `X-Real-IP` |
+| 与 Tunnel 匹配度 | cloudflared 本机回源时 **正确**;若改为局域网反代/多跳,需把反代出口 CIDR 写进列表 |
+
+**风险**:CIDR 过宽 → IP 伪造;过窄且反代不在 loopback → 审计 IP 全是反代地址。当前「本机 tunnel」模型合适。
+
+---
+
+## 6. CORS / 同源
+
+| 项 | 结果 |
+|----|------|
+| `CORS_ALLOWED_ORIGINS` | 未配置 |
+| `FRONTEND_BASE_URL` | 未配置 |
+| 部署形态 | 一体化 + 公网单 origin `https://incc.qzz.io` |
+
+结论:Cookie 会话走**第一方同源**,无需为控制台扩大 CORS。若将来前后端分离且不同 origin,必须重开 CORS + 再审 SameSite/Trusted URL。
+
+---
+
+## 7. 核对矩阵(勾选表)
+
+### A. 配置层(本机已代勾)
+
+- [x] 使用 `SESSION_COOKIE_SECURE` 而非 `SESSION_SECURE`
+- [x] `SESSION_COOKIE_SECURE=true`
+- [x] `SESSION_COOKIE_TRUSTED_URL` 非空且全为 https
+- [x] Trusted host 与公网入口 host 一致(`incc.qzz.io`)
+- [x] `SESSION_SECRET` 存在且非空(值未读)
+- [x] 启动日志无 “Session cookie is not secure”
+- [x] `TRUSTED_PROXY_CIDRS` 存在;当前为 loopback
+- [x] 公网 HTTPS 200 且版本头匹配
+- [x] HTTP 首页 301 到 HTTPS
+- [x] 3000 有 inbound block
+- [x] cloudflared 在跑
+- [ ] **HSTS 响应头**(未通过)
+- [ ] **登录后 Cookie 属性浏览器点验**(待人工)
+- [ ] **CF Access / WAF / Tunnel 路由只读导出**(未取控制台)
+- [ ] **证书续期机制确认**(到期 2026-09-25)
+
+### B. 建议的人工 / 控制台项
+
+1. **浏览器 Cookie 点验**(见 §4.2)
+2. **Cloudflare Dashboard**(只读截图/导出):
+ - SSL/TLS 模式(建议 Full (strict))
+ - Always Use HTTPS
+ - HSTS 是否在 CF 层开启(可补源站缺失)
+ - Tunnel Public Hostname → `http://127.0.0.1:3000`
+ - 是否有 Access 策略覆盖管理路径
+3. **OAuth 回调**(若启用 GitHub/OIDC 等):回调 URL 是否仅为 `https://incc.qzz.io/...`
+4. **多入口**:若还有自定义域,必须追加到 `SESSION_COOKIE_TRUSTED_URL`(改配置需确认后重启)
+
+---
+
+## 8. 发现项与优先级
+
+| ID | 发现 | 等级 | 建议 |
+|----|------|------|------|
+| C1 | 无 HSTS 响应头 | P1 | 在 Cloudflare 启用 HSTS(含 includeSubDomains 需谨慎);或源站中间件添加(改代码/反代) |
+| C2 | 登录后 Set-Cookie 未在本清单实测 | P1 | 管理员无痕登录点验 §4.2 |
+| C3 | CF Access/WAF 无导出证据 | P1 | 控制台只读导出归档 |
+| C4 | LE 证书 ~69 天到期 | P2 | 确认自动续期;到期前复查 |
+| C5 | `TRUSTED_PROXY_CIDRS` 仅 loopback | 信息 | 保持;换反代拓扑时再改 |
+| C6 | 3000 监听 `::` 依赖防火墙封锁 | 信息 | 保持 Block 规则;勿删除 |
+
+**无 P0 配置错误**(在「单域名 HTTPS + 本机 Tunnel」模型下)。
+
+---
+
+## 9. 明确未做
+
+- 未读取或打印 `SESSION_SECRET` 及任何密钥值
+- 未修改 `.env`、未重启进程
+- 未真实登录、未写入会话 Cookie
+- 未调用 Cloudflare API、未改 DNS/Tunnel
+- 未扫描支付/OAuth 回调完整列表(需账号与控制台)
+
+---
+
+## 10. 可选下一步(需你确认再执行)
+
+1. **仅文档**:把本清单链入 `docs/operations`(可复制进仓库)。
+2. **HSTS**:CF 面板开启(推荐,无代码变更)或源站加头(需发版)。
+3. **你完成 §4.2 点验后**,把结果回填本文件「人工」一节。
+4. **OAuth 全量回调清单**(若你启用了第三方登录)。
+
+---
+
+## 11. 证据索引
+
+| 证据 | 来源 |
+|------|------|
+| 键名 / 布尔 / URL 形态 / CIDR | 本地 `.env` 解析(无值输出) |
+| HTTPS/HTTP/HSTS/版本头 | `HttpWebRequest` + `curl -sSI` |
+| TLS | `SslStream` + X509 |
+| 监听/防火墙/Tunnel | `Get-NetTCPConnection` / `Get-NetFirewallRule` / `Get-Service` |
+| Secure 启动警告 | 最近 3 个 `oneapi-*.log` 检索 |
+| Cookie 代码 | `main.go`、`common/session_cookie.go` |
+
+## 12. 2026-07-18 续:HSTS / 文档入库 / OAuth
+
+| 动作 | 结果 |
+|------|------|
+| CF 自动开 HSTS | **未完成**:Wrangler token 无 zone_settings 写权限(403);控制台自动化遇人机验证 |
+| HSTS 操作手册 | 见同目录 `cloudflare-hsts-runbook.md`(面板点击即可,不改代码) |
+| 清单入库 | 本文件位于 `src/docs/operations/` |
+| OAuth 回调全量核对 | 见 `oauth-callback-domain-checklist.md` |
+
+### OAuth 关键偏差(摘要)
+
+- 仅 **GitHub OAuth** 启用;GitHub App 回调应登记 **`https://incc.qzz.io/oauth/github`**(不是 `/api/oauth/github`)。
+- 系统 option 仍显示 `server_address=http://localhost:3000`,Passkey `rp_id=localhost` / `origins=http://localhost:3000` / `allow_insecure=true` —— **与公网 HTTPS 不一致**,需管理后台修正(本清单不写库)。
+
diff --git a/docs/operations/healthcheck-logout-regression-20260718.md b/docs/operations/healthcheck-logout-regression-20260718.md
new file mode 100644
index 000000000000..a12f9e22bead
--- /dev/null
+++ b/docs/operations/healthcheck-logout-regression-20260718.md
@@ -0,0 +1,115 @@
+# 健康检查 / 登出回归报告
+
+- **时间:** 2026-07-18(复检补强 10:53 UTC+8 附近)
+- **入口:** `https://incc.qzz.io` / `D:\newapi`
+- **生产进程:** PID **19984**,启动 2026-07-18T18:39:47
+- **二进制 revision:** `33be725d327813816d791d9d55a8a0d761a5566e`(与 `src` HEAD 一致,`vcs.modified=false`)
+- **SHA-256:** `7a71b9280dd3b19cae11794d5ff10cb84b69871e66f5b0bd3a4879a182f4fc4d`
+- **版本串:** `v1.0.0-rc.21-41-g33be725d`
+
+---
+
+## 1. 版本一致性
+
+| 检查项 | 结果 |
+|--------|------|
+| 生产 exe revision | `33be725d…` |
+| 源码 HEAD | `33be725d…` |
+| 匹配 | **是** |
+| dirty | false |
+
+---
+
+## 2. 本机探针(127.0.0.1:3000)
+
+| 路径 | 期望 | 实际 | 说明 |
+|------|------|------|------|
+| `/livez` | 200 JSON | **通过** | 无 HSTS(纯 HTTP 预期) |
+| `/readyz` | 200 JSON | **通过** | |
+| `/healthz` | 200 JSON | **通过** | |
+| `/api/status` | 200 JSON | **通过** | |
+| `/v1/models` | 401 | **通过** | 无 Token |
+| `/metrics` | 404 非 HTML | **通过** | SPA 不再吞路径 |
+| `/frontend-healthz` | 404 非 HTML | **通过** | |
+| `/api/user/logout` | 200 JSON | **通过** | |
+| `/sign-in` | 200 HTML | **通过** | `lang=zh-CN` |
+| `/console` | 200 HTML | **通过** | SPA |
+| `/livez` + `X-Forwarded-Proto: https` | HSTS | **通过** | `max-age=15768000` |
+
+---
+
+## 3. 公网探针(https://incc.qzz.io)
+
+| 路径 | 结果 | 说明 |
+|------|------|------|
+| `/livez` | **200** + HSTS | 稳定 |
+| `/readyz` | **200** + HSTS | 稳定 |
+| `/api/user/logout` | **200** + HSTS | 稳定 |
+| `/oauth/github` | **200** HTML + HSTS | SPA 回调壳 |
+| `/api/oauth/state` | **200** JSON + HSTS | |
+| `/metrics` | **404** JSON 非 HTML | 通过 |
+| `/api/status` GET | **200**(重试后) | 偶发 CF/`RemoteDisconnected`/403 挑战;**本地同源始终 200** |
+| `/sign-in` GET | **200**(重试后) | 同上,偶发边缘抖动 |
+| `http://incc.qzz.io/` | **301 → https://incc.qzz.io/** | `curl -sSI` 确认 |
+
+**说明:** `curl -sI`(HEAD)对部分 API 可能返回 404,属 **HEAD 未实现/未路由**,不能当作 GET 失败;GET 体正常返回 JSON。
+
+### 公网/本地 status 字段(GET)
+
+| 字段 | 值 |
+|------|-----|
+| version | `v1.0.0-rc.21-41-g33be725d` |
+| server_address | `https://incc.qzz.io` |
+| github_oauth | true / `Ov23liURGuQ4SZgvLGGq` |
+| passkey_rp_id | `incc.qzz.io` |
+| passkey_origins | `https://incc.qzz.io` |
+| passkey_allow_insecure | false |
+| theme | default |
+
+---
+
+## 4. 登出回归
+
+| 检查 | 结果 |
+|------|------|
+| `GET /api/user/logout` | 200 `{"success":true}` |
+| Set-Cookie | `session` **Max-Age=0; Secure; HttpOnly; SameSite=Strict; Path=/** |
+| 前端版本 | 含 `33be725d` 登出抑制 Session expired + `location.replace('/sign-in')` |
+| 日志样本 | 可见 `GET /api/user/logout` 后 `GET /sign-in` |
+
+**未做:** 持真实登录 Cookie 的浏览器 UI 全自动点退(需人工 30 秒确认)。
+
+---
+
+## 5. 基础设施
+
+| 项 | 结果 |
+|----|------|
+| 防火墙 Block TCP 3000 | Enabled / Block |
+| Cloudflared | Running,进程 1 |
+| SQLite 备份任务 | Ready;上次 11:02:10 result=0;下次 03:30 |
+| 最新备份 | `one-api-20260718-110210.db` 26.9MB |
+
+---
+
+## 6. 源码门禁
+
+| 门禁 | 结果 |
+|------|------|
+| `go test ./router` | pass |
+| `go test ./middleware` | pass |
+| `go test -tags frontend_external .` | pass |
+
+---
+
+## 7. 总判定
+
+| 类别 | 判定 |
+|------|------|
+| 核心健康(livez/readyz/本地 status/鉴权边界/metrics 404) | **通过** |
+| 登出 Cookie 过期语义 | **通过** |
+| HSTS | **通过**(公网 + 反代头) |
+| 公网个别 GET 偶发断开/403 | **边缘/挑战抖动**,非进程宕机;本地直连正常 |
+| 完整 UI 登出手测 | **待用户点一次** |
+
+**总体:生产健康,可继续使用。** 若 UI 登出仍异常,带截图/文案再开一轮。
diff --git a/docs/operations/log-query-baseline.md b/docs/operations/log-query-baseline.md
new file mode 100644
index 000000000000..9813decadd31
--- /dev/null
+++ b/docs/operations/log-query-baseline.md
@@ -0,0 +1,28 @@
+# Log Query Baseline
+
+Cursor pagination uses `(created_at, id)` for SQLite/MySQL/PostgreSQL and `(created_at, request_id)` for ClickHouse. Offset pagination remains available only for compatibility.
+
+PostgreSQL staging baseline:
+
+```sql
+EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
+SELECT * FROM logs
+WHERE (created_at < :created_at OR (created_at = :created_at AND id < :id))
+ORDER BY created_at DESC, id DESC
+LIMIT 101;
+
+EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
+SELECT * FROM logs
+WHERE trace_id = :trace_id
+ORDER BY created_at ASC, id ASC
+LIMIT 200;
+```
+
+Acceptance:
+
+- no large `OFFSET` in the cursor query;
+- an index scan uses `idx_created_at_id` or a more selective filter index;
+- trace lookup uses `idx_logs_trace_id` / `idx_logs_trace_created`;
+- representative deep-page P95 is below 300 ms under staging data volume.
+
+The model test suite runs SQLite `EXPLAIN QUERY PLAN` assertions. PostgreSQL, MySQL and ClickHouse plans must be captured in staging because their optimizers and data distributions cannot be represented by the in-memory unit database.
diff --git a/docs/operations/oauth-callback-domain-checklist.md b/docs/operations/oauth-callback-domain-checklist.md
new file mode 100644
index 000000000000..e25fcede2ab3
--- /dev/null
+++ b/docs/operations/oauth-callback-domain-checklist.md
@@ -0,0 +1,241 @@
+# OAuth 回调域名只读全量核对
+
+- **日期**:2026-07-18
+- **公网入口**:`https://incc.qzz.io`
+- **运行版本**:`v1.0.0-rc.21-34-gbbddd729`
+- **方法**:只读 `/api/status`、源码路由与 OAuth 客户端构造;**不**读取 client secret、不发起真实 OAuth 授权、不改 GitHub App 设置
+
+---
+
+## 1. 生产启用了哪些身份方式
+
+来源:本机 `GET http://127.0.0.1:3000/api/status`(字段为公开状态,不含 secret)
+
+| 方式 | 启用 | client_id / 备注 |
+|------|------|------------------|
+| **GitHub OAuth** | **是** | `github_client_id=Ov23liURGuQ4SZgvLGGq`(公开 client_id) |
+| Discord OAuth | 否 | client_id 空 |
+| OIDC | 否 | endpoint/client_id 空 |
+| LinuxDO OAuth | 否 | client_id 空 |
+| Telegram OAuth | 否 | bot 名空 |
+| WeChat 登录 | 否 | — |
+| **Passkey** | **是** | 见 §4(当前配置指向 localhost,生产域名下不可用) |
+| 密码登录 | 状态里 `password_login_enabled` 存在(本清单不展开账号策略) | |
+
+结论:第三方 OAuth 仅 **GitHub** 需回调域名闭环;Passkey 配置与公网域名**不一致**。
+
+---
+
+## 2. GitHub OAuth 实际跳转链路(源码)
+
+### 2.1 授权 URL 构造
+
+文件:`web/default/src/lib/oauth.ts` → `buildGitHubOAuthUrl`
+
+```text
+https://github.com/login/oauth/authorize
+ ?client_id=
+ &state=
+ &scope=user:email
+```
+
+**注意:authorize 请求未带 `redirect_uri` 参数。**
+因此 GitHub 使用 **OAuth App / GitHub App 控制台里配置的唯一 Authorization callback URL**。
+
+### 2.2 浏览器回调(前端 SPA)
+
+路由:`/oauth/$provider`(`web/default/src/routes/oauth/$provider.tsx`)
+
+生产期望 URL:
+
+```text
+https://incc.qzz.io/oauth/github?code=...&state=...
+```
+
+页面再调用:
+
+```text
+GET https://incc.qzz.io/api/oauth/github?code=...&state=...
+```
+
+### 2.3 后端换票
+
+路由:`router/api-router.go`
+
+```text
+GET /api/oauth/state → GenerateOAuthCode(写 session oauth_state)
+GET /api/oauth/:provider → HandleOAuth(校验 state,ExchangeToken,建会话)
+```
+
+GitHub 换票:`oauth/github.go` POST `https://github.com/login/oauth/access_token`
+(body 含 client_id/client_secret/code;**无 redirect_uri 字段**,与 authorize 一致,依赖 App 默认回调)。
+
+### 2.4 必须登记在 GitHub 上的回调
+
+| 位置 | 应配置值 |
+|------|----------|
+| GitHub OAuth App → Authorization callback URL | **`https://incc.qzz.io/oauth/github`** |
+
+| 错误配置示例 | 后果 |
+|--------------|------|
+| `http://localhost:3000/oauth/github` | 生产登录 redirect_uri_mismatch / 回本地 |
+| `https://incc.qzz.io/api/oauth/github` | 前端路由对不上,SPA 收不到 code 展示 |
+| `https://incc.qzz.io/oauth/github/`(尾斜杠不一致) | 可能 mismatch(取决于 GitHub 严格匹配) |
+| 仅 HTTP | 与公网 HTTPS 不一致 |
+
+**本审计无法读取你的 GitHub Developer Settings**;请打开
+https://github.com/settings/developers → 对应 OAuth App(client_id 前缀 `Ov23li…`)人工核对 callback 是否**精确等于**上表。
+
+### 2.5 绑定流程
+
+已登录用户绑定 GitHub 时仍走同一 authorize + `/oauth/github` 回调(`window.opener` 区分 bind/login)。
+Callback 仍必须是同一 URL。
+
+---
+
+## 3. 其他 Provider 回调模板(当前未启用,备查)
+
+若将来打开,前端会显式带 `redirect_uri=window.location.origin/...`:
+
+| Provider | 启用 | Authorize redirect_uri(前端) | 后端 API |
+|----------|------|--------------------------------|----------|
+| Discord | 否 | `{origin}/oauth/discord` | `/api/oauth/discord` |
+| OIDC | 否 | `{origin}/oauth/oidc` | `/api/oauth/oidc` |
+| LinuxDO | 否 | 构造函数未带 redirect_uri(与 GitHub 类似,依赖控制台默认) | `/api/oauth/linuxdo` |
+| WeChat / Telegram | 否 | 非标准路由 `/api/oauth/wechat`、`/api/oauth/telegram/*` | 见 `api-router.go` |
+
+生产 origin 固定为 `https://incc.qzz.io` 时,未来启用 Discord/OIDC 应在对应控制台登记:
+
+- `https://incc.qzz.io/oauth/discord`
+- `https://incc.qzz.io/oauth/oidc`
+
+---
+
+## 4. 与 OAuth 相关的系统地址字段(高优先级偏差)
+
+`/api/status` 仍暴露:
+
+| 字段 | 当前值 | 期望(生产) | 影响 |
+|------|--------|--------------|------|
+| `server_address` | `http://localhost:3000` | `https://incc.qzz.io` | 邮件链接、部分回跳、第三方文档/支付回调文案可能指错域名 |
+| `passkey_rp_id` | `localhost` | `incc.qzz.io` | **公网 Passkey 无法绑定/登录** |
+| `passkey_origins` | `http://localhost:3000` | `https://incc.qzz.io` | 同上 |
+| `passkey_allow_insecure` | `true` | 生产应为 `false` | 与 HTTPS 生产策略不一致 |
+| `passkey_login` | `true` | 可保留 true,但须先修正 rp_id/origins | 功能开关开着但配置不可用 |
+| `docs_link` | `https://docs.newapi.pro` | 可保留 | 外链文档 |
+
+**说明**:`SESSION_COOKIE_TRUSTED_URL=https://incc.qzz.io` 已正确;**业务 option 里的 ServerAddress / Passkey 仍像开发机默认值**,与 Cookie 层脱节。
+
+### 建议修改入口(需你确认后改,本清单不自动写库)
+
+管理后台 → 系统设置(站点 / 认证 / Passkey)或 options:
+
+1. **Server Address / 服务器地址** → `https://incc.qzz.io`(无尾斜杠或与项目约定一致)
+2. **Passkey RP ID** → `incc.qzz.io`
+3. **Passkey Origins** → `https://incc.qzz.io`
+4. **Passkey allow insecure** → `false`
+5. 保存后**重新登录**测 Passkey;已在 localhost 注册的凭据不会自动迁移到新 rp_id
+
+---
+
+## 5. Cookie / 会话与 OAuth 交叉
+
+| 项 | 状态 |
+|----|------|
+| OAuth state 存 session cookie | `/api/oauth/state` 使用 `sessions` |
+| Secure Cookie | 已 `SESSION_COOKIE_SECURE=true` + Trusted URL |
+| SameSite=Strict | 源码默认 Strict |
+
+**SameSite=Strict 与 OAuth:**
+GitHub 回跳是**跨站导航回到** `incc.qzz.io`。部分浏览器对「顶级导航带回第一方 Cookie」仍发送 cookie;若出现「state 无效」,需验证:
+
+- 回调是否仍在 `https://incc.qzz.io` 第一方;
+- 是否被中间页跨站;
+- 必要时评估 `Lax`(需产品决策,不在本清单自动改)。
+
+---
+
+## 6. Tunnel / 域名(与回调一致)
+
+`C:\Users\yuanjia\.cloudflared\config-incc-newapi.yml`(只读):
+
+```yaml
+hostname: incc.qzz.io
+service: http://localhost:3000
+```
+
+- 公网仅 `incc.qzz.io` → OAuth 回调只登记这一 host 即可。
+- 若将来加 `www` 或其他域名:Cookie Trusted URL、ServerAddress、Passkey origins、GitHub callback **全部**要同步扩展。
+
+---
+
+## 7. 核对勾选表
+
+### 已由自动化完成
+
+- [x] 列出已启用 OAuth/Passkey
+- [x] 从源码固定 GitHub 回调路径为 `/oauth/github`
+- [x] 后端 API 为 `/api/oauth/github`(二次调用,不是 GitHub 回调)
+- [x] 发现 `server_address` / Passkey 仍为 localhost
+- [x] Tunnel hostname = `incc.qzz.io`
+
+### 需你人工完成
+
+- [ ] GitHub Developer Settings 中 callback **精确**为 `https://incc.qzz.io/oauth/github`
+- [ ] 用无痕窗口点一次「Continue with GitHub」确认可回跳并登录
+- [ ] 后台改 ServerAddress + Passkey 四项后复测 Passkey
+- [ ] 若有自定义 OAuth Provider(DB 表),在后台列表中逐个核对 redirect
+
+---
+
+## 8. 风险分级
+
+| ID | 项 | 等级 |
+|----|----|------|
+| O1 | GitHub callback 未在本环境验证(控制台在 GitHub) | P1 人工 |
+| O2 | `server_address=http://localhost:3000` | **P0/P1** 业务配置错误 |
+| O3 | Passkey rp_id/origins=localhost 且 allow_insecure=true | **P0/P1** 公网 Passkey 失效/不安全默认 |
+| O4 | SameSite=Strict 与 OAuth 兼容性 | P2 观察 |
+| O5 | Discord/OIDC 未启用 | 信息 |
+
+---
+
+## 9. 明确未做
+
+- 未读取 GitHub client secret
+- 未修改 options 数据库
+- 未代表你点击 GitHub 授权
+- 未改 Cloudflare / DNS
+
+---
+
+## 11. 2026-07-18 生产执行
+
+| 项 | 状态 |
+|----|------|
+| ServerAddress | **已改为** `https://incc.qzz.io`(DB options,已反映到 `/api/status`) |
+| passkey.rp_id / origins | **已改为** `incc.qzz.io` / `https://incc.qzz.io` |
+| passkey.allow_insecure_origin | **已改为** `false` |
+| options 备份 | `D:\newapi\backups\options-before-prod-fix-20260718-162714.sql` |
+| GitHub callback URL | **仍需人工**在 GitHub Developer Settings 设为 `https://incc.qzz.io/oauth/github`(API/浏览器自动化无法代改) |
+| HSTS | 应用层已上线(`3830dc07`);CF 面板可再开双保险 |
+
+人工勾选:
+
+- [x] GitHub OAuth App callback 已设为 `https://incc.qzz.io/oauth/github`(用户 2026-07-18 控制台截图确认)
+- [x] GitHub **Homepage URL** 已改为 `https://incc.qzz.io/`(2026-07-18 本机 Chrome 代改,页面提示 Application updated successfully)
+- [ ] 无痕窗口实测「Continue with GitHub」登录成功
+- [ ] (可选)Passkey 在生产域名重新注册
+- [ ] (可选)CF Edge Certificates 开启 HSTS(应用层 HSTS 已上线)
+
+### GitHub OAuth App 当前快照(2026-07-18 已更新,无 secret)
+
+| 字段 | 值 | 判定 |
+|------|-----|------|
+| Application name | newapi | 可保留 |
+| Client ID | Ov23liURGuQ4SZgvLGGq | 与 `/api/status` 一致 |
+| Homepage URL | `https://incc.qzz.io/` | **正确** |
+| Authorization callback URL | `https://incc.qzz.io/oauth/github` | **正确** |
+| Client secret | 控制台显示 Never used | 做一次真实登录后会变为 used |
+| Device Flow | 未要求 | 可保持关闭 |
+
diff --git a/docs/operations/optimization-audit-2026-07-18.md b/docs/operations/optimization-audit-2026-07-18.md
new file mode 100644
index 000000000000..ec28af20af40
--- /dev/null
+++ b/docs/operations/optimization-audit-2026-07-18.md
@@ -0,0 +1,266 @@
+# new-api 优化审计与执行计划
+
+- **审计日期**:2026-07-18
+- **权威源码**:`D:\newapi\src`
+- **分支 / HEAD**:`feat/adaptive-channel-balance-rc12` / `6ce0799034e836180031953f83cc7dab4f1d6e08`
+- **生产二进制**:`D:\newapi\new-api-fixed.exe`
+- **生产 PID**:33016(审计时)
+- **生产 SHA-256**:`75450924043c1f19f53357d9399772ccb8b5a2a794bfab33a945d0d493603e37`
+- **嵌入 revision**:`6ce0799034e836180031953f83cc7dab4f1d6e08`(与 HEAD 一致)
+- **公网入口**:`https://incc.qzz.io`
+- **范围边界**:不读 `.env` 值、不读业务表数据/日志正文;不改生产 DB;本审计后的代码修复可部署,但须单独记录。
+
+---
+
+## 1. 测试方法与证据
+
+### 1.1 已执行的自动化 / 本地门禁
+
+| 门禁 | 命令 / 方式 | 结果 |
+|------|-------------|------|
+| Router 单测 | `go test ./router -count=1` | pass |
+| 纯后端构建测试 | `go test -tags frontend_external . -count=1` | pass |
+| Vet | `go vet ./router .` | clean |
+| 默认前端类型检查 | `bun run typecheck`(`web/default`) | pass |
+| 中文 locale 结构 | 解析 `zh.json` nested `translation` 键数 | 5182;`Home=主页`,`Sign in=登录` |
+| PR CI | GitHub Actions run `29634700553` | go/web/image **全绿** |
+
+### 1.2 运行时探针(本机 127.0.0.1:3000)
+
+| 路径 | HTTP | Content-Type / 说明 |
+|------|------|---------------------|
+| `/livez` | 200 | `application/json` `{"plane":"all","status":"ok"}` |
+| `/readyz` | 200 | `application/json` `{"status":"ok"}` |
+| `/healthz` | 200 | JSON 存活(兼容) |
+| `/api/status` | 200 | JSON 管理状态 |
+| `/v1/models`(无 Token) | **401** | 预期鉴权失败 |
+| `/api/no-such` | **404** | API 未匹配 |
+| `/unknown-page-xyz` | 200 HTML | SPA NoRoute 回退(预期对前端路由) |
+| **`/metrics`** | **200 HTML SPA** | **异常:未启用 metrics 时不应伪装成页面** |
+| **`/frontend-healthz`** | **200 HTML SPA** | 分离 Nginx 才有该端点;一体机误匹配到 SPA |
+
+### 1.3 公网探针
+
+| 路径 | 结果 |
+|------|------|
+| `https://incc.qzz.io/livez` | 200 |
+| `https://incc.qzz.io/readyz` | 200 |
+| `https://incc.qzz.io/api/status` | 200 |
+| 浏览器自动化访问首页/登录 | 触发 **Cloudflare 人机验证**(自动化环境);此前已用浏览器验证中文 UI 通过 |
+
+### 1.4 配置键名(仅键名,无值)
+
+生产 `.env` 已包含:
+
+- `PORT`
+- `SQLITE_PATH`
+- `SESSION_SECRET`
+- `SESSION_COOKIE_SECURE`
+- `SESSION_COOKIE_TRUSTED_URL`
+- `TRUSTED_PROXY_CIDRS`
+
+说明:Cookie 相关键名已与源码对齐(不再使用错误的 `SESSION_SECURE`)。
+
+### 1.5 备份与恢复
+
+| 项 | 状态 |
+|----|------|
+| 计划任务 `NewAPI-SQLiteBackup` | Ready;每日 03:30;上次 2026-07-18 11:02:10;`LastTaskResult=0` |
+| 备份脚本 | `D:\newapi\scripts\backup-sqlite.ps1`(`.backup` + integrity + sha256) |
+| 最新备份文件 | `backups/db/one-api-20260718-110210.db` + `.sha256` |
+| 恢复演练记录 | `backups/restore-tests/restore-20260718-112444.json` 存在 |
+| sqlite3 工具 | `D:\newapi\tools\sqlite\sqlite3.exe` 存在 |
+
+### 1.6 前端中文
+
+| 项 | 状态 |
+|----|------|
+| 默认主题 i18n 嵌套展开 | 已修(`6ce07990`) |
+| 默认主题默认中文 / 忽略 navigator | 已修(`cfff28c1`) |
+| 浏览器实测首页中文 | 此前通过(主页/控制台/模型广场/登录等) |
+| 经典主题 locale 结构 | 文件为 `{translation:{...}}`,**静态 resources 导入方式与 i18next 兼容**(与 default 的 custom backend 不同) |
+
+### 1.7 磁盘与清理
+
+清理后约释放 1.59GB+;生产 exe/data/src 保留。`src` 仍约 2GB(含 node_modules/dist,属构建依赖)。
+
+---
+
+## 2. 架构现状(简图)
+
+```text
+浏览器 / Tunnel / Cloudflare
+ │
+ ▼
+ new-api-fixed.exe (一体化 embed,RUN_MODE/APP_PLANE 默认 all)
+ │
+ ├─ /livez /readyz /healthz
+ ├─ /api/* /v1/* /mj /pg /suno /kling /jimeng ...
+ └─ SPA NoRoute + 静态资源 (web/default|classic dist)
+```
+
+可选未部署路径:
+
+```text
+frontend Nginx (:8080) ──反代──► backend (:3000, FRONTEND_MODE=disabled, -tags frontend_external)
+```
+
+---
+
+## 3. 问题清单(按优先级)
+
+### P0 — 必须尽快
+
+#### P0-1 SPA NoRoute 吞掉运维/指标路径
+
+- **现象**:`METRICS_ENABLED` 未开时,`GET /metrics` 返回 **200 + index.html**,而不是 404/503。
+- **根因**:`router/web-router.go` 的 `NoRoute` 仅排除 `/v1`、`/api`、`/assets` 前缀;`/metrics` 未注册时落入 SPA。
+- **影响**:
+ - 监控误判「有页面」;
+ - 扫描器/编排以为指标端点存在;
+ - 与 fail-closed metrics 设计意图不一致(启用但无 token 应为 503,未启用应为明确非 HTML 失败)。
+- **证据**:本机探针 Content-Type `text/html`。
+- **建议修复**:NoRoute 对明确的后端/运维前缀直接 `404` JSON(或现有 `RelayNotFound`),至少包括:
+ - `/metrics`
+ - `/livez` `/readyz` `/healthz`(已注册时不会落到 NoRoute;防御性仍可列)
+ - `/v1beta`、`/mj`、`/pg`、`/suno`、`/kling`、`/jimeng`、`/dashboard`(billing 兼容路径若未挂到 api 组则需核对)
+- **验证**:`curl -i /metrics` → 非 HTML;启用 metrics 无 token → 503;有 token → 200 文本指标。
+- **风险**:低;仅改变未注册路径的失败形态。
+- **置信度**:高。
+
+#### P0-2 生产 Secure Cookie / HTTPS 闭环仍依赖环境证据
+
+- **现象**:键名已正确配置,但本审计**不读取值**,无法证明:
+ - `SESSION_COOKIE_SECURE=true` 是否在公网 HTTPS 下启用;
+ - `SESSION_COOKIE_TRUSTED_URL` 是否完整包含 `https://incc.qzz.io` 等入口;
+ - Tunnel/Access/WAF catch-all 是否存在。
+- **影响**:会话 Cookie 明文风险、OAuth 回调失败或过宽信任。
+- **建议**:在**不打印密钥**前提下做只读核对清单(管理员本地执行):检查 Secure/SameSite 属性、回调 URL 列表、Cloudflare 策略导出。
+- **本轮是否自动改生产 `.env`**:**否**(高风险,需单独确认)。
+- **置信度**:高(键名);配置正确性未证。
+
+### P1 — 高收益
+
+#### P1-1 供应链:签名 + 完整 SBOM
+
+- **现状**:exe `Authenticode=NotSigned`;仅有 Go module CycloneDX(非前端完整 SBOM)。
+- **建议**:clean build → 签名 → Go SBOM + 前端 lockfile SBOM → 写入 release manifest。
+- **阻塞**:证书/策略选择。
+- **置信度**:高。
+
+#### P1-2 真实业务 E2E
+
+- **现状**:单测/门禁强;未用沙箱凭据跑 鉴权→Relay→计费→支付→调度。
+- **建议**:隔离 staging + 合成账户 + 预算上限。
+- **置信度**:高。
+
+#### P1-3 前端体积
+
+- **现状**:默认前端产物仍很大(构建日志 index/async 数 MB 级);预算门禁已存在。
+- **建议**:RUM/瀑布后再拆 VChart/Shiki/Mermaid 等。
+- **置信度**:体积高;用户影响中。
+
+#### P1-4 非 root 容器
+
+- **现状**:一体化 Dockerfile 仍默认 root 运行;分离前端已用 unprivileged Nginx。
+- **建议**:staging 验证 volume 权限后改 USER。
+- **置信度**:高。
+
+#### P1-5 Cloudflare 对自动化/部分地区挑战
+
+- **现象**:browser-act 访问公网触发「正在进行安全验证」。
+- **影响**:自动化巡检、部分用户体验。
+- **建议**:对 `/livez`/`/readyz` 放行;管理后台保持挑战;记录 Ray ID 策略。
+- **置信度**:中(需 CF 控制台)。
+
+### P2 — 中期
+
+| ID | 项 | 说明 |
+|----|----|------|
+| P2-1 | SQLite 容量/连接池 | 无基准前不改默认 100/1000 |
+| P2-2 | 经典前端类型/a11y | 渐进 `checkJs` + axe |
+| P2-3 | 分离部署上线 | 镜像与 compose 已就绪,生产仍一体化 |
+| P2-4 | `src` 体积 | node_modules/dist 占磁盘;可选清理后 CI/本地重装 |
+| P2-5 | `/frontend-healthz` 一体机语义 | 一体机可显式 404,避免与分离部署混淆 |
+
+### 已关闭(本周期)
+
+| 项 | 状态 |
+|----|------|
+| 前后端交付缝 + CI 三镜像 | 完成 |
+| 中文默认 + locale 嵌套展开 | 完成并部署 |
+| 可信代理 fail-closed / metrics token fail-closed 源码 | 已有 |
+| SQLite 日备 + 恢复演练文件 | 有 |
+| 磁盘临时产物清理 | 已做一轮 |
+
+---
+
+## 4. 目标设定(本轮最优执行)
+
+在**不改生产密钥/不碰 DB 数据**前提下,本轮只做:
+
+1. **落地 P0-1**:修复 SPA NoRoute 误吞 `/metrics` 及同类后端前缀;补回归测试。
+2. **补文档**:本审计文件 + 运维注意点写入 `runtime-separation` 交叉链接。
+3. **本地验证**:`go test ./router`、构建标签测试。
+4. **推送 fork**。
+5. **生产部署**:因属安全/运维语义修复,**构建并 promote 到线上**(与中文修复同一发布通道),验证 `/metrics` 不再返回 HTML。
+6. **不自动改** `.env` Cookie 真值、不启用 metrics、不上分离镜像(除非后续确认)。
+
+成功标准:
+
+- `GET /metrics`(默认未启用)→ **404**(或非 HTML),不再是 SPA。
+- `/livez` `/readyz` `/api/status` 仍 200。
+- `/v1/models` 无 Token 仍 401。
+- 首页中文仍正常。
+- 测试通过并推送到 fork。
+
+---
+
+## 5. 回滚
+
+- 二进制:`backups/releases` 中保留的最新旧包,或 `release-manifests/zh-unwrap-20260718` 对应包。
+- 代码:`git revert` NoRoute 提交。
+- 配置:未改 `.env` 则无配置回滚。
+
+---
+
+## 6. 明确不做(除非再次确认)
+
+- 读取或修改 `.env` 中的密钥/Cookie 真值
+- 开启 `METRICS_ENABLED` 并配置 token
+- 切换生产到前后端分离 compose
+- 代码签名采购与实施
+- 真实上游收费 E2E
+
+---
+
+## 8. 本轮执行记录(2026-07-18 续)
+
+| 目标 | 结果 |
+|------|------|
+| 文档 | 本文件 |
+| P0-1 SPA NoRoute 修复 | 提交 `bbddd729`,已推 fork,已部署生产 |
+| 生产 PID | **32544** |
+| 生产 SHA-256 | `d1120e03acd4498cf008bb71441cc07734f7fc0901a3639ce94904b21b35090c` |
+| `/metrics` | **404** 非 HTML(修复前为 200 HTML) |
+| `/frontend-healthz` | **404** 非 HTML |
+| `/livez` `/readyz` `/api/status` | 200 JSON |
+| `/v1/models` | 401 |
+| `/console` | 200 HTML(SPA 正常) |
+| 测试 | `go test ./router` 通过(含 `TestIsNonSPARequestPath`、`TestEmbeddedFrontendDoesNotServeSPAForMetrics`) |
+
+### 未在本轮执行
+
+- 修改生产 Cookie 真值 / Tunnel 策略
+- 启用 metrics token
+- 签名与完整 SBOM
+- 真实业务 E2E
+- 前后端分离上生产
+
+### 下一步建议(需确认)
+
+1. 只读核对 `SESSION_COOKIE_SECURE` / `TRUSTED_URL` 与公网 HTTPS 是否一致
+2. 沙箱 E2E 矩阵
+3. 签名 + SBOM 发布流程
+4. 前端体积 RUM 后拆包
+
diff --git a/docs/operations/project-overview-20260718.md b/docs/operations/project-overview-20260718.md
new file mode 100644
index 000000000000..f9899b8bb41f
--- /dev/null
+++ b/docs/operations/project-overview-20260718.md
@@ -0,0 +1,142 @@
+# D:\newapi 项目交付概览(更新)
+
+- **实施起始:** 2026-07-17
+- **最后验证:** 2026-07-18(健康检查 + 登出回归 + 磁盘清理)
+- **权威源码:** `D:\newapi\src`
+- **分支:** `feat/adaptive-channel-balance-rc12`
+- **HEAD / 生产 revision:** `33be725d327813816d791d9d55a8a0d761a5566e`
+- **生产进程:** PID 19984(`new-api-fixed.exe`)
+- **公网:** `https://incc.qzz.io`
+- **Fork:** `xvyimu/new-api` 已与 HEAD 同步
+
+---
+
+## 1. 当前结论(一句话)
+
+生产已跑在含中文 UI、HSTS、Secure Cookie 配置、OAuth/Passkey 域名修正、登出加固与 SPA 运维路径修复的一体化二进制上;本地/核心公网探针与 Go 门禁通过。剩余主要是人工 UI 登出点验、CF 面板 HSTS 双保险、签名/SBOM、真实业务 E2E。
+
+---
+
+## 2. 架构(现行)
+
+```text
+浏览器 → Cloudflare → cloudflared Tunnel → 127.0.0.1:3000
+ └─ new-api-fixed.exe(一体化 embed,RUN_MODE/APP_PLANE 默认 all)
+ ├─ /livez /readyz /healthz
+ ├─ /api/* /v1/* relay 前缀…
+ └─ SPA(default 主题,中文默认)
+```
+
+可选未切换:`deploy/separated` 前后端分离镜像 + Nginx 同源反代。
+
+唯一构建源:`D:\newapi\src`。`_qn_tmp` 仅上游参考,禁止发布。
+
+---
+
+## 3. 本周期已落地(相对 07-17 基线)
+
+### 3.1 安全 / 会话 / HTTPS
+
+| 项 | 状态 |
+|----|------|
+| `SESSION_COOKIE_SECURE=true` + Trusted URL=`https://incc.qzz.io` | 生产已配 |
+| 应用层 HSTS `max-age=15768000`(`X-Forwarded-Proto=https`) | 已部署 |
+| 3000 入站防火墙 Block | 已启用 |
+| Tunnel `incc.qzz.io` → localhost:3000 | Running |
+| SPA 不再把 `/metrics` 等伪装成 HTML 200 | 已部署 |
+| 登出:`session` Max-Age=0 + 前端硬跳登录 + 抑制 401 噪声 | 已部署 |
+
+### 3.2 中文与 OAuth / Passkey
+
+| 项 | 状态 |
+|----|------|
+| 默认前端 locale 嵌套 `translation` 展开 | 已修 |
+| 中文默认 / 忽略 navigator 英文 | 已修 |
+| `ServerAddress` | `https://incc.qzz.io` |
+| Passkey rp/origins | 生产域名;`allow_insecure=false` |
+| GitHub callback | `https://incc.qzz.io/oauth/github` |
+| GitHub Homepage | `https://incc.qzz.io/` |
+
+### 3.3 交付缝 / CI(源码,生产仍一体化)
+
+- `frontend_external` + `FRONTEND_MODE`
+- `Dockerfile.backend` / `deploy/separated/*`
+- quality:pure backend、三镜像、nginx digest 解析
+- 文档:ADR、runtime-separation、cookie/oauth/hsts 清单
+
+### 3.4 备份
+
+- 日备任务 `NewAPI-SQLiteBackup` 正常(`.backup` + integrity + sha256)
+- 最新库备份:`backups/db/one-api-20260718-110210.db`
+
+### 3.5 磁盘
+
+- 已清理过期 release-build / 中间诊断包 / 旧日志等
+- 本轮再删旧 `release-manifests/*`(保留当前 prod 对应 `logout-fix2-20260718`),约 **+543 MB**
+- 现 `release-manifests` ≈ 136 MB;`src` ≈ 2.0 GB(主要为 `web/node_modules`,构建需要,未删)
+
+---
+
+## 4. 最新健康检查摘要(2026-07-18)
+
+详见:`docs/healthcheck-logout-regression-20260718.md`
+
+| 类别 | 结果 |
+|------|------|
+| 版本三边一致 | 通过 |
+| 本机 livez/readyz/status/metrics404/logout | 通过 |
+| 公网 livez/readyz/HSTS/logout cookie | 通过 |
+| 公网部分路径偶发 CF 断开/403 | 边缘抖动;重试或本地直连正常 |
+| `go test` router/middleware/frontend_external | 通过 |
+| UI 登录→退出手测 | 建议你再点一次确认 |
+
+---
+
+## 5. 风险与未完成
+
+| 优先级 | 项 | 状态 |
+|--------|----|------|
+| P1 | 完整 UI 登出手测 | 接口层已过;待人工 |
+| P1 | CF 面板 HSTS 双保险 | 应用层已有;面板需账号操作(见 runbook) |
+| P1 | Authenticode 签名 + 完整 SBOM | 未做 |
+| P1 | 真实鉴权/Relay/计费/支付 E2E | 未做 |
+| P2 | 前端体积 RUM 后拆包 | 预算门禁已有 |
+| P2 | 非 root 容器 / 分离部署上生产 | 镜像就绪未切 |
+| P2 | 证书续期(LE ~2026-09-25) | 观察 |
+| 信息 | `curl -sI` HEAD 对部分 API 404 | 不影响 GET |
+
+---
+
+## 6. 关键路径索引
+
+| 用途 | 路径 |
+|------|------|
+| 源码 | `D:\newapi\src` |
+| 生产 exe | `D:\newapi\new-api-fixed.exe` |
+| 数据 | `D:\newapi\data` |
+| 本轮健康报告 | `D:\newapi\docs\healthcheck-logout-regression-20260718.md` |
+| 优化审计 | `D:\newapi\docs\optimization-audit-2026-07-18.md` |
+| Cookie/HTTPS 清单 | `src/docs/operations/cookie-https-readonly-checklist.md` |
+| OAuth 清单 | `src/docs/operations/oauth-callback-domain-checklist.md` |
+| HSTS runbook | `src/docs/operations/cloudflare-hsts-runbook.md` |
+| 运维执行记录 | `D:\newapi\docs\ops-fix-execution-2026-07-18.md` |
+| 当前发布证据目录 | `D:\newapi\release-manifests\logout-fix2-20260718\` |
+| 启动/晋升 | `start-newapi-hidden.ps1` + 计划任务 `NewAPIServer` |
+
+---
+
+## 7. 建议下一步(需确认)
+
+1. 你本机无痕:**登录 → 退出**,确认无 “Session expired” 连环提示。
+2. (可选)CF Edge Certificates 再开 HSTS。
+3. 签名 + SBOM 发布流程。
+4. 沙箱 E2E。
+5. 是否合并 PR #1。
+
+---
+
+## 8. 回滚
+
+- 二进制:`release-manifests\logout-fix2-20260718\` 或 `backups\releases\` 最新包
+- options:`backups\options-before-prod-fix-20260718-162714.sql`
+- 代码:fork 分支 `git revert` 对应提交
diff --git a/docs/operations/runtime-separation.md b/docs/operations/runtime-separation.md
new file mode 100644
index 000000000000..3dff68711b43
--- /dev/null
+++ b/docs/operations/runtime-separation.md
@@ -0,0 +1,91 @@
+# Runtime Separation
+
+The default remains a single compatible process:
+
+```text
+RUN_MODE=all
+APP_PLANE=all
+```
+
+`RUN_MODE` controls process responsibility:
+
+| Mode | HTTP | Claims tasks | Creates scheduled tasks | Exits after migration |
+|---|---:|---:|---:|---:|
+| `all` | yes | yes | yes | no |
+| `serve` | yes | no | no | no |
+| `worker` | no | yes | no | no |
+| `scheduler` | no | no | yes | no |
+| `migrate` | no | no | no | yes |
+
+`APP_PLANE` controls the routes exposed by an HTTP process:
+
+| Plane | Routes |
+|---|---|
+| `all` | Relay, management API and web UI |
+| `relay` | Relay/video routes and `/healthz` only |
+| `management` | Management API, dashboard and web UI |
+
+## Frontend delivery (`FRONTEND_MODE`)
+
+Independent of `RUN_MODE` / `APP_PLANE`, the HTTP process can deliver the console in four modes:
+
+| Mode | Behavior |
+|---|---|
+| `auto` (default) | Compatible legacy behavior: slave nodes with `FRONTEND_BASE_URL` redirect; master embeds assets when available |
+| `embedded` | Always register embedded dual-theme static assets (fails fast if the binary was built with `frontend_external`) |
+| `redirect` | Always 301 non-API pages to `FRONTEND_BASE_URL` (including master). URL must be an absolute HTTP(S) origin without credentials, path, query, or fragment |
+| `disabled` | Pure API process: no web `NoRoute`, unknown paths return Gin 404 |
+
+Build tags:
+
+| Tag | Result |
+|---|---|
+| *(default / no tag)* | `frontend_assets_embedded.go` embeds `web/default/dist` and `web/classic/dist` |
+| `frontend_external` | `frontend_assets_external.go` returns empty assets; pair with `FRONTEND_MODE=disabled` or `redirect` |
+
+Recommended same-origin split (frontend Nginx proxies backend):
+
+```text
+browser --> frontend container (:8080)
+ |-- SPA + /assets
+ +-- /api /v1 /v1beta /mj /:mode/mj /pg /suno /kling /jimeng
+ /healthz /livez /readyz --> backend (:3000, FRONTEND_MODE=disabled)
+```
+
+See `deploy/separated/README.md` and ADR `docs/adr/0001-frontend-backend-delivery-seam.md`.
+
+Operational shortcuts from repository root:
+
+```bash
+make build-backend
+make docker-separated
+FRONTEND_BASE=http://127.0.0.1:8080 ./deploy/separated/smoke.sh
+```
+
+## Split Deployment (process roles)
+
+1. Run one `RUN_MODE=migrate` job before updating application instances.
+2. Run at least one `RUN_MODE=scheduler` process with `NODE_TYPE=master`.
+3. Run at least one `RUN_MODE=worker` process with `NODE_TYPE=master`.
+4. Run Relay instances with `RUN_MODE=serve`, `APP_PLANE=relay`.
+5. Run management instances with `RUN_MODE=serve`, `APP_PLANE=management`.
+
+Relay and management instances may use `NODE_TYPE=slave` after the migration job succeeds. Worker and scheduler processes must not use `NODE_TYPE=slave` because task execution is master-only.
+
+When management HTTP is pure backend (`FRONTEND_MODE=disabled`), put the SPA on a same-origin reverse proxy rather than expanding public CORS unless a multi-origin layout is intentionally accepted.
+
+## Metrics
+
+Set `METRICS_ENABLED=true` to expose `/metrics`. Set `METRICS_TOKEN` and scrape with `Authorization: Bearer `. If no token is configured, restrict the endpoint at the network layer. The separated frontend edge deliberately returns 404 for `/metrics` so metrics stay off the public console origin.
+
+## Rollback
+
+- Process roles: restore `RUN_MODE=all` and `APP_PLANE=all` and start the previous single process.
+- Frontend delivery: restore the integrated image/binary (default embed build) and leave `FRONTEND_MODE` unset/`auto`.
+- Database migrations add compatible columns/indexes and do not require destructive rollback.
+
+## SPA NoRoute boundary
+
+Embedded mode must not serve `index.html` for backend/ops paths such as `/metrics`,
+`/v1`, `/v1beta`, `/mj`, `/pg`, `/suno`, `/kling`, `/jimeng`, `/dashboard`, or
+`/frontend-healthz`. Unregistered paths under those prefixes return API-style 404 JSON.
diff --git a/docs/operations/security-risk-acceptance.md b/docs/operations/security-risk-acceptance.md
new file mode 100644
index 000000000000..ff70749d5873
--- /dev/null
+++ b/docs/operations/security-risk-acceptance.md
@@ -0,0 +1,13 @@
+# Security Risk Acceptance
+
+## GO-2026-5932
+
+- **Reviewed:** 2026-07-17
+- **Review by:** 2026-10-17
+- **Module:** `golang.org/x/crypto@v0.52.0`
+- **Affected package:** `golang.org/x/crypto/openpgp`
+- **Decision:** Temporarily accepted as unreachable.
+
+`govulncheck -show verbose ./...` reports zero symbol-level and zero package-level vulnerabilities. The repository does not import `openpgp`; the advisory appears only because another safe `x/crypto` package keeps the module in the dependency graph. The advisory has no fixed version.
+
+Revoke this acceptance immediately if `openpgp` becomes reachable, a transitive dependency starts importing it, or a maintained replacement/fixed release becomes available. CI continues to run `govulncheck` on every pull request so either change becomes visible.
diff --git a/docs/operations/slo.md b/docs/operations/slo.md
new file mode 100644
index 000000000000..436c9adf3485
--- /dev/null
+++ b/docs/operations/slo.md
@@ -0,0 +1,31 @@
+# Service Level Objectives
+
+## Objectives
+
+| Surface | Indicator | Objective (rolling 30 days) |
+|---|---|---|
+| Relay | non-4xx availability | >= 99.9% |
+| Management API | non-5xx availability | >= 99.5% |
+| Management API | request latency P95 | < 300 ms |
+| Browser | LCP P75 | <= 2.5 s |
+| Browser | INP P75 | <= 200 ms |
+| Browser | CLS P75 | <= 0.1 |
+
+The Relay latency SLO must be split by route and upstream model. End-to-end model generation time is not a gateway-only SLO; alert on gateway errors, header timeout, cancellation and queueing separately.
+
+## Collection
+
+- Enable `METRICS_ENABLED` and scrape `/metrics` every 15 seconds.
+- The HTTP middleware exports request count, duration and in-flight requests using bounded route templates rather than raw paths.
+- The frontend sends only metric name, value and rating to `/api/rum`. It sends no URL, user ID, token, trace ID or content, and honors browser Do Not Track.
+- Keep `trace_id` and `request_id` in structured logs for drill-down after an alert.
+
+## Error Budget Workflow
+
+1. Page on sustained Relay 5xx or availability burn.
+2. Create a trace sample from affected route/model groups.
+3. Separate database, gateway and upstream duration before mitigation.
+4. Freeze risky releases when the 30-day error budget is exhausted.
+5. Record the incident, corrective action and regression test.
+
+Prometheus alert examples are in `deploy/prometheus/new-api-alerts.yml`.
diff --git a/dto/channel_settings.go b/dto/channel_settings.go
index dbcfd3181ae9..79da5427b97c 100644
--- a/dto/channel_settings.go
+++ b/dto/channel_settings.go
@@ -17,6 +17,9 @@ type ChannelSettings struct {
PassThroughBodyEnabled bool `json:"pass_through_body_enabled,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
+ // SkipAutoTest excludes this channel from AutomaticallyTestChannels / testAllChannels.
+ // Manual single-channel tests still work. Upstream discussion: #5205.
+ SkipAutoTest bool `json:"skip_auto_test,omitempty"`
}
type VertexKeyType string
diff --git a/frontend_assets_embedded.go b/frontend_assets_embedded.go
new file mode 100644
index 000000000000..2bebe8860f32
--- /dev/null
+++ b/frontend_assets_embedded.go
@@ -0,0 +1,84 @@
+//go:build !frontend_external
+
+package main
+
+import (
+ "bytes"
+ "embed"
+ "os"
+ "strings"
+
+ "github.com/QuantumNous/new-api/router"
+)
+
+//go:embed web/default/dist
+var buildFS embed.FS
+
+//go:embed web/default/dist/index.html
+var indexPage []byte
+
+//go:embed web/classic/dist
+var classicBuildFS embed.FS
+
+//go:embed web/classic/dist/index.html
+var classicIndexPage []byte
+
+// prepareFrontendAssets 注入一体化部署所需的分析脚本,并返回双主题嵌入资源。
+func prepareFrontendAssets() router.ThemeAssets {
+ // 先修改内存中的首页,再把同一份字节交给路由层,避免静态文件与 SPA 回退内容不一致。
+ InjectUmamiAnalytics()
+ InjectGoogleAnalytics()
+ return router.ThemeAssets{
+ DefaultBuildFS: buildFS,
+ DefaultIndexPage: indexPage,
+ ClassicBuildFS: classicBuildFS,
+ ClassicIndexPage: classicIndexPage,
+ }
+}
+
+// InjectUmamiAnalytics 把可选的 Umami 配置注入两个主题的首页模板。
+func InjectUmamiAnalytics() {
+ analyticsInjectBuilder := &strings.Builder{}
+ if os.Getenv("UMAMI_WEBSITE_ID") != "" {
+ umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID")
+ umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL")
+ if umamiScriptURL == "" {
+ umamiScriptURL = "https://analytics.umami.is/script.js"
+ }
+ analyticsInjectBuilder.WriteString("")
+ }
+ analyticsInjectBuilder.WriteString("\n")
+ analyticsInject := []byte(analyticsInjectBuilder.String())
+ placeholder := []byte("\n")
+ indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
+ classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
+}
+
+// InjectGoogleAnalytics 把可选的 Google Analytics 配置注入两个主题的首页模板。
+func InjectGoogleAnalytics() {
+ analyticsInjectBuilder := &strings.Builder{}
+ if os.Getenv("GOOGLE_ANALYTICS_ID") != "" {
+ gaID := os.Getenv("GOOGLE_ANALYTICS_ID")
+ // 生成 Google Analytics 4 的最小启动脚本。
+ analyticsInjectBuilder.WriteString("")
+ analyticsInjectBuilder.WriteString("")
+ }
+ analyticsInjectBuilder.WriteString("\n")
+ analyticsInject := []byte(analyticsInjectBuilder.String())
+ placeholder := []byte("\n")
+ indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
+ classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
+}
diff --git a/frontend_assets_external.go b/frontend_assets_external.go
new file mode 100644
index 000000000000..a50ca6120865
--- /dev/null
+++ b/frontend_assets_external.go
@@ -0,0 +1,10 @@
+//go:build frontend_external
+
+package main
+
+import "github.com/QuantumNous/new-api/router"
+
+// prepareFrontendAssets 为纯后端构建返回空资源,运行时必须选择 disabled 或 redirect 模式。
+func prepareFrontendAssets() router.ThemeAssets {
+ return router.ThemeAssets{}
+}
diff --git a/go.mod b/go.mod
index f44126002065..197fe98d7891 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,8 @@ module github.com/QuantumNous/new-api
// +heroku goVersion go1.18
go 1.25.1
+toolchain go1.26.5
+
require (
github.com/Calcium-Ion/go-epay v0.0.4
github.com/abema/go-mp4 v1.4.1
@@ -36,6 +38,7 @@ require (
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/pkg/errors v0.9.1
github.com/pquerna/otp v1.5.0
+ github.com/prometheus/client_golang v1.22.0
github.com/samber/hot v0.11.0
github.com/samber/lo v1.52.0
github.com/shirou/gopsutil v3.21.11+incompatible
@@ -50,11 +53,11 @@ require (
github.com/waffo-com/waffo-go v1.3.2
github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c
golang.org/x/crypto v0.52.0
- golang.org/x/image v0.41.0
+ golang.org/x/image v0.43.0
golang.org/x/net v0.55.0
- golang.org/x/sync v0.20.0
+ golang.org/x/sync v0.21.0
golang.org/x/sys v0.45.0
- golang.org/x/text v0.37.0
+ golang.org/x/text v0.38.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.4.3
gorm.io/driver/postgres v1.5.2
@@ -140,7 +143,6 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
- github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
diff --git a/go.sum b/go.sum
index e2faeb0d096d..5182ee96c7ec 100644
--- a/go.sum
+++ b/go.sum
@@ -2229,6 +2229,8 @@ golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeap
golang.org/x/image v0.0.0-20220302094943-723b81ca9867/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
+golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
+golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -2262,6 +2264,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -2397,6 +2400,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -2583,6 +2588,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
+golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -2677,6 +2684,7 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
+golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/i18n/i18n.go b/i18n/i18n.go
index 7ca8d2aa9971..eaa217be4e64 100644
--- a/i18n/i18n.go
+++ b/i18n/i18n.go
@@ -19,7 +19,8 @@ const (
LangZhCN = "zh-CN"
LangZhTW = "zh-TW"
LangEn = "en"
- DefaultLang = LangEn // Fallback to English if language not supported
+ // DefaultLang 在无法识别 Accept-Language / 用户语言时回落简体中文。
+ DefaultLang = LangZhCN
)
//go:embed locales/*.yaml
diff --git a/main.go b/main.go
index 770ea156ba86..4f1a045b4423 100644
--- a/main.go
+++ b/main.go
@@ -1,9 +1,7 @@
package main
import (
- "bytes"
"context"
- "embed"
"errors"
"fmt"
"log"
@@ -23,6 +21,7 @@ import (
"github.com/QuantumNous/new-api/middleware"
"github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/oauth"
+ "github.com/QuantumNous/new-api/pkg/observability"
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
"github.com/QuantumNous/new-api/relay"
"github.com/QuantumNous/new-api/router"
@@ -40,28 +39,20 @@ import (
_ "net/http/pprof"
)
-//go:embed web/default/dist
-var buildFS embed.FS
-
-//go:embed web/default/dist/index.html
-var indexPage []byte
-
-//go:embed web/classic/dist
-var classicBuildFS embed.FS
-
-//go:embed web/classic/dist/index.html
-var classicIndexPage []byte
-
func main() {
startTime := time.Now()
+ mode, plane, err := parseRuntimeConfig(os.Getenv("RUN_MODE"), os.Getenv("APP_PLANE"), os.Getenv("NODE_TYPE"))
+ if err != nil {
+ log.Fatalf("invalid runtime configuration: %v", err)
+ }
- err := InitResources()
+ err = InitResources()
if err != nil {
common.FatalLog("failed to initialize resources: " + err.Error())
return
}
- common.SysLog("New API " + common.Version + " started")
+ common.SysLog(fmt.Sprintf("New API %s started: run_mode=%s plane=%s", common.Version, mode, plane))
if os.Getenv("GIN_MODE") != "debug" {
gin.SetMode(gin.ReleaseMode)
}
@@ -75,10 +66,26 @@ func main() {
common.FatalLog("failed to close database: " + err.Error())
}
}()
+ if mode == runModeMigrate {
+ common.SysLog("database migration completed")
+ return
+ }
+ systemTaskCtx, stopSystemTasks := context.WithCancel(context.Background())
+ defer stopSystemTasks()
if common.RedisEnabled {
// for compatibility with old versions
common.MemoryCacheEnabled = true
+ // Multi-instance adaptive metrics snapshot (best-effort, 2m TTL).
+ if mode.servesHTTP() || mode.runsWorker() {
+ gopool.Go(func() {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+ for range ticker.C {
+ service.SyncAdaptiveMetricsToRedis()
+ }
+ })
+ }
}
if common.MemoryCacheEnabled {
common.SysLog("memory cache enabled")
@@ -113,9 +120,11 @@ func main() {
go authz.StartPolicySync(common.SyncFrequency)
// 数据看板
- go model.UpdateQuotaData()
+ if mode.servesHTTP() {
+ go model.UpdateQuotaData()
+ }
- if os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" {
+ if mode.runsScheduler() && os.Getenv("CHANNEL_UPDATE_FREQUENCY") != "" {
frequency, err := strconv.Atoi(os.Getenv("CHANNEL_UPDATE_FREQUENCY"))
if err != nil {
common.FatalLog("failed to parse CHANNEL_UPDATE_FREQUENCY: " + err.Error())
@@ -124,10 +133,12 @@ func main() {
}
// Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day
- service.StartCodexCredentialAutoRefreshTask()
+ if mode.runsScheduler() {
+ service.StartCodexCredentialAutoRefreshTask()
- // Subscription quota reset task (daily/weekly/monthly/custom)
- service.StartSubscriptionQuotaResetTask()
+ // Subscription quota reset task (daily/weekly/monthly/custom)
+ service.StartSubscriptionQuotaResetTask()
+ }
// Report this process as a system instance so the System Info page can show
// all currently alive nodes in multi-instance deployments.
@@ -136,12 +147,16 @@ func main() {
// Wire task polling adaptor factory (breaks service -> relay import cycle).
// Must run before the system task runner starts: the async_task_poll handler
// calls service.RunTaskPollingOnce, which needs this factory set.
- service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor {
- a := relay.GetTaskAdaptor(platform)
- if a == nil {
- return nil
+ if mode.runsWorker() || mode.runsScheduler() {
+ service.GetTaskAdaptorFunc = func(platform constant.TaskPlatform) service.TaskPollingAdaptor {
+ a := relay.GetTaskAdaptor(platform)
+ if a == nil {
+ return nil
+ }
+ return a
}
- return a
+
+ controller.RegisterScheduledSystemTasks()
}
// Register the periodic channel test, upstream model update, and async task
@@ -149,10 +164,14 @@ func main() {
// (DB-lease dedup across masters + run history), then start the runner that
// schedules and executes them. Master-only execution and the UpdateTask
// switch are enforced inside the runner and each handler's Enabled().
- controller.RegisterScheduledSystemTasks()
- service.StartSystemTaskRunner()
+ if mode.runsScheduler() {
+ service.StartSystemTaskSchedulerContext(systemTaskCtx)
+ }
+ if mode.runsWorker() {
+ service.StartSystemTaskWorkerContext(systemTaskCtx)
+ }
- if os.Getenv("BATCH_UPDATE_ENABLED") == "true" {
+ if mode.servesHTTP() && os.Getenv("BATCH_UPDATE_ENABLED") == "true" {
common.BatchUpdateEnabled = true
common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s")
model.InitBatchUpdater()
@@ -160,10 +179,10 @@ func main() {
if os.Getenv("ENABLE_PPROF") == "true" {
gopool.Go(func() {
- log.Println(http.ListenAndServe("0.0.0.0:8005", nil))
+ log.Println(http.ListenAndServe("127.0.0.1:8005", nil))
})
go common.Monitor()
- common.SysLog("pprof enabled")
+ common.SysLog("pprof enabled on 127.0.0.1:8005")
}
err = common.StartPyroScope()
@@ -171,13 +190,38 @@ func main() {
common.SysError(fmt.Sprintf("start pyroscope error : %v", err))
}
+ if !mode.servesHTTP() {
+ common.SysLog(fmt.Sprintf("runtime ready: run_mode=%s", mode))
+ quit := make(chan os.Signal, 1)
+ signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
+ sig := <-quit
+ common.SysLog(fmt.Sprintf("received signal: %v, shutting down...", sig))
+ stopSystemTasks()
+ shutdownTimeout := time.Duration(common.GetEnvOrDefault("SHUTDOWN_TIMEOUT_SECONDS", 120)) * time.Second
+ ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
+ defer cancel()
+ if err := service.WaitForSystemTasks(ctx); err != nil {
+ common.SysError(fmt.Sprintf("system tasks did not stop before shutdown deadline: %v", err))
+ }
+ return
+ }
+
// Initialize HTTP server
server := gin.New()
+ if err := configureTrustedProxies(server); err != nil {
+ common.FatalLog("failed to configure trusted proxies: " + err.Error())
+ return
+ }
server.Use(gin.CustomRecovery(func(c *gin.Context, err any) {
- common.SysLog(fmt.Sprintf("panic detected: %v", err))
+ reqID := c.GetString(common.RequestIdKey)
+ common.SysLog(fmt.Sprintf("panic detected request_id=%s: %v", reqID, err))
+ msg := "Internal server error"
+ if reqID != "" {
+ msg = fmt.Sprintf("Internal server error (request_id=%s)", reqID)
+ }
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
- "message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),
+ "message": msg,
"type": "new_api_panic",
},
})
@@ -186,7 +230,14 @@ func main() {
//server.Use(gzip.Gzip(gzip.DefaultCompression))
server.Use(middleware.RequestId())
server.Use(middleware.Version())
+ server.Use(middleware.TraceContext())
+ // HSTS 等安全头:经 Tunnel/CF 的 HTTPS 回源会带 X-Forwarded-Proto。
+ server.Use(middleware.SecurityHeaders())
server.Use(middleware.I18n())
+ if observability.Enabled() {
+ server.Use(observability.HTTPMiddleware())
+ server.GET("/metrics", observability.MetricsAuth(), gin.WrapH(observability.Handler()))
+ }
middleware.SetUpLogger(server)
// Initialize session store
store := cookie.NewStore([]byte(common.SessionSecret))
@@ -199,25 +250,18 @@ func main() {
})
server.Use(sessions.Sessions("session", store))
- InjectUmamiAnalytics()
- InjectGoogleAnalytics()
-
// 设置路由
- router.SetRouter(server, router.ThemeAssets{
- DefaultBuildFS: buildFS,
- DefaultIndexPage: indexPage,
- ClassicBuildFS: classicBuildFS,
- ClassicIndexPage: classicIndexPage,
- })
+ // 统一通过构建适配器取得前端资源,使后端镜像可以选择完全不嵌入静态文件。
+ if err := router.SetRouterForPlane(server, prepareFrontendAssets(), plane); err != nil {
+ common.FatalLog("failed to configure router: " + err.Error())
+ return
+ }
var port = os.Getenv("PORT")
if port == "" {
port = strconv.Itoa(*common.Port)
}
- srv := &http.Server{
- Addr: ":" + port,
- Handler: server,
- }
+ srv := newHTTPServer(":"+port, server)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
@@ -233,6 +277,7 @@ func main() {
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
common.SysLog(fmt.Sprintf("received signal: %v, shutting down...", sig))
+ stopSystemTasks()
// SSE streams may run for minutes; give them time to finish before forced exit
shutdownTimeout := time.Duration(common.GetEnvOrDefault("SHUTDOWN_TIMEOUT_SECONDS", 120)) * time.Second
@@ -241,6 +286,9 @@ func main() {
if err := srv.Shutdown(ctx); err != nil {
common.SysError(fmt.Sprintf("server forced to shutdown: %v", err))
}
+ if err := service.WaitForSystemTasks(ctx); err != nil {
+ common.SysError(fmt.Sprintf("system tasks did not stop before shutdown deadline: %v", err))
+ }
// 内存中的看板数据保存入库,避免重启丢失未落库数据 (issue #5679)
if common.DataExportEnabled {
model.SaveQuotaDataCache()
@@ -248,49 +296,14 @@ func main() {
common.SysLog("server exited")
}
-func InjectUmamiAnalytics() {
- analyticsInjectBuilder := &strings.Builder{}
- if os.Getenv("UMAMI_WEBSITE_ID") != "" {
- umamiSiteID := os.Getenv("UMAMI_WEBSITE_ID")
- umamiScriptURL := os.Getenv("UMAMI_SCRIPT_URL")
- if umamiScriptURL == "" {
- umamiScriptURL = "https://analytics.umami.is/script.js"
- }
- analyticsInjectBuilder.WriteString("")
- }
- analyticsInjectBuilder.WriteString("\n")
- analyticsInject := []byte(analyticsInjectBuilder.String())
- placeholder := []byte("\n")
- indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
- classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
-}
-
-func InjectGoogleAnalytics() {
- analyticsInjectBuilder := &strings.Builder{}
- if os.Getenv("GOOGLE_ANALYTICS_ID") != "" {
- gaID := os.Getenv("GOOGLE_ANALYTICS_ID")
- // Google Analytics 4 (gtag.js)
- analyticsInjectBuilder.WriteString("")
- analyticsInjectBuilder.WriteString("")
- }
- analyticsInjectBuilder.WriteString("\n")
- analyticsInject := []byte(analyticsInjectBuilder.String())
- placeholder := []byte("\n")
- indexPage = bytes.ReplaceAll(indexPage, placeholder, analyticsInject)
- classicIndexPage = bytes.ReplaceAll(classicIndexPage, placeholder, analyticsInject)
+func newHTTPServer(addr string, handler http.Handler) *http.Server {
+ return &http.Server{
+ Addr: addr,
+ Handler: handler,
+ ReadHeaderTimeout: time.Duration(common.GetEnvOrDefault("HTTP_READ_HEADER_TIMEOUT_SECONDS", 10)) * time.Second,
+ IdleTimeout: time.Duration(common.GetEnvOrDefault("HTTP_IDLE_TIMEOUT_SECONDS", 120)) * time.Second,
+ MaxHeaderBytes: common.GetEnvOrDefault("HTTP_MAX_HEADER_BYTES", 1<<20),
+ }
}
func InitResources() error {
diff --git a/main_server_test.go b/main_server_test.go
new file mode 100644
index 000000000000..1724fe9fcb6e
--- /dev/null
+++ b/main_server_test.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewHTTPServerUsesSafeConnectionDefaults(t *testing.T) {
+ t.Setenv("HTTP_READ_HEADER_TIMEOUT_SECONDS", "")
+ t.Setenv("HTTP_IDLE_TIMEOUT_SECONDS", "")
+ t.Setenv("HTTP_MAX_HEADER_BYTES", "")
+
+ server := newHTTPServer(":3000", http.NewServeMux())
+ require.Equal(t, 10*time.Second, server.ReadHeaderTimeout)
+ require.Equal(t, 120*time.Second, server.IdleTimeout)
+ require.Equal(t, 1<<20, server.MaxHeaderBytes)
+}
+
+func TestNewHTTPServerAcceptsTimeoutOverrides(t *testing.T) {
+ t.Setenv("HTTP_READ_HEADER_TIMEOUT_SECONDS", "7")
+ t.Setenv("HTTP_IDLE_TIMEOUT_SECONDS", "90")
+ t.Setenv("HTTP_MAX_HEADER_BYTES", "524288")
+
+ server := newHTTPServer(":3000", http.NewServeMux())
+ require.Equal(t, 7*time.Second, server.ReadHeaderTimeout)
+ require.Equal(t, 90*time.Second, server.IdleTimeout)
+ require.Equal(t, 524288, server.MaxHeaderBytes)
+}
diff --git a/makefile b/makefile
index 58c4ae4c6677..835d4c078476 100644
--- a/makefile
+++ b/makefile
@@ -10,25 +10,54 @@ DEV_POSTGRES_DB = new-api
DEV_POSTGRES_USER = root
DEV_SQLITE_PATH ?= one-api.db
-.PHONY: all build-web build-web-classic build-all-web start-api dev dev-api dev-api-rebuild dev-web dev-web-classic reset-setup
+.PHONY: all check-build-root check-version build-web build-web-classic build-all-web start-api dev dev-api dev-api-rebuild dev-web dev-web-classic reset-setup build-backend docker-integrated docker-backend docker-frontend docker-separated
all: build-all-web start-api
-build-web:
+check-build-root:
+ @test "$$(git rev-parse --show-prefix)" = "" || (echo "Run make from the authoritative repository root." && exit 1)
+ @test "$$(basename "$$(git rev-parse --show-toplevel)")" != "_qn_tmp" || (echo "Refusing to build from the upstream reference tree." && exit 1)
+
+check-version: check-build-root
+ @test -s VERSION || (echo "VERSION must not be empty." && exit 1)
+
+build-web: check-version
@echo "Building default web..."
@cd ./web && bun install --frozen-lockfile
@cd $(WEB_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
-build-web-classic:
+build-web-classic: check-version
@echo "Building classic web..."
@cd ./web && bun install --frozen-lockfile
@cd $(WEB_CLASSIC_DIR) && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
build-all-web: build-web build-web-classic
-start-api:
+# Pure backend binary without embedding web/*/dist (requires FRONTEND_MODE=disabled|redirect at runtime).
+build-backend: check-version
+ @echo "Building pure backend (tags=frontend_external)..."
+ @go build -trimpath -buildvcs=true -tags frontend_external \
+ -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$$(cat VERSION)'" \
+ -o new-api-backend .
+
+docker-integrated: check-build-root
+ @echo "Building integrated image..."
+ @docker build --tag new-api:local .
+
+docker-backend: check-build-root
+ @echo "Building pure backend image..."
+ @docker build -f Dockerfile.backend --tag new-api-backend:local .
+
+docker-frontend: check-build-root
+ @echo "Building separated frontend image..."
+ @docker build -f deploy/separated/Dockerfile.frontend --tag new-api-frontend:local .
+
+docker-separated: docker-backend docker-frontend
+ @echo "Separated images ready: new-api-backend:local new-api-frontend:local"
+
+start-api: check-build-root
@echo "Starting api dev server..."
- @cd $(API_DIR) && go run main.go &
+ @cd $(API_DIR) && go run . &
dev-api:
@echo "Starting api services (docker)..."
diff --git a/middleware/auth.go b/middleware/auth.go
index 86abddc79945..00d2aafca613 100644
--- a/middleware/auth.go
+++ b/middleware/auth.go
@@ -23,6 +23,24 @@ import (
"gorm.io/gorm"
)
+func asIntID(v any) int {
+ switch x := v.(type) {
+ case int:
+ return x
+ case int32:
+ return int(x)
+ case int64:
+ return int(x)
+ case float64:
+ return int(x)
+ case string:
+ n, _ := strconv.Atoi(x)
+ return n
+ default:
+ return 0
+ }
+}
+
func validUserInfo(username string, role int) bool {
// check username is empty
if strings.TrimSpace(username) == "" {
@@ -34,12 +52,39 @@ func validUserInfo(username string, role int) bool {
return true
}
+func getFreshSessionUser(userID int) (*model.User, error) {
+ if userID <= 0 {
+ return nil, gorm.ErrRecordNotFound
+ }
+ if model.DB == nil {
+ return nil, model.ErrDatabase
+ }
+ return model.GetUserById(userID, false)
+}
+
+func abortSessionUserRefresh(c *gin.Context, err error) {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ c.JSON(http.StatusUnauthorized, gin.H{
+ "success": false,
+ "message": common.TranslateMessage(c, i18n.MsgAuthNotLoggedIn),
+ })
+ } else {
+ common.SysLog("session user refresh failed: " + err.Error())
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "success": false,
+ "message": common.TranslateMessage(c, i18n.MsgDatabaseError),
+ })
+ }
+ c.Abort()
+}
+
func authHelper(c *gin.Context, minRole int) {
session := sessions.Default(c)
username := session.Get("username")
role := session.Get("role")
id := session.Get("id")
status := session.Get("status")
+ authenticatedBySession := username != nil
useAccessToken := false
if username == nil {
// Check access token
@@ -113,7 +158,7 @@ func authHelper(c *gin.Context, minRole int) {
return
}
- if id != apiUserId {
+ if asIntID(id) != apiUserId {
c.JSON(http.StatusUnauthorized, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthUserIdMismatch),
@@ -121,7 +166,29 @@ func authHelper(c *gin.Context, minRole int) {
c.Abort()
return
}
- if status.(int) == common.UserStatusDisabled {
+ // Session cookies are identity hints. Authorization always comes from the
+ // current database row so bans, deletion and demotion fail closed.
+ userGroup := ""
+ if g := session.Get("group"); g != nil {
+ if gs, ok := g.(string); ok {
+ userGroup = gs
+ }
+ }
+ if authenticatedBySession {
+ full, refreshErr := getFreshSessionUser(asIntID(id))
+ if refreshErr != nil {
+ abortSessionUserRefresh(c, refreshErr)
+ return
+ }
+ username = full.Username
+ role = full.Role
+ status = full.Status
+ userGroup = full.Group
+ }
+ statusInt := asIntID(status)
+ roleInt := asIntID(role)
+ usernameStr, _ := username.(string)
+ if statusInt == common.UserStatusDisabled {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
@@ -129,7 +196,7 @@ func authHelper(c *gin.Context, minRole int) {
c.Abort()
return
}
- if role.(int) < minRole {
+ if roleInt < minRole {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthInsufficientPrivilege),
@@ -137,7 +204,7 @@ func authHelper(c *gin.Context, minRole int) {
c.Abort()
return
}
- if !validUserInfo(username.(string), role.(int)) {
+ if !validUserInfo(usernameStr, roleInt) {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgAuthUserInfoInvalid),
@@ -145,13 +212,18 @@ func authHelper(c *gin.Context, minRole int) {
c.Abort()
return
}
+ // Normalize context values after possible interface typing from sessions.
+ username = usernameStr
+ role = roleInt
+ status = statusInt
+ id = asIntID(id)
// 防止不同newapi版本冲突,导致数据不通用
c.Header("Auth-Version", "864b7076dbcd0a3c01b5520316720ebf")
c.Set("username", username)
c.Set("role", role)
c.Set("id", id)
- c.Set("group", session.Get("group"))
- c.Set("user_group", session.Get("group"))
+ c.Set("group", userGroup)
+ c.Set("user_group", userGroup)
c.Set("use_access_token", useAccessToken)
// 管理/root 写操作审计兜底:内聚在鉴权链路里,保证任何经过 AdminAuth/RootAuth
@@ -220,24 +292,41 @@ func WssAuth(c *gin.Context) {
// Used for endpoints that need to be accessible from both the dashboard and API clients.
func TokenOrUserAuth() func(c *gin.Context) {
return func(c *gin.Context) {
- // Try session auth first (dashboard users)
+ // Try session auth first (dashboard users) — re-source status from DB
+ // so ban/disable takes effect before 30-day cookie expires.
session := sessions.Default(c)
if id := session.Get("id"); id != nil {
- if status, ok := session.Get("status").(int); ok && status == common.UserStatusEnabled {
- c.Set("id", id)
- c.Next()
+ uid := asIntID(id)
+ full, refreshErr := getFreshSessionUser(uid)
+ if refreshErr != nil {
+ abortSessionUserRefresh(c, refreshErr)
return
}
+ if full.Status != common.UserStatusEnabled {
+ c.JSON(http.StatusForbidden, gin.H{
+ "success": false,
+ "message": common.TranslateMessage(c, i18n.MsgAuthUserBanned),
+ })
+ c.Abort()
+ return
+ }
+ c.Set("id", full.Id)
+ c.Set("username", full.Username)
+ c.Set("role", full.Role)
+ c.Set("status", full.Status)
+ c.Set("group", full.Group)
+ c.Set("user_group", full.Group)
+ c.Next()
+ return
}
// Fall back to token auth (API clients)
TokenAuth()(c)
}
}
-// TokenAuthReadOnly 宽松版本的令牌认证中间件,用于只读查询接口。
-// 只验证令牌 key 是否存在,不检查令牌状态、过期时间和额度。
-// 即使令牌已过期、已耗尽或已禁用,也允许访问。
-// 仍然检查用户是否被封禁。
+// TokenAuthReadOnly is used by usage/log query endpoints.
+// Rejects explicitly disabled tokens; expired/exhausted tokens may still read metadata.
+// User ban checks remain required.
func TokenAuthReadOnly() func(c *gin.Context) {
return func(c *gin.Context) {
key := c.Request.Header.Get("Authorization")
diff --git a/middleware/auth_test.go b/middleware/auth_test.go
new file mode 100644
index 000000000000..9aa5f939e91b
--- /dev/null
+++ b/middleware/auth_test.go
@@ -0,0 +1,224 @@
+package middleware
+
+import (
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-contrib/sessions/cookie"
+ "github.com/gin-gonic/gin"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func setupTokenOrUserAuthTestDB(t *testing.T) *gorm.DB {
+ t.Helper()
+
+ originalDB := model.DB
+ originalRedisEnabled := common.RedisEnabled
+ common.RedisEnabled = false
+
+ 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)
+ require.NoError(t, db.AutoMigrate(&model.User{}))
+ model.DB = db
+
+ t.Cleanup(func() {
+ model.DB = originalDB
+ common.RedisEnabled = originalRedisEnabled
+ if sqlDB, err := db.DB(); err == nil {
+ _ = sqlDB.Close()
+ }
+ })
+
+ return db
+}
+
+func tokenOrUserAuthSessionCookies(t *testing.T, router *gin.Engine, userID int) []*http.Cookie {
+ t.Helper()
+
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/login", nil)
+ router.ServeHTTP(recorder, request)
+ require.Equal(t, http.StatusNoContent, recorder.Code)
+ return recorder.Result().Cookies()
+}
+
+func newTokenOrUserAuthTestRouter(t *testing.T, userID int, handler gin.HandlerFunc) *gin.Engine {
+ t.Helper()
+
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.Use(sessions.Sessions("session", cookie.NewStore([]byte("token-or-user-auth-test"))))
+ router.GET("/login", func(c *gin.Context) {
+ session := sessions.Default(c)
+ session.Set("id", userID)
+ session.Set("username", "stale-user")
+ session.Set("role", common.RoleCommonUser)
+ session.Set("status", common.UserStatusEnabled)
+ session.Set("group", "default")
+ require.NoError(t, session.Save())
+ c.Status(http.StatusNoContent)
+ })
+ router.GET("/protected", TokenOrUserAuth(), handler)
+ return router
+}
+
+func TestTokenOrUserAuthRejectsDBDisabledSessionUser(t *testing.T) {
+ db := setupTokenOrUserAuthTestDB(t)
+ user := &model.User{
+ Id: 101,
+ Username: "disabled-user",
+ Password: "not-used-in-test",
+ Role: common.RoleCommonUser,
+ Status: common.UserStatusDisabled,
+ Group: "default",
+ }
+ require.NoError(t, db.Create(user).Error)
+
+ handlerCalled := false
+ router := newTokenOrUserAuthTestRouter(t, user.Id, func(c *gin.Context) {
+ handlerCalled = true
+ c.Status(http.StatusNoContent)
+ })
+ cookies := tokenOrUserAuthSessionCookies(t, router, user.Id)
+
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/protected", nil)
+ for _, sessionCookie := range cookies {
+ request.AddCookie(sessionCookie)
+ }
+ router.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusForbidden, recorder.Code)
+ require.False(t, handlerCalled)
+}
+
+func TestTokenOrUserAuthRefreshesSessionContextFromDB(t *testing.T) {
+ db := setupTokenOrUserAuthTestDB(t)
+ user := &model.User{
+ Id: 102,
+ Username: "fresh-user",
+ Password: "not-used-in-test",
+ Role: common.RoleAdminUser,
+ Status: common.UserStatusEnabled,
+ Group: "premium",
+ }
+ require.NoError(t, db.Create(user).Error)
+
+ router := newTokenOrUserAuthTestRouter(t, user.Id, func(c *gin.Context) {
+ require.Equal(t, user.Username, c.GetString("username"))
+ require.Equal(t, user.Role, c.GetInt("role"))
+ require.Equal(t, user.Group, c.GetString("user_group"))
+ c.Status(http.StatusNoContent)
+ })
+ cookies := tokenOrUserAuthSessionCookies(t, router, user.Id)
+
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/protected", nil)
+ for _, sessionCookie := range cookies {
+ request.AddCookie(sessionCookie)
+ }
+ router.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestTokenOrUserAuthRejectsMissingSessionUser(t *testing.T) {
+ setupTokenOrUserAuthTestDB(t)
+ const missingUserID = 103
+
+ handlerCalled := false
+ router := newTokenOrUserAuthTestRouter(t, missingUserID, func(c *gin.Context) {
+ handlerCalled = true
+ c.Status(http.StatusNoContent)
+ })
+ cookies := tokenOrUserAuthSessionCookies(t, router, missingUserID)
+
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/protected", nil)
+ for _, sessionCookie := range cookies {
+ request.AddCookie(sessionCookie)
+ }
+ router.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusUnauthorized, recorder.Code)
+ require.False(t, handlerCalled)
+}
+
+func TestTokenOrUserAuthFailsClosedOnDatabaseError(t *testing.T) {
+ db := setupTokenOrUserAuthTestDB(t)
+ user := &model.User{
+ Id: 104,
+ Username: "db-error-user",
+ Password: "not-used-in-test",
+ Role: common.RoleCommonUser,
+ Status: common.UserStatusEnabled,
+ Group: "default",
+ }
+ require.NoError(t, db.Create(user).Error)
+
+ handlerCalled := false
+ router := newTokenOrUserAuthTestRouter(t, user.Id, func(c *gin.Context) {
+ handlerCalled = true
+ c.Status(http.StatusNoContent)
+ })
+ cookies := tokenOrUserAuthSessionCookies(t, router, user.Id)
+
+ sqlDB, err := db.DB()
+ require.NoError(t, err)
+ require.NoError(t, sqlDB.Close())
+
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/protected", nil)
+ for _, sessionCookie := range cookies {
+ request.AddCookie(sessionCookie)
+ }
+ router.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusInternalServerError, recorder.Code)
+ require.False(t, handlerCalled)
+}
+
+func TestAdminAuthRejectsDeletedSessionUser(t *testing.T) {
+ setupTokenOrUserAuthTestDB(t)
+ const missingUserID = 105
+
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.Use(sessions.Sessions("session", cookie.NewStore([]byte("admin-auth-test"))))
+ router.GET("/login", func(c *gin.Context) {
+ session := sessions.Default(c)
+ session.Set("id", missingUserID)
+ session.Set("username", "deleted-admin")
+ session.Set("role", common.RoleAdminUser)
+ session.Set("status", common.UserStatusEnabled)
+ session.Set("group", "default")
+ require.NoError(t, session.Save())
+ c.Status(http.StatusNoContent)
+ })
+ handlerCalled := false
+ router.GET("/admin", AdminAuth(), func(c *gin.Context) {
+ handlerCalled = true
+ c.Status(http.StatusNoContent)
+ })
+
+ cookies := tokenOrUserAuthSessionCookies(t, router, missingUserID)
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/admin", nil)
+ request.Header.Set("New-Api-User", fmt.Sprintf("%d", missingUserID))
+ for _, sessionCookie := range cookies {
+ request.AddCookie(sessionCookie)
+ }
+ router.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusUnauthorized, recorder.Code)
+ require.False(t, handlerCalled)
+}
diff --git a/middleware/cache.go b/middleware/cache.go
index 1a9dff877d9e..8c554690b572 100644
--- a/middleware/cache.go
+++ b/middleware/cache.go
@@ -1,15 +1,21 @@
package middleware
import (
+ "regexp"
+
"github.com/gin-gonic/gin"
)
+var fingerprintedAssetPattern = regexp.MustCompile(`\.[0-9a-f]{8,}\.(?:css|eot|gif|ico|jpe?g|js|png|svg|ttf|webp|woff2?)$`)
+
func Cache() func(c *gin.Context) {
return func(c *gin.Context) {
- if c.Request.RequestURI == "/" {
- c.Header("Cache-Control", "no-cache")
+ if fingerprintedAssetPattern.MatchString(c.Request.URL.Path) {
+ c.Header("Cache-Control", "public, max-age=31536000, immutable")
} else {
- c.Header("Cache-Control", "max-age=604800") // one week
+ // HTML, SPA routes and stable filenames must revalidate so a release
+ // cannot strand clients on an old entry document.
+ c.Header("Cache-Control", "no-cache")
}
c.Header("Cache-Version", "b688f2fb5be447c25e5aa3bd063087a83db32a288bf6a4f35f2d8db310e40b14")
c.Next()
diff --git a/middleware/cache_test.go b/middleware/cache_test.go
new file mode 100644
index 000000000000..0e7175c4d013
--- /dev/null
+++ b/middleware/cache_test.go
@@ -0,0 +1,30 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func cacheHeaderForPath(path string) string {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.Use(Cache())
+ router.GET("/*path", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+ recorder := httptest.NewRecorder()
+ router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
+ return recorder.Header().Get("Cache-Control")
+}
+
+func TestCacheUsesImmutablePolicyForFingerprintedAssets(t *testing.T) {
+ require.Equal(t, "public, max-age=31536000, immutable", cacheHeaderForPath("/static/js/index.457aecb830.js?theme=default"))
+ require.Equal(t, "public, max-age=31536000, immutable", cacheHeaderForPath("/static/font/public-sans.035c7fe496.woff2"))
+}
+
+func TestCacheRevalidatesHTMLAndStableAssets(t *testing.T) {
+ require.Equal(t, "no-cache", cacheHeaderForPath("/dashboard"))
+ require.Equal(t, "no-cache", cacheHeaderForPath("/favicon.ico"))
+}
diff --git a/middleware/cors.go b/middleware/cors.go
index e90d77bdd34b..b8b7b6090e21 100644
--- a/middleware/cors.go
+++ b/middleware/cors.go
@@ -1,20 +1,93 @@
package middleware
import (
+ "net/url"
+ "os"
+ "strings"
+
"github.com/QuantumNous/new-api/common"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func CORS() gin.HandlerFunc {
+ allowedOrigins := parseAllowedOrigins("CORS_ALLOWED_ORIGINS")
+ if len(allowedOrigins) == 0 {
+ // Reuse the explicitly trusted HTTPS frontends when secure session cookies
+ // are configured. With neither setting present, cross-origin access is
+ // denied; normal same-origin requests do not require CORS headers.
+ allowedOrigins = parseAllowedOrigins("SESSION_COOKIE_TRUSTED_URL")
+ }
+ if len(allowedOrigins) == 0 {
+ return func(c *gin.Context) {
+ c.Next()
+ }
+ }
+
config := cors.DefaultConfig()
- config.AllowAllOrigins = true
+ config.AllowOrigins = allowedOrigins
config.AllowCredentials = true
- config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
- config.AllowHeaders = []string{"*"}
+ config.AllowMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
+ config.AllowHeaders = []string{
+ "Accept",
+ "Authorization",
+ "Content-Type",
+ "New-Api-Key",
+ "New-Api-User",
+ "X-Request-Id",
+ "X-Trace-Id",
+ "X-Thread-Id",
+ "AH-Trace-Id",
+ "AH-Thread-Id",
+ }
+ config.ExposeHeaders = []string{
+ "X-Oneapi-Request-Id",
+ "X-Request-Id",
+ "X-Trace-Id",
+ "X-Thread-Id",
+ "AH-Trace-Id",
+ "AH-Thread-Id",
+ }
return cors.New(config)
}
+func parseAllowedOrigins(name string) []string {
+ raw := strings.TrimSpace(os.Getenv(name))
+ if raw == "" {
+ return nil
+ }
+ seen := make(map[string]struct{})
+ values := make([]string, 0)
+ for _, item := range strings.Split(raw, ",") {
+ value, ok := normalizeAllowedOrigin(item)
+ if !ok {
+ common.SysError("ignoring invalid " + name + " origin entry")
+ continue
+ }
+ if _, ok := seen[value]; ok {
+ continue
+ }
+ seen[value] = struct{}{}
+ values = append(values, value)
+ }
+ return values
+}
+
+func normalizeAllowedOrigin(value string) (string, bool) {
+ value = strings.TrimSpace(value)
+ if value == "" || strings.Contains(value, "*") {
+ return "", false
+ }
+ parsed, err := url.Parse(value)
+ if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
+ return "", false
+ }
+ if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
+ return "", false
+ }
+ return parsed.Scheme + "://" + parsed.Host, true
+}
+
func Version() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-New-Api-Version", common.Version)
diff --git a/middleware/cors_test.go b/middleware/cors_test.go
new file mode 100644
index 000000000000..07ce3c7a5584
--- /dev/null
+++ b/middleware/cors_test.go
@@ -0,0 +1,83 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func corsTestRouter() *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ router := gin.New()
+ router.Use(CORS())
+ router.GET("/resource", func(c *gin.Context) { c.Status(http.StatusNoContent) })
+ return router
+}
+
+func performCORSPreflight(t *testing.T, router *gin.Engine, origin string) *httptest.ResponseRecorder {
+ t.Helper()
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodOptions, "/resource", nil)
+ request.Header.Set("Origin", origin)
+ request.Header.Set("Access-Control-Request-Method", http.MethodGet)
+ request.Header.Set("Access-Control-Request-Headers", "authorization,content-type")
+ router.ServeHTTP(recorder, request)
+ return recorder
+}
+
+func TestCORSRejectsCrossOriginByDefault(t *testing.T) {
+ t.Setenv("CORS_ALLOWED_ORIGINS", "")
+ t.Setenv("SESSION_COOKIE_TRUSTED_URL", "")
+
+ recorder := performCORSPreflight(t, corsTestRouter(), "https://evil.example")
+ require.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin"))
+ require.NotEqual(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestCORSRejectsOriginOutsideConfiguredAllowlist(t *testing.T) {
+ t.Setenv("CORS_ALLOWED_ORIGINS", "https://console.example")
+ t.Setenv("SESSION_COOKIE_TRUSTED_URL", "")
+
+ recorder := performCORSPreflight(t, corsTestRouter(), "https://evil.example")
+ require.Equal(t, http.StatusForbidden, recorder.Code)
+ require.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin"))
+}
+
+func TestCORSAllowsConfiguredCredentialOrigin(t *testing.T) {
+ t.Setenv("CORS_ALLOWED_ORIGINS", "https://console.example, https://admin.example")
+ t.Setenv("SESSION_COOKIE_TRUSTED_URL", "")
+
+ recorder := performCORSPreflight(t, corsTestRouter(), "https://console.example")
+ require.Equal(t, http.StatusNoContent, recorder.Code)
+ require.Equal(t, "https://console.example", recorder.Header().Get("Access-Control-Allow-Origin"))
+ require.Equal(t, "true", recorder.Header().Get("Access-Control-Allow-Credentials"))
+}
+
+func TestCORSFallsBackToTrustedSessionURLs(t *testing.T) {
+ t.Setenv("CORS_ALLOWED_ORIGINS", "")
+ t.Setenv("SESSION_COOKIE_TRUSTED_URL", "https://console.example")
+
+ recorder := performCORSPreflight(t, corsTestRouter(), "https://console.example")
+ require.Equal(t, "https://console.example", recorder.Header().Get("Access-Control-Allow-Origin"))
+}
+
+func TestCORSRejectsWildcardAndInvalidOrigins(t *testing.T) {
+ t.Setenv("CORS_ALLOWED_ORIGINS", "*,javascript:alert(1),https://console.example/path")
+ t.Setenv("SESSION_COOKIE_TRUSTED_URL", "")
+
+ recorder := performCORSPreflight(t, corsTestRouter(), "https://evil.example")
+ require.Empty(t, recorder.Header().Get("Access-Control-Allow-Origin"))
+ require.NotEqual(t, http.StatusNoContent, recorder.Code)
+}
+
+func TestCORSNormalizesTrailingSlash(t *testing.T) {
+ t.Setenv("CORS_ALLOWED_ORIGINS", "https://console.example/")
+ t.Setenv("SESSION_COOKIE_TRUSTED_URL", "")
+
+ recorder := performCORSPreflight(t, corsTestRouter(), "https://console.example")
+ require.Equal(t, http.StatusNoContent, recorder.Code)
+ require.Equal(t, "https://console.example", recorder.Header().Get("Access-Control-Allow-Origin"))
+}
diff --git a/middleware/header_nav_test.go b/middleware/header_nav_test.go
index d4c9c221ef2d..a8ccbece831d 100644
--- a/middleware/header_nav_test.go
+++ b/middleware/header_nav_test.go
@@ -6,6 +6,7 @@ import (
"testing"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/model"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
@@ -36,6 +37,17 @@ func withHeaderNavModules(t *testing.T, raw string) {
func performHeaderNavRequest(t *testing.T, handler gin.HandlerFunc, authenticated bool) *httptest.ResponseRecorder {
t.Helper()
+ if authenticated {
+ db := setupTokenOrUserAuthTestDB(t)
+ require.NoError(t, db.Create(&model.User{
+ Id: 1,
+ Username: "tester",
+ Password: "not-used-in-test",
+ Role: common.RoleCommonUser,
+ Status: common.UserStatusEnabled,
+ Group: "default",
+ }).Error)
+ }
gin.SetMode(gin.TestMode)
router := gin.New()
diff --git a/middleware/recover.go b/middleware/recover.go
index 745a61015dae..3af236f63416 100644
--- a/middleware/recover.go
+++ b/middleware/recover.go
@@ -13,11 +13,16 @@ func RelayPanicRecover() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
- common.SysLog(fmt.Sprintf("panic detected: %v", err))
+ reqID := c.GetString(common.RequestIdKey)
+ common.SysLog(fmt.Sprintf("panic detected request_id=%s: %v", reqID, err))
common.SysLog(fmt.Sprintf("stacktrace from panic: %s", string(debug.Stack())))
+ msg := "Internal server error"
+ if reqID != "" {
+ msg = fmt.Sprintf("Internal server error (request_id=%s)", reqID)
+ }
c.JSON(http.StatusInternalServerError, gin.H{
"error": gin.H{
- "message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),
+ "message": msg,
"type": "new_api_panic",
},
})
diff --git a/middleware/security_headers.go b/middleware/security_headers.go
new file mode 100644
index 000000000000..8cea9bfe605f
--- /dev/null
+++ b/middleware/security_headers.go
@@ -0,0 +1,41 @@
+package middleware
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+// SecurityHeaders 为经 HTTPS 到达的请求附加安全响应头。
+// 当反向代理/Tunnel 终止 TLS 时,通过 X-Forwarded-Proto 识别 HTTPS。
+// HSTS 仅在判定为 HTTPS 时发送,避免本机 http://127.0.0.1 被浏览器错误 HSTS。
+func SecurityHeaders() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ c.Header("X-Content-Type-Options", "nosniff")
+ c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
+ c.Header("X-Frame-Options", "SAMEORIGIN")
+
+ if isHTTPSRequest(c.Request) {
+ // 6 months; no includeSubDomains/preload — single-host production default.
+ c.Header("Strict-Transport-Security", "max-age=15768000")
+ }
+ c.Next()
+ }
+}
+
+// isHTTPSRequest 判断客户端侧是否为 HTTPS(含反代转发头)。
+func isHTTPSRequest(r *http.Request) bool {
+ if r.TLS != nil {
+ return true
+ }
+ proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
+ if proto == "" {
+ return false
+ }
+ // 取最左(最靠近客户端)的协议标记。
+ if i := strings.IndexByte(proto, ','); i >= 0 {
+ proto = proto[:i]
+ }
+ return strings.EqualFold(strings.TrimSpace(proto), "https")
+}
diff --git a/middleware/security_headers_test.go b/middleware/security_headers_test.go
new file mode 100644
index 000000000000..9fb3f775e02c
--- /dev/null
+++ b/middleware/security_headers_test.go
@@ -0,0 +1,48 @@
+package middleware
+
+import (
+ "crypto/tls"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestSecurityHeadersSetsHSTSOnlyForHTTPS(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ t.Run("plain http no hsts", func(t *testing.T) {
+ engine := gin.New()
+ engine.Use(SecurityHeaders())
+ engine.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
+ rec := httptest.NewRecorder()
+ engine.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/x", nil))
+ require.Equal(t, http.StatusOK, rec.Code)
+ require.Empty(t, rec.Header().Get("Strict-Transport-Security"))
+ require.Equal(t, "nosniff", rec.Header().Get("X-Content-Type-Options"))
+ })
+
+ t.Run("x-forwarded-proto https sets hsts", func(t *testing.T) {
+ engine := gin.New()
+ engine.Use(SecurityHeaders())
+ engine.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/x", nil)
+ req.Header.Set("X-Forwarded-Proto", "https")
+ engine.ServeHTTP(rec, req)
+ require.Equal(t, "max-age=15768000", rec.Header().Get("Strict-Transport-Security"))
+ })
+
+ t.Run("direct tls sets hsts", func(t *testing.T) {
+ engine := gin.New()
+ engine.Use(SecurityHeaders())
+ engine.GET("/x", func(c *gin.Context) { c.String(http.StatusOK, "ok") })
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/x", nil)
+ req.TLS = &tls.ConnectionState{}
+ engine.ServeHTTP(rec, req)
+ require.Equal(t, "max-age=15768000", rec.Header().Get("Strict-Transport-Security"))
+ })
+}
diff --git a/middleware/trace.go b/middleware/trace.go
new file mode 100644
index 000000000000..121d07cf2198
--- /dev/null
+++ b/middleware/trace.go
@@ -0,0 +1,88 @@
+package middleware
+
+import (
+ "strings"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+const maxClientTraceIDBytes = 256
+
+func normalizeClientTraceID(value string) string {
+ value = strings.TrimSpace(value)
+ if value == "" || len(value) > maxClientTraceIDBytes {
+ return ""
+ }
+ for _, r := range value {
+ if r < 0x21 || r > 0x7e {
+ return ""
+ }
+ }
+ return value
+}
+
+func firstValidTraceHeader(c *gin.Context, names ...string) string {
+ if c == nil || c.Request == nil {
+ return ""
+ }
+ for _, name := range names {
+ if value := normalizeClientTraceID(c.GetHeader(name)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+// TraceContext injects AxonHub-compatible Thread/Trace IDs for agent observability.
+// Accepts AH-* and X-* aliases; generates UUIDs when missing. Echoes headers on the response.
+//
+// Affinity sticky only uses client-provided Trace IDs (see affinity_trace_id) so
+// auto-generated per-request IDs do not pollute the channel affinity LRU.
+func TraceContext() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ clientThread := firstValidTraceHeader(c,
+ "AH-Thread-Id", "Ah-Thread-Id", "X-Thread-Id", "X-Ah-Thread-Id")
+ clientTrace := firstValidTraceHeader(c,
+ "AH-Trace-Id", "Ah-Trace-Id", "X-Trace-Id", "X-Ah-Trace-Id")
+
+ // Optional coding-tool fallbacks count as client-provided.
+ if clientTrace == "" {
+ clientTrace = firstValidTraceHeader(c, "Session_id", "Session-Id", "X-Session-Id")
+ }
+
+ threadID := clientThread
+ traceID := clientTrace
+ if threadID == "" {
+ threadID = uuid.NewString()
+ }
+ if traceID == "" {
+ if rid := c.GetString(common.RequestIdKey); rid != "" {
+ traceID = rid
+ } else {
+ traceID = uuid.NewString()
+ }
+ }
+
+ c.Set(string(constant.ContextKeyThreadId), threadID)
+ c.Set(string(constant.ContextKeyTraceId), traceID)
+ c.Set("thread_id", threadID)
+ c.Set("trace_id", traceID)
+ // Only client-supplied traces are sticky-affinity eligible.
+ if clientTrace != "" {
+ c.Set("affinity_trace_id", clientTrace)
+ c.Set("trace_client_provided", true)
+ } else {
+ c.Set("trace_client_provided", false)
+ }
+
+ c.Header("AH-Thread-Id", threadID)
+ c.Header("AH-Trace-Id", traceID)
+ c.Header("X-Thread-Id", threadID)
+ c.Header("X-Trace-Id", traceID)
+
+ c.Next()
+ }
+}
diff --git a/middleware/trace_test.go b/middleware/trace_test.go
new file mode 100644
index 000000000000..45fe98524c66
--- /dev/null
+++ b/middleware/trace_test.go
@@ -0,0 +1,95 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/service"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestTraceContextGeneratesAndEchoes(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(RequestId())
+ r.Use(TraceContext())
+ r.GET("/v1/chat/completions", func(c *gin.Context) {
+ require.NotEmpty(t, c.GetString("thread_id"))
+ require.NotEmpty(t, c.GetString("trace_id"))
+ require.Empty(t, c.GetString("affinity_trace_id"))
+ require.False(t, c.GetBool("trace_client_provided"))
+ _, _ = service.GetPreferredChannelByAffinity(c, "gpt-test", "default")
+ _, affinityConfigured := service.GetChannelAffinityStatsContext(c)
+ require.False(t, affinityConfigured)
+ service.RecordChannelAffinity(c, 123)
+ c.JSON(200, gin.H{
+ "thread_id": c.GetString("thread_id"),
+ "trace_id": c.GetString("trace_id"),
+ })
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil)
+ r.ServeHTTP(w, req)
+ require.Equal(t, 200, w.Code)
+ require.NotEmpty(t, w.Header().Get("AH-Thread-Id"))
+ require.NotEmpty(t, w.Header().Get("AH-Trace-Id"))
+ // When client omits AH-Trace-Id, fallback links to request id.
+ require.Equal(t, w.Header().Get(common.RequestIdKey), w.Header().Get("AH-Trace-Id"))
+}
+
+func TestTraceContextRespectsClientHeaders(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(RequestId())
+ r.Use(TraceContext())
+ r.GET("/v1/chat/completions", func(c *gin.Context) {
+ require.Equal(t, "trace-xyz", c.GetString("affinity_trace_id"))
+ require.True(t, c.GetBool("trace_client_provided"))
+ _, _ = service.GetPreferredChannelByAffinity(c, "gpt-test", "default")
+ _, affinityConfigured := service.GetChannelAffinityStatsContext(c)
+ require.True(t, affinityConfigured)
+ c.JSON(200, gin.H{
+ "thread_id": c.GetString("thread_id"),
+ "trace_id": c.GetString("trace_id"),
+ })
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil)
+ req.Header.Set("AH-Thread-Id", "thread-abc")
+ req.Header.Set("X-Trace-Id", "trace-xyz")
+ r.ServeHTTP(w, req)
+ require.Equal(t, 200, w.Code)
+ require.Equal(t, "thread-abc", w.Header().Get("AH-Thread-Id"))
+ require.Equal(t, "trace-xyz", w.Header().Get("AH-Trace-Id"))
+ require.Equal(t, "trace-xyz", w.Header().Get("X-Trace-Id"))
+}
+
+func TestTraceContextRejectsOversizedAffinityTrace(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(RequestId())
+ r.Use(TraceContext())
+ r.GET("/v1/chat/completions", func(c *gin.Context) {
+ require.Empty(t, c.GetString("affinity_trace_id"))
+ require.False(t, c.GetBool("trace_client_provided"))
+ require.NotEqual(t, c.GetHeader("AH-Trace-Id"), c.GetString("trace_id"))
+ c.Status(http.StatusNoContent)
+ })
+
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil)
+ req.Header.Set("AH-Trace-Id", string(make([]byte, maxClientTraceIDBytes+1)))
+ r.ServeHTTP(w, req)
+ require.Equal(t, http.StatusNoContent, w.Code)
+}
+
+func TestTraceContextRejectsControlCharactersForAffinity(t *testing.T) {
+ require.Empty(t, normalizeClientTraceID("trace\tattacker"))
+ require.Empty(t, normalizeClientTraceID("trace attacker"))
+ require.Equal(t, "trace-safe_123", normalizeClientTraceID(" trace-safe_123 "))
+}
diff --git a/middleware/turnstile-check.go b/middleware/turnstile-check.go
index af87fad4423c..c1bd7c7670b0 100644
--- a/middleware/turnstile-check.go
+++ b/middleware/turnstile-check.go
@@ -4,8 +4,11 @@ import (
"encoding/json"
"net/http"
"net/url"
+ "strings"
+ "time"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/service"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -32,11 +35,28 @@ func TurnstileCheck() gin.HandlerFunc {
c.Abort()
return
}
- rawRes, err := http.PostForm("https://challenges.cloudflare.com/turnstile/v0/siteverify", url.Values{
+ form := url.Values{
"secret": {common.TurnstileSecretKey},
"response": {response},
"remoteip": {c.ClientIP()},
- })
+ }
+ request, err := http.NewRequestWithContext(
+ c.Request.Context(),
+ http.MethodPost,
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
+ strings.NewReader(form.Encode()),
+ )
+ if err != nil {
+ common.SysLog(err.Error())
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "message": err.Error(),
+ })
+ c.Abort()
+ return
+ }
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rawRes, err := service.GetHttpClientWithTimeout(10 * time.Second).Do(request)
if err != nil {
common.SysLog(err.Error())
c.JSON(http.StatusOK, gin.H{
diff --git a/model/ability.go b/model/ability.go
index e67b28301e02..216b22912dd7 100644
--- a/model/ability.go
+++ b/model/ability.go
@@ -106,6 +106,10 @@ func getChannelQuery(group string, model string, retry int) (*gorm.DB, error) {
}
func GetChannel(group string, model string, retry int, requestPath string) (*Channel, error) {
+ return GetChannelExcluding(group, model, retry, requestPath, nil)
+}
+
+func GetChannelExcluding(group string, model string, retry int, requestPath string, excluded map[int]struct{}) (*Channel, error) {
var abilities []Ability
var err error = nil
@@ -122,6 +126,12 @@ func GetChannel(group string, model string, retry int, requestPath string) (*Cha
return nil, err
}
abilities = filterAbilitiesByRequestPathAndModel(abilities, requestPath, model)
+ if len(excluded) > 0 {
+ abilities = lo.Filter(abilities, func(ability Ability, _ int) bool {
+ _, skip := excluded[ability.ChannelId]
+ return !skip
+ })
+ }
channel := Channel{}
if len(abilities) > 0 {
// Randomly choose one
diff --git a/model/channel_cache.go b/model/channel_cache.go
index 81923017d79c..9dcb095fa9eb 100644
--- a/model/channel_cache.go
+++ b/model/channel_cache.go
@@ -111,10 +111,60 @@ func SyncChannelCache(frequency int) {
}
}
+// GetSatisfiedChannels returns all enabled channels for group+model (path-aware),
+// highest priority first. Used by adaptive balance candidate collection.
+// When memory cache is off, falls back to a single DB-selected channel.
+func GetSatisfiedChannels(group string, modelName string, requestPath string) ([]*Channel, error) {
+ if !common.MemoryCacheEnabled {
+ ch, err := GetChannel(group, modelName, 0, requestPath)
+ if err != nil {
+ return nil, err
+ }
+ if ch == nil {
+ return nil, nil
+ }
+ return []*Channel{ch}, nil
+ }
+
+ channelSyncLock.RLock()
+ defer channelSyncLock.RUnlock()
+
+ ids := filterChannelsByRequestPathAndModel(group2model2channels[group][modelName], requestPath, modelName)
+ if len(ids) == 0 {
+ normalizedModel := ratio_setting.FormatMatchingModelName(modelName)
+ ids = filterChannelsByRequestPathAndModel(group2model2channels[group][normalizedModel], requestPath, normalizedModel)
+ }
+ if len(ids) == 0 {
+ return nil, nil
+ }
+
+ out := make([]*Channel, 0, len(ids))
+ seen := make(map[int]struct{}, len(ids))
+ for _, id := range ids {
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ ch, ok := channelsIDM[id]
+ if !ok || ch == nil {
+ continue
+ }
+ if ch.Status != common.ChannelStatusEnabled {
+ continue
+ }
+ out = append(out, ch)
+ }
+ return out, nil
+}
+
func GetRandomSatisfiedChannel(group string, model string, retry int, requestPath string) (*Channel, error) {
+ return GetRandomSatisfiedChannelExcluding(group, model, retry, requestPath, nil)
+}
+
+func GetRandomSatisfiedChannelExcluding(group string, model string, retry int, requestPath string, excluded map[int]struct{}) (*Channel, error) {
// if memory cache is disabled, get channel directly from database
if !common.MemoryCacheEnabled {
- return GetChannel(group, model, retry, requestPath)
+ return GetChannelExcluding(group, model, retry, requestPath, excluded)
}
channelSyncLock.RLock()
@@ -132,6 +182,18 @@ func GetRandomSatisfiedChannel(group string, model string, retry int, requestPat
if len(channels) == 0 {
return nil, nil
}
+ if len(excluded) > 0 {
+ filtered := make([]int, 0, len(channels))
+ for _, channelID := range channels {
+ if _, skip := excluded[channelID]; !skip {
+ filtered = append(filtered, channelID)
+ }
+ }
+ channels = filtered
+ if len(channels) == 0 {
+ return nil, nil
+ }
+ }
if len(channels) == 1 {
if channel, ok := channelsIDM[channels[0]]; ok {
diff --git a/model/channel_selection_exclusion_test.go b/model/channel_selection_exclusion_test.go
new file mode 100644
index 000000000000..c50033512856
--- /dev/null
+++ b/model/channel_selection_exclusion_test.go
@@ -0,0 +1,54 @@
+package model
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func TestGetChannelExcludingSkipsPreviouslyFailedChannel(t *testing.T) {
+ originalDB := DB
+ originalMemoryCacheEnabled := common.MemoryCacheEnabled
+ common.MemoryCacheEnabled = false
+
+ 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)
+ require.NoError(t, db.AutoMigrate(&Channel{}, &Ability{}))
+ DB = db
+ t.Cleanup(func() {
+ DB = originalDB
+ common.MemoryCacheEnabled = originalMemoryCacheEnabled
+ if sqlDB, dbErr := db.DB(); dbErr == nil {
+ _ = sqlDB.Close()
+ }
+ })
+
+ priority := int64(100)
+ require.NoError(t, db.Create(&Channel{Id: 1, Name: "depleted"}).Error)
+ require.NoError(t, db.Create(&Channel{Id: 2, Name: "available"}).Error)
+ require.NoError(t, db.Create(&Ability{
+ Group: "default", Model: "gpt-test", ChannelId: 1,
+ Enabled: true, Priority: &priority, Weight: 100,
+ }).Error)
+ require.NoError(t, db.Create(&Ability{
+ Group: "default", Model: "gpt-test", ChannelId: 2,
+ Enabled: true, Priority: &priority, Weight: 1,
+ }).Error)
+
+ channel, err := GetChannelExcluding(
+ "default",
+ "gpt-test",
+ 0,
+ "/v1/chat/completions",
+ map[int]struct{}{1: {}},
+ )
+ require.NoError(t, err)
+ require.NotNil(t, channel)
+ require.Equal(t, 2, channel.Id)
+}
diff --git a/model/clickhouse_log_test.go b/model/clickhouse_log_test.go
index d9737e6b226d..727e8ed496fb 100644
--- a/model/clickhouse_log_test.go
+++ b/model/clickhouse_log_test.go
@@ -83,6 +83,7 @@ func TestClickHouseLogCreateTableSQL(t *testing.T) {
assert.Contains(t, withoutTTL, "ENGINE = MergeTree()")
assert.Contains(t, withoutTTL, "PARTITION BY toYYYYMM(toDateTime(created_at))")
assert.Contains(t, withoutTTL, "ORDER BY (created_at, request_id)")
+ assert.Contains(t, withoutTTL, "trace_id String DEFAULT ''")
assert.NotContains(t, withoutTTL, "TTL ")
withTTL := clickHouseLogCreateTableSQL(30)
@@ -90,6 +91,13 @@ func TestClickHouseLogCreateTableSQL(t *testing.T) {
assert.Contains(t, withTTL, "TTL toDateTime(created_at) + INTERVAL 30 DAY DELETE")
}
+func TestClickHouseLogSchemaMigrationSQL(t *testing.T) {
+ statements := clickHouseLogSchemaMigrationSQL()
+ require.Len(t, statements, 2)
+ assert.Contains(t, statements[0], "ADD COLUMN IF NOT EXISTS trace_id")
+ assert.Contains(t, statements[1], "ADD INDEX IF NOT EXISTS idx_logs_trace_id")
+}
+
func TestClickHouseCreateTableHasTTL(t *testing.T) {
assert.True(t, clickHouseCreateTableHasTTL("CREATE TABLE logs (...)\nTTL toDateTime(created_at) + INTERVAL 30 DAY DELETE"))
assert.True(t, clickHouseCreateTableHasTTL("CREATE TABLE logs (...) TTL toDateTime(created_at)"))
diff --git a/model/log.go b/model/log.go
index 506bd504b686..b4751b5d9113 100644
--- a/model/log.go
+++ b/model/log.go
@@ -2,12 +2,15 @@ package model
import (
"context"
+ "encoding/base64"
+ "encoding/json"
"errors"
"fmt"
"strings"
"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"
@@ -57,9 +60,9 @@ func sanitizeClickHouseLikePattern(input string) (string, error) {
}
type Log struct {
- Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"`
- UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
- CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"`
+ Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2;index:idx_logs_user_created_id,priority:3"`
+ UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1;index:idx_logs_user_created_id,priority:1"`
+ CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type;index:idx_logs_user_created_id,priority:2;index:idx_logs_trace_created,priority:2"`
Type int `json:"type" gorm:"index:idx_created_at_type"`
Content string `json:"content"`
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
@@ -77,6 +80,7 @@ type Log struct {
Ip string `json:"ip" gorm:"index;default:''"`
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
+ TraceId string `json:"trace_id,omitempty" gorm:"type:varchar(128);index:idx_logs_trace_id;index:idx_logs_trace_created,priority:1;default:''"`
Other string `json:"other"`
}
@@ -98,11 +102,64 @@ func ensureLogRequestId(log *Log) {
}
}
+func ensureLogTraceId(log *Log) {
+ if log == nil || log.TraceId != "" || log.Other == "" {
+ return
+ }
+ other, err := common.StrToMap(log.Other)
+ if err != nil || other == nil {
+ return
+ }
+ if traceId, ok := other["trace_id"].(string); ok {
+ log.TraceId = strings.TrimSpace(traceId)
+ }
+}
+
func createLog(log *Log) error {
ensureLogRequestId(log)
+ ensureLogTraceId(log)
return LOG_DB.Create(log).Error
}
+var ErrInvalidLogCursor = errors.New("invalid log cursor")
+
+type logCursor struct {
+ CreatedAt int64 `json:"t"`
+ Id int `json:"i,omitempty"`
+ RequestId string `json:"r,omitempty"`
+}
+
+func encodeLogCursor(log *Log) (string, error) {
+ if log == nil || log.CreatedAt <= 0 {
+ return "", ErrInvalidLogCursor
+ }
+ payload, err := json.Marshal(logCursor{
+ CreatedAt: log.CreatedAt,
+ Id: log.Id,
+ RequestId: log.RequestId,
+ })
+ if err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(payload), nil
+}
+
+func decodeLogCursor(value string) (logCursor, error) {
+ var cursor logCursor
+ value = strings.TrimSpace(value)
+ if value == "" {
+ return cursor, nil
+ }
+ payload, err := base64.RawURLEncoding.DecodeString(value)
+ if err != nil || len(payload) > 512 {
+ return cursor, ErrInvalidLogCursor
+ }
+ if err := json.Unmarshal(payload, &cursor); err != nil || cursor.CreatedAt <= 0 {
+ return logCursor{}, ErrInvalidLogCursor
+ }
+ return cursor, nil
+}
+
func clickHouseLogOrder(prefix string) string {
return prefix + "created_at desc, " + prefix + "request_id desc"
}
@@ -279,6 +336,42 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s
}
}
+// mergeTraceIntoOther injects thread_id/trace_id from gin context into Other JSON.
+func mergeTraceIntoOther(c *gin.Context, otherStr string) string {
+ other, _ := common.StrToMap(otherStr)
+ if other == nil {
+ other = map[string]interface{}{}
+ }
+ if c != nil {
+ if v := strings.TrimSpace(c.GetString("thread_id")); v != "" {
+ other["thread_id"] = v
+ }
+ if v := strings.TrimSpace(c.GetString("trace_id")); v != "" {
+ other["trace_id"] = v
+ }
+ // Also try constant keys if set under typed names
+ if v := strings.TrimSpace(c.GetString(string(constant.ContextKeyThreadId))); v != "" {
+ other["thread_id"] = v
+ }
+ if v := strings.TrimSpace(c.GetString(string(constant.ContextKeyTraceId))); v != "" {
+ other["trace_id"] = v
+ }
+ }
+ return common.MapToJsonStr(other)
+}
+
+func traceIdFromContext(c *gin.Context) string {
+ if c == nil {
+ return ""
+ }
+ for _, key := range []string{"trace_id", string(constant.ContextKeyTraceId)} {
+ if value := strings.TrimSpace(c.GetString(key)); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
isStream bool, group string, other map[string]interface{}) {
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content)))
@@ -317,7 +410,8 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
}(),
RequestId: requestId,
UpstreamRequestId: upstreamRequestId,
- Other: otherStr,
+ TraceId: traceIdFromContext(c),
+ Other: mergeTraceIntoOther(c, otherStr),
}
err := createLog(log)
if err != nil {
@@ -350,6 +444,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
createdAt := common.GetTimestamp()
otherStr := common.MapToJsonStr(params.Other)
+ otherStr = mergeTraceIntoOther(c, otherStr)
// 判断是否需要记录 IP
needRecordIp := false
if settingMap, err := GetUserSetting(userId, false); err == nil {
@@ -381,6 +476,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
}(),
RequestId: requestId,
UpstreamRequestId: upstreamRequestId,
+ TraceId: traceIdFromContext(c),
Other: otherStr,
}
err := createLog(log)
@@ -465,40 +561,69 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
}
}
-func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
- var tx *gorm.DB
- if logType == LogTypeUnknown {
- tx = LOG_DB
- } else {
- tx = LOG_DB.Where("logs.type = ?", logType)
+type LogQuery struct {
+ LogType int
+ StartTimestamp int64
+ EndTimestamp int64
+ ModelName string
+ Username string
+ TokenName string
+ Channel int
+ Group string
+ RequestId string
+ UpstreamRequestId string
+ TraceId string
+}
+
+func buildLogQuery(query LogQuery, userId *int) (*gorm.DB, error) {
+ tx := LOG_DB.Model(&Log{})
+ if userId != nil {
+ tx = tx.Where("logs.user_id = ?", *userId)
+ }
+ if query.LogType != LogTypeUnknown {
+ tx = tx.Where("logs.type = ?", query.LogType)
}
- if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
- return nil, 0, err
+ var err error
+ if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", query.ModelName); err != nil {
+ return nil, err
}
- if tx, err = applyExplicitLogTextFilter(tx, "logs.username", username); err != nil {
- return nil, 0, err
+ if userId == nil {
+ if tx, err = applyExplicitLogTextFilter(tx, "logs.username", query.Username); err != nil {
+ return nil, err
+ }
}
- if tokenName != "" {
- tx = tx.Where("logs.token_name = ?", tokenName)
+ if query.TokenName != "" {
+ tx = tx.Where("logs.token_name = ?", query.TokenName)
}
- if requestId != "" {
- tx = tx.Where("logs.request_id = ?", requestId)
+ if query.RequestId != "" {
+ tx = tx.Where("logs.request_id = ?", query.RequestId)
}
- if upstreamRequestId != "" {
- tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
+ if query.UpstreamRequestId != "" {
+ tx = tx.Where("logs.upstream_request_id = ?", query.UpstreamRequestId)
}
- if startTimestamp != 0 {
- tx = tx.Where("logs.created_at >= ?", startTimestamp)
+ if query.TraceId != "" {
+ tx = tx.Where("logs.trace_id = ?", query.TraceId)
}
- if endTimestamp != 0 {
- tx = tx.Where("logs.created_at <= ?", endTimestamp)
+ if query.StartTimestamp != 0 {
+ tx = tx.Where("logs.created_at >= ?", query.StartTimestamp)
}
- if channel != 0 {
- tx = tx.Where("logs.channel_id = ?", channel)
+ if query.EndTimestamp != 0 {
+ tx = tx.Where("logs.created_at <= ?", query.EndTimestamp)
}
- if group != "" {
- tx = tx.Where("logs."+logGroupCol+" = ?", group)
+ if userId == nil && query.Channel != 0 {
+ tx = tx.Where("logs.channel_id = ?", query.Channel)
+ }
+ if query.Group != "" {
+ tx = tx.Where("logs."+logGroupCol+" = ?", query.Group)
+ }
+ return tx, nil
+}
+
+func GetAllLogs(query LogQuery, startIdx int, num int) (logs []*Log, total int64, err error) {
+ tx, err := buildLogQuery(query, nil)
+ if err != nil {
+ return nil, 0, err
}
err = tx.Model(&Log{}).Count(&total).Error
if err != nil {
@@ -516,6 +641,14 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
assignDisplayLogIds(logs, startIdx)
}
+ if err := populateLogChannelNames(logs); err != nil {
+ return logs, total, err
+ }
+
+ return logs, total, nil
+}
+
+func populateLogChannelNames(logs []*Log) error {
channelIds := types.NewSet[int]()
for _, log := range logs {
if log.ChannelId != 0 {
@@ -543,8 +676,8 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
}
} else {
// Bulk query channels from DB
- if err = DB.Table("channels").Select("id, name").Where("id IN ?", channelIds.Items()).Find(&channels).Error; err != nil {
- return logs, total, err
+ if err := DB.Table("channels").Select("id, name").Where("id IN ?", channelIds.Items()).Find(&channels).Error; err != nil {
+ return err
}
}
channelMap := make(map[int]string, len(channels))
@@ -556,46 +689,22 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
}
}
- return logs, total, err
+ return nil
}
const logSearchCountLimit = 10000
-func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
- var tx *gorm.DB
- if logType == LogTypeUnknown {
- tx = LOG_DB.Where("logs.user_id = ?", userId)
- } else {
- tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
- }
-
- if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
+func GetUserLogs(userId int, query LogQuery, startIdx int, num int) (logs []*Log, total int64, err error) {
+ tx, err := buildLogQuery(query, &userId)
+ if err != nil {
return nil, 0, err
}
- if tokenName != "" {
- tx = tx.Where("logs.token_name = ?", tokenName)
- }
- if requestId != "" {
- tx = tx.Where("logs.request_id = ?", requestId)
- }
- if upstreamRequestId != "" {
- tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
- }
- if startTimestamp != 0 {
- tx = tx.Where("logs.created_at >= ?", startTimestamp)
- }
- if endTimestamp != 0 {
- tx = tx.Where("logs.created_at <= ?", endTimestamp)
- }
- if group != "" {
- tx = tx.Where("logs."+logGroupCol+" = ?", group)
- }
err = tx.Model(&Log{}).Limit(logSearchCountLimit).Count(&total).Error
if err != nil {
common.SysError("failed to count user logs: " + err.Error())
return nil, 0, errors.New("查询日志失败")
}
- order := "logs.id desc"
+ order := "logs.created_at desc, logs.id desc"
if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
order = clickHouseLogOrder("logs.")
}
@@ -609,6 +718,99 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
return logs, total, err
}
+func applyLogCursor(tx *gorm.DB, value string) (*gorm.DB, error) {
+ cursor, err := decodeLogCursor(value)
+ if err != nil || cursor.CreatedAt == 0 {
+ if strings.TrimSpace(value) == "" {
+ return tx, nil
+ }
+ return nil, ErrInvalidLogCursor
+ }
+ if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
+ if cursor.RequestId == "" {
+ return nil, ErrInvalidLogCursor
+ }
+ return tx.Where(
+ "logs.created_at < ? OR (logs.created_at = ? AND logs.request_id < ?)",
+ cursor.CreatedAt,
+ cursor.CreatedAt,
+ cursor.RequestId,
+ ), nil
+ }
+ if cursor.Id <= 0 {
+ return nil, ErrInvalidLogCursor
+ }
+ return tx.Where(
+ "logs.created_at < ? OR (logs.created_at = ? AND logs.id < ?)",
+ cursor.CreatedAt,
+ cursor.CreatedAt,
+ cursor.Id,
+ ), nil
+}
+
+func getLogsByCursor(tx *gorm.DB, cursorValue string, num int) (logs []*Log, nextCursor string, hasMore bool, err error) {
+ if num <= 0 {
+ num = common.ItemsPerPage
+ }
+ if num > 100 {
+ num = 100
+ }
+ tx, err = applyLogCursor(tx, cursorValue)
+ if err != nil {
+ return nil, "", false, err
+ }
+
+ order := "logs.created_at desc, logs.id desc"
+ if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
+ order = clickHouseLogOrder("logs.")
+ }
+ if err := tx.Order(order).Limit(num + 1).Find(&logs).Error; err != nil {
+ return nil, "", false, err
+ }
+ if len(logs) <= num {
+ return logs, "", false, nil
+ }
+
+ hasMore = true
+ logs = logs[:num]
+ nextCursor, err = encodeLogCursor(logs[len(logs)-1])
+ if err != nil {
+ return nil, "", false, err
+ }
+ return logs, nextCursor, hasMore, nil
+}
+
+func GetAllLogsByCursor(query LogQuery, cursorValue string, num int, displayStart int) (logs []*Log, nextCursor string, hasMore bool, err error) {
+ tx, err := buildLogQuery(query, nil)
+ if err != nil {
+ return nil, "", false, err
+ }
+ logs, nextCursor, hasMore, err = getLogsByCursor(tx, cursorValue, num)
+ if err != nil {
+ return nil, "", false, err
+ }
+ if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
+ assignDisplayLogIds(logs, displayStart)
+ }
+ if err := populateLogChannelNames(logs); err != nil {
+ return logs, "", false, err
+ }
+ return logs, nextCursor, hasMore, nil
+}
+
+func GetUserLogsByCursor(userId int, query LogQuery, cursorValue string, num int, displayStart int) (logs []*Log, nextCursor string, hasMore bool, err error) {
+ tx, err := buildLogQuery(query, &userId)
+ if err != nil {
+ return nil, "", false, err
+ }
+ logs, nextCursor, hasMore, err = getLogsByCursor(tx, cursorValue, num)
+ if err != nil {
+ return nil, "", false, err
+ }
+ formatUserLogs(logs, displayStart)
+ return logs, nextCursor, hasMore, nil
+}
+
type Stat struct {
Quota int `json:"quota"`
Rpm int `json:"rpm"`
@@ -762,3 +964,41 @@ func DeleteOldLog(ctx context.Context, targetTimestamp int64, limit int) (int64,
return total, nil
}
+
+// GetLogsByTraceId uses the indexed trace_id column for new rows and falls
+// back to legacy JSON only when no indexed rows exist.
+func GetLogsByTraceId(traceId string, limit int) ([]*Log, error) {
+ traceId = strings.TrimSpace(traceId)
+ if traceId == "" {
+ return nil, errors.New("trace_id empty")
+ }
+ if limit <= 0 || limit > 500 {
+ limit = 100
+ }
+ var logs []*Log
+ order := "created_at asc, id asc"
+ if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
+ order = "created_at asc, request_id asc"
+ }
+ err := LOG_DB.Model(&Log{}).
+ Where("trace_id = ?", traceId).
+ Order(order).
+ Limit(limit).
+ Find(&logs).Error
+ if err != nil || len(logs) > 0 {
+ return logs, err
+ }
+
+ legacy := LOG_DB.Model(&Log{})
+ if common.UsingLogDatabase(common.DatabaseTypeClickHouse) {
+ safe := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_", "\"", "").Replace(traceId)
+ pattern := "%\"trace_id\":\"" + safe + "\"%"
+ legacy = legacy.Where("other LIKE ?", pattern)
+ } else {
+ safe := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_", "\"", "").Replace(traceId)
+ pattern := "%\"trace_id\":\"" + safe + "\"%"
+ legacy = legacy.Where("other LIKE ? ESCAPE '!'", pattern)
+ }
+ err = legacy.Order(order).Limit(limit).Find(&logs).Error
+ return logs, err
+}
diff --git a/model/log_cursor_test.go b/model/log_cursor_test.go
new file mode 100644
index 000000000000..53169ec9384a
--- /dev/null
+++ b/model/log_cursor_test.go
@@ -0,0 +1,105 @@
+package model
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/glebarez/sqlite"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+func withLogTestDatabase(t *testing.T) *gorm.DB {
+ t.Helper()
+ db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
+ require.NoError(t, err)
+ require.NoError(t, db.AutoMigrate(&Log{}))
+
+ previousDB := DB
+ previousLogDB := LOG_DB
+ previousType := common.LogDatabaseType()
+ DB = db
+ LOG_DB = db
+ common.SetLogDatabaseType(common.DatabaseTypeSQLite)
+ t.Cleanup(func() {
+ DB = previousDB
+ LOG_DB = previousLogDB
+ common.SetLogDatabaseType(previousType)
+ })
+ return db
+}
+
+func TestLogCursorPaginationDoesNotSkipTimestampTies(t *testing.T) {
+ db := withLogTestDatabase(t)
+ logs := []*Log{
+ {Id: 1, CreatedAt: 100, RequestId: "request-1"},
+ {Id: 2, CreatedAt: 100, RequestId: "request-2"},
+ {Id: 3, CreatedAt: 99, RequestId: "request-3"},
+ {Id: 4, CreatedAt: 98, RequestId: "request-4"},
+ }
+ require.NoError(t, db.Create(&logs).Error)
+
+ first, cursor, hasMore, err := GetAllLogsByCursor(LogQuery{}, "", 2, 0)
+ require.NoError(t, err)
+ require.True(t, hasMore)
+ require.NotEmpty(t, cursor)
+ require.Equal(t, []int{2, 1}, []int{first[0].Id, first[1].Id})
+
+ second, nextCursor, hasMore, err := GetAllLogsByCursor(LogQuery{}, cursor, 2, 2)
+ require.NoError(t, err)
+ require.False(t, hasMore)
+ require.Empty(t, nextCursor)
+ require.Equal(t, []int{3, 4}, []int{second[0].Id, second[1].Id})
+}
+
+func TestLogCursorRejectsMalformedValues(t *testing.T) {
+ withLogTestDatabase(t)
+ _, _, _, err := GetAllLogsByCursor(LogQuery{}, "not-base64", 20, 0)
+ require.ErrorIs(t, err, ErrInvalidLogCursor)
+}
+
+func TestCreateLogCopiesTraceIdFromLegacyOtherField(t *testing.T) {
+ withLogTestDatabase(t)
+ log := &Log{
+ CreatedAt: 100,
+ Other: common.MapToJsonStr(map[string]interface{}{"trace_id": "trace-123"}),
+ }
+ require.NoError(t, createLog(log))
+ require.Equal(t, "trace-123", log.TraceId)
+
+ logs, err := GetLogsByTraceId("trace-123", 10)
+ require.NoError(t, err)
+ require.Len(t, logs, 1)
+ require.Equal(t, log.Id, logs[0].Id)
+}
+
+func TestLogCursorQueriesHaveSQLiteIndexPlans(t *testing.T) {
+ db := withLogTestDatabase(t)
+ type planRow struct {
+ Detail string `gorm:"column:detail"`
+ }
+ var cursorPlan []planRow
+ require.NoError(t, db.Raw(
+ "EXPLAIN QUERY PLAN SELECT * FROM logs WHERE created_at < ? OR (created_at = ? AND id < ?) ORDER BY created_at DESC, id DESC LIMIT 101",
+ 100,
+ 100,
+ 10,
+ ).Scan(&cursorPlan).Error)
+ cursorDetails := make([]string, 0, len(cursorPlan))
+ for _, row := range cursorPlan {
+ cursorDetails = append(cursorDetails, row.Detail)
+ }
+ require.Contains(t, strings.Join(cursorDetails, "\n"), "idx_created_at_id")
+
+ var tracePlan []planRow
+ require.NoError(t, db.Raw(
+ "EXPLAIN QUERY PLAN SELECT * FROM logs WHERE trace_id = ? ORDER BY created_at ASC, id ASC LIMIT 200",
+ "trace-123",
+ ).Scan(&tracePlan).Error)
+ traceDetails := make([]string, 0, len(tracePlan))
+ for _, row := range tracePlan {
+ traceDetails = append(traceDetails, row.Detail)
+ }
+ require.Contains(t, strings.Join(traceDetails, "\n"), "idx_logs_trace")
+}
diff --git a/model/main.go b/model/main.go
index 76f98a59c307..4d63db0027fd 100644
--- a/model/main.go
+++ b/model/main.go
@@ -1,6 +1,8 @@
package model
import (
+ "context"
+ "database/sql"
"fmt"
"log"
"net/url"
@@ -54,6 +56,29 @@ var DB *gorm.DB
var LOG_DB *gorm.DB
+type sqlConnectionPoolConfig struct {
+ maxIdleConns int
+ maxOpenConns int
+ maxLifetime time.Duration
+}
+
+func connectionPoolConfig() sqlConnectionPoolConfig {
+ return sqlConnectionPoolConfig{
+ maxIdleConns: common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100),
+ maxOpenConns: common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000),
+ maxLifetime: time.Second * time.Duration(
+ common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60),
+ ),
+ }
+}
+
+func configureConnectionPool(sqlDB *sql.DB) {
+ config := connectionPoolConfig()
+ sqlDB.SetMaxIdleConns(config.maxIdleConns)
+ sqlDB.SetMaxOpenConns(config.maxOpenConns)
+ sqlDB.SetConnMaxLifetime(config.maxLifetime)
+}
+
func createRootAccountIfNeed() error {
var user User
//if user.Status != common.UserStatusEnabled {
@@ -200,9 +225,7 @@ func InitDB() (err error) {
if err != nil {
return err
}
- sqlDB.SetMaxIdleConns(common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100))
- sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000))
- sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60)))
+ configureConnectionPool(sqlDB)
if !common.IsMasterNode {
return nil
@@ -244,9 +267,7 @@ func InitLogDB() (err error) {
if err != nil {
return err
}
- sqlDB.SetMaxIdleConns(common.GetEnvOrDefault("SQL_MAX_IDLE_CONNS", 100))
- sqlDB.SetMaxOpenConns(common.GetEnvOrDefault("SQL_MAX_OPEN_CONNS", 1000))
- sqlDB.SetConnMaxLifetime(time.Second * time.Duration(common.GetEnvOrDefault("SQL_MAX_LIFETIME", 60)))
+ configureConnectionPool(sqlDB)
if !common.IsMasterNode {
return nil
@@ -400,9 +421,21 @@ func migrateClickHouseLogDB() error {
if err := LOG_DB.Exec(clickHouseLogCreateTableSQL(ttlDays)).Error; err != nil {
return err
}
+ for _, statement := range clickHouseLogSchemaMigrationSQL() {
+ if err := LOG_DB.Exec(statement).Error; err != nil {
+ return err
+ }
+ }
return syncClickHouseLogTTL(ttlDays)
}
+func clickHouseLogSchemaMigrationSQL() []string {
+ return []string{
+ "ALTER TABLE logs ADD COLUMN IF NOT EXISTS trace_id String DEFAULT ''",
+ "ALTER TABLE logs ADD INDEX IF NOT EXISTS idx_logs_trace_id trace_id TYPE bloom_filter(0.01) GRANULARITY 1",
+ }
+}
+
func clickHouseLogTTLDays() int {
ttlDays := common.GetEnvOrDefault("LOG_SQL_CLICKHOUSE_TTL_DAYS", 0)
if ttlDays < 0 {
@@ -448,6 +481,7 @@ CREATE TABLE IF NOT EXISTS logs (
ip String DEFAULT '',
request_id String DEFAULT '',
upstream_request_id String DEFAULT '',
+ trace_id String DEFAULT '',
other String DEFAULT ''
)
ENGINE = MergeTree()
@@ -805,13 +839,7 @@ func PingDB() error {
return nil
}
- sqlDB, err := DB.DB()
- if err != nil {
- log.Printf("Error getting sql.DB from GORM: %v", err)
- return err
- }
-
- err = sqlDB.Ping()
+ err := PingDBContext(context.Background())
if err != nil {
log.Printf("Error pinging DB: %v", err)
return err
@@ -821,3 +849,11 @@ func PingDB() error {
common.SysLog("Database pinged successfully")
return nil
}
+
+func PingDBContext(ctx context.Context) error {
+ sqlDB, err := DB.DB()
+ if err != nil {
+ return err
+ }
+ return sqlDB.PingContext(ctx)
+}
diff --git a/model/main_pool_test.go b/model/main_pool_test.go
new file mode 100644
index 000000000000..3359b4afe6a9
--- /dev/null
+++ b/model/main_pool_test.go
@@ -0,0 +1,30 @@
+package model
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestConnectionPoolConfigPreservesExistingDefaults(t *testing.T) {
+ t.Setenv("SQL_MAX_IDLE_CONNS", "")
+ t.Setenv("SQL_MAX_OPEN_CONNS", "")
+ t.Setenv("SQL_MAX_LIFETIME", "")
+
+ config := connectionPoolConfig()
+ require.Equal(t, 100, config.maxIdleConns)
+ require.Equal(t, 1000, config.maxOpenConns)
+ require.Equal(t, 60*time.Second, config.maxLifetime)
+}
+
+func TestConnectionPoolConfigHonorsExplicitOverrides(t *testing.T) {
+ t.Setenv("SQL_MAX_IDLE_CONNS", "3")
+ t.Setenv("SQL_MAX_OPEN_CONNS", "7")
+ t.Setenv("SQL_MAX_LIFETIME", "90")
+
+ config := connectionPoolConfig()
+ require.Equal(t, 3, config.maxIdleConns)
+ require.Equal(t, 7, config.maxOpenConns)
+ require.Equal(t, 90*time.Second, config.maxLifetime)
+}
diff --git a/model/option.go b/model/option.go
index 8e8587f271c8..ae10b6d7d3ab 100644
--- a/model/option.go
+++ b/model/option.go
@@ -176,13 +176,17 @@ func InitOptionMap() {
common.OptionMap["AutomaticRetryStatusCodes"] = operation_setting.AutomaticRetryStatusCodesToString()
common.OptionMap["ExposeRatioEnabled"] = strconv.FormatBool(ratio_setting.IsExposeRatioEnabled())
- // 自动添加所有注册的模型配置
+ // ExportAllConfigs 补充注册的配置(OptionMap 为空时填充,后面会被 DB 值覆盖)
modelConfigs := config.GlobalConfig.ExportAllConfigs()
for k, v := range modelConfigs {
- common.OptionMap[k] = v
+ if _, exists := common.OptionMap[k]; !exists {
+ common.OptionMap[k] = v
+ }
}
common.OptionMapRWMutex.Unlock()
+
+ // 后加载数据库值(在锁外执行,因为 updateOptionMap 内部同锁)
loadOptionsFromDatabase()
}
diff --git a/model/perf_metric.go b/model/perf_metric.go
index f9c33c851989..cdffce0f87a8 100644
--- a/model/perf_metric.go
+++ b/model/perf_metric.go
@@ -1,6 +1,7 @@
package model
import (
+ "strings"
"time"
"gorm.io/gorm"
@@ -50,6 +51,8 @@ func UpsertPerfMetric(metric *PerfMetric) error {
func GetPerfMetrics(modelName string, group string, startTs int64, endTs int64) ([]PerfMetric, error) {
var metrics []PerfMetric
+ // Keep in sync with pkg/perf_metrics.NormalizeModelName — no import (cycle).
+ modelName = strings.ToLower(strings.TrimSpace(modelName))
query := DB.Model(&PerfMetric{}).
Where("model_name = ? AND bucket_ts >= ? AND bucket_ts <= ?", modelName, startTs, endTs)
if group != "" {
diff --git a/model/token.go b/model/token.go
index 5d62258e7920..07736bf66f77 100644
--- a/model/token.go
+++ b/model/token.go
@@ -413,30 +413,39 @@ func DecreaseTokenQuota(id int, key string, quota int) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
+ // Synchronous floor first; never batch-skip debit (overdraft risk).
+ if err = decreaseTokenQuota(id, quota); err != nil {
+ return err
+ }
if common.RedisEnabled {
gopool.Go(func() {
- err := cacheDecrTokenQuota(key, int64(quota))
- if err != nil {
- common.SysLog("failed to decrease token quota: " + err.Error())
+ if err := cacheDecrTokenQuota(key, int64(quota)); err != nil {
+ common.SysLog("failed to decrease token quota cache: " + err.Error())
}
})
}
- if common.BatchUpdateEnabled {
- addNewRecord(BatchUpdateTypeTokenQuota, id, -quota)
- return nil
- }
- return decreaseTokenQuota(id, quota)
+ return nil
}
func decreaseTokenQuota(id int, quota int) (err error) {
- err = DB.Model(&Token{}).Where("id = ?", id).Updates(
- map[string]interface{}{
- "remain_quota": gorm.Expr("remain_quota - ?", quota),
- "used_quota": gorm.Expr("used_quota + ?", quota),
- "accessed_time": common.GetTimestamp(),
- },
- ).Error
- return err
+ // Floor guard for limited tokens only. Unlimited tokens still bookkeep
+ // remain/used (remain may already be negative historically) without floor.
+ result := DB.Model(&Token{}).
+ Where("id = ? AND (unlimited_quota = ? OR remain_quota >= ?)", id, true, quota).
+ Updates(
+ map[string]interface{}{
+ "remain_quota": gorm.Expr("remain_quota - ?", quota),
+ "used_quota": gorm.Expr("used_quota + ?", quota),
+ "accessed_time": common.GetTimestamp(),
+ },
+ )
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return errors.New("令牌额度不足")
+ }
+ return nil
}
// CountUserTokens returns total number of tokens for the given user, used for pagination
diff --git a/model/user.go b/model/user.go
index 03eb589ede80..80905190345c 100644
--- a/model/user.go
+++ b/model/user.go
@@ -1105,25 +1105,33 @@ func DecreaseUserQuota(id int, quota int, db bool) (err error) {
if quota < 0 {
return errors.New("quota 不能为负数!")
}
+ // Always apply DB floor first so concurrent pre-consume cannot overdraft.
+ // BatchUpdate only accelerates non-critical increments; debit stays synchronous.
+ if err = decreaseUserQuota(id, quota); err != nil {
+ return err
+ }
+ // Cache after successful DB debit only (avoids optimistic under-read on floor fail).
gopool.Go(func() {
- err := cacheDecrUserQuota(id, int64(quota))
- if err != nil {
- common.SysLog("failed to decrease user quota: " + err.Error())
+ if err := cacheDecrUserQuota(id, int64(quota)); err != nil {
+ common.SysLog("failed to decrease user quota cache: " + err.Error())
}
})
- if !db && common.BatchUpdateEnabled {
- addNewRecord(BatchUpdateTypeUserQuota, id, -quota)
- return nil
- }
- return decreaseUserQuota(id, quota)
+ _ = db // retained for API compatibility with callers
+ return nil
}
func decreaseUserQuota(id int, quota int) (err error) {
- err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error
- if err != nil {
- return err
+ // Floor guard: refuse to drive balance negative under concurrent pre-consume.
+ result := DB.Model(&User{}).
+ Where("id = ? AND quota >= ?", id, quota).
+ Update("quota", gorm.Expr("quota - ?", quota))
+ if result.Error != nil {
+ return result.Error
}
- return err
+ if result.RowsAffected == 0 {
+ return errors.New("用户额度不足")
+ }
+ return nil
}
func DeltaUpdateUserQuota(id int, delta int) (err error) {
@@ -1185,15 +1193,31 @@ func updateUserQuotaUsedQuotaAndRequestCount(id int, quota int, usedQuota int, r
return
}
- err := DB.Model(&User{}).Where("id = ?", id).Updates(
+ // Debit (quota < 0) must refuse to drive balance negative under concurrent settle.
+ query := DB.Model(&User{}).Where("id = ?", id)
+ if quota < 0 {
+ query = query.Where("quota >= ?", -quota)
+ }
+ result := query.Updates(
map[string]interface{}{
"quota": gorm.Expr("quota + ?", quota),
"used_quota": gorm.Expr("used_quota + ?", usedQuota),
"request_count": gorm.Expr("request_count + ?", requestCount),
},
- ).Error
- if err != nil {
- common.SysLog("failed to batch update user quota, used quota and request count: " + err.Error())
+ )
+ if result.Error != nil {
+ common.SysLog("failed to batch update user quota, used quota and request count: " + result.Error.Error())
+ return
+ }
+ if quota < 0 && result.RowsAffected == 0 {
+ common.SysLog(fmt.Sprintf("batch user quota debit skipped (insufficient balance): user=%d delta=%d", id, quota))
+ // Still apply used_quota/request_count so usage stats are not lost when balance is already zero.
+ _ = DB.Model(&User{}).Where("id = ?", id).Updates(
+ map[string]interface{}{
+ "used_quota": gorm.Expr("used_quota + ?", usedQuota),
+ "request_count": gorm.Expr("request_count + ?", requestCount),
+ },
+ ).Error
}
}
diff --git a/model/utils.go b/model/utils.go
index b17937064938..1c1b639457f4 100644
--- a/model/utils.go
+++ b/model/utils.go
@@ -82,10 +82,16 @@ func batchUpdate() {
for key, value := range store {
switch i {
case BatchUpdateTypeTokenQuota:
- err := increaseTokenQuota(key, value)
- if err != nil {
- common.SysLog("failed to batch update token quota: " + err.Error())
- }
+ // Negative delta must use floor path (remain_quota >= debit).
+ var err error
+ if value < 0 {
+ err = decreaseTokenQuota(key, -value)
+ } else if value > 0 {
+ err = increaseTokenQuota(key, value)
+ }
+ if err != nil {
+ common.SysLog("failed to batch update token quota: " + err.Error())
+ }
case BatchUpdateTypeChannelUsedQuota:
updateChannelUsedQuota(key, value)
}
diff --git a/oauth/discord.go b/oauth/discord.go
index b626d2f82e5e..c3ebb3d94741 100644
--- a/oauth/discord.go
+++ b/oauth/discord.go
@@ -71,9 +71,7 @@ func (p *DiscordProvider) ExchangeToken(ctx context.Context, code string, c *gin
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
- client := http.Client{
- Timeout: 5 * time.Second,
- }
+ client := newHTTPClient(5 * time.Second)
res, err := client.Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("[OAuth-Discord] ExchangeToken error: %s", err.Error()))
@@ -116,9 +114,7 @@ func (p *DiscordProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
- client := http.Client{
- Timeout: 5 * time.Second,
- }
+ client := newHTTPClient(5 * time.Second)
res, err := client.Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("[OAuth-Discord] GetUserInfo error: %s", err.Error()))
diff --git a/oauth/generic.go b/oauth/generic.go
index 11bbb9b625f6..03be71fdf15f 100644
--- a/oauth/generic.go
+++ b/oauth/generic.go
@@ -131,9 +131,7 @@ func (p *GenericOAuthProvider) ExchangeToken(ctx context.Context, code string, c
logger.LogDebug(ctx, "[OAuth-Generic-%s] ExchangeToken: token_endpoint=%s, redirect_uri=%s, auth_style=%d",
p.config.Slug, p.config.TokenEndpoint, redirectUri, authStyle)
- client := http.Client{
- Timeout: 20 * time.Second,
- }
+ client := newHTTPClient(20 * time.Second)
res, err := client.Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] ExchangeToken error: %s", p.config.Slug, err.Error()))
@@ -212,9 +210,7 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke
req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, token.AccessToken))
req.Header.Set("Accept", "application/json")
- client := http.Client{
- Timeout: 20 * time.Second,
- }
+ client := newHTTPClient(20 * time.Second)
res, err := client.Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("[OAuth-Generic-%s] GetUserInfo error: %s", p.config.Slug, err.Error()))
diff --git a/oauth/github.go b/oauth/github.go
index 314118a3765c..e0757c8f47b0 100644
--- a/oauth/github.go
+++ b/oauth/github.go
@@ -69,9 +69,7 @@ func (p *GitHubProvider) ExchangeToken(ctx context.Context, code string, c *gin.
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
- client := http.Client{
- Timeout: 20 * time.Second,
- }
+ client := newHTTPClient(20 * time.Second)
res, err := client.Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] ExchangeToken error: %s", err.Error()))
@@ -111,9 +109,7 @@ func (p *GitHubProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*O
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
- client := http.Client{
- Timeout: 20 * time.Second,
- }
+ client := newHTTPClient(20 * time.Second)
res, err := client.Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("[OAuth-GitHub] GetUserInfo error: %s", err.Error()))
diff --git a/oauth/http_client.go b/oauth/http_client.go
new file mode 100644
index 000000000000..d4dec9989ac7
--- /dev/null
+++ b/oauth/http_client.go
@@ -0,0 +1,21 @@
+package oauth
+
+import (
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+)
+
+var (
+ oauthTransportOnce sync.Once
+ oauthTransport *http.Transport
+)
+
+func newHTTPClient(timeout time.Duration) *http.Client {
+ oauthTransportOnce.Do(func() {
+ oauthTransport = common.NewOutboundHTTPTransport(http.ProxyFromEnvironment, nil)
+ })
+ return &http.Client{Transport: oauthTransport, Timeout: timeout}
+}
diff --git a/oauth/linuxdo.go b/oauth/linuxdo.go
index 1ed91e00999c..644cb399a593 100644
--- a/oauth/linuxdo.go
+++ b/oauth/linuxdo.go
@@ -76,7 +76,7 @@ func (p *LinuxDOProvider) ExchangeToken(ctx context.Context, code string, c *gin
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
- client := http.Client{Timeout: 5 * time.Second}
+ client := newHTTPClient(5 * time.Second)
res, err := client.Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("[OAuth-LinuxDO] ExchangeToken error: %s", err.Error()))
@@ -119,7 +119,7 @@ func (p *LinuxDOProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
req.Header.Set("Accept", "application/json")
- client := http.Client{Timeout: 5 * time.Second}
+ client := newHTTPClient(5 * time.Second)
res, err := client.Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("[OAuth-LinuxDO] GetUserInfo error: %s", err.Error()))
diff --git a/oauth/oidc.go b/oauth/oidc.go
index 9bdc6d01e572..1e1423f6eacc 100644
--- a/oauth/oidc.go
+++ b/oauth/oidc.go
@@ -73,9 +73,7 @@ func (p *OIDCProvider) ExchangeToken(ctx context.Context, code string, c *gin.Co
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
- client := http.Client{
- Timeout: 5 * time.Second,
- }
+ client := newHTTPClient(5 * time.Second)
res, err := client.Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] ExchangeToken error: %s", err.Error()))
@@ -120,9 +118,7 @@ func (p *OIDCProvider) GetUserInfo(ctx context.Context, token *OAuthToken) (*OAu
}
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
- client := http.Client{
- Timeout: 5 * time.Second,
- }
+ client := newHTTPClient(5 * time.Second)
res, err := client.Do(req)
if err != nil {
logger.LogError(ctx, fmt.Sprintf("[OAuth-OIDC] GetUserInfo error: %s", err.Error()))
diff --git a/pkg/ionet/client.go b/pkg/ionet/client.go
index e53947570c96..13367dbb6300 100644
--- a/pkg/ionet/client.go
+++ b/pkg/ionet/client.go
@@ -8,6 +8,8 @@ import (
"net/url"
"strconv"
"time"
+
+ "github.com/QuantumNous/new-api/common"
)
const (
@@ -25,7 +27,8 @@ type DefaultHTTPClient struct {
func NewDefaultHTTPClient(timeout time.Duration) *DefaultHTTPClient {
return &DefaultHTTPClient{
client: &http.Client{
- Timeout: timeout,
+ Transport: common.NewOutboundHTTPTransport(http.ProxyFromEnvironment, nil),
+ Timeout: timeout,
},
}
}
diff --git a/pkg/observability/metrics.go b/pkg/observability/metrics.go
new file mode 100644
index 000000000000..acd66d7ff2d4
--- /dev/null
+++ b/pkg/observability/metrics.go
@@ -0,0 +1,105 @@
+package observability
+
+import (
+ "crypto/subtle"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/gin-gonic/gin"
+ "github.com/prometheus/client_golang/prometheus"
+ "github.com/prometheus/client_golang/prometheus/promhttp"
+)
+
+const routeTagContextKey = "route_tag"
+
+var (
+ httpRequests = prometheus.NewCounterVec(prometheus.CounterOpts{
+ Namespace: "newapi",
+ Name: "http_requests_total",
+ Help: "Total HTTP requests by plane, route class, method, route and status.",
+ }, []string{"plane", "route_class", "method", "route", "status"})
+ httpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Namespace: "newapi",
+ Name: "http_request_duration_seconds",
+ Help: "HTTP request duration by plane, route class, method and route.",
+ Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60},
+ }, []string{"plane", "route_class", "method", "route"})
+ httpInFlight = prometheus.NewGaugeVec(prometheus.GaugeOpts{
+ Namespace: "newapi",
+ Name: "http_requests_in_flight",
+ Help: "Current in-flight HTTP requests by plane.",
+ }, []string{"plane"})
+ webVitals = prometheus.NewHistogramVec(prometheus.HistogramOpts{
+ Namespace: "newapi",
+ Name: "web_vital_value",
+ Help: "Privacy-preserving browser Web Vital samples (CLS unitless; LCP and INP milliseconds).",
+ Buckets: []float64{0.01, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10, 50, 100, 200, 500, 1000, 2500, 4000, 10000, 60000},
+ }, []string{"name", "rating"})
+)
+
+func init() {
+ prometheus.MustRegister(httpRequests, httpDuration, httpInFlight, webVitals)
+}
+
+func Enabled() bool {
+ return common.GetEnvOrDefaultBool("METRICS_ENABLED", false)
+}
+
+func planeName() string {
+ switch value := strings.ToLower(strings.TrimSpace(os.Getenv("APP_PLANE"))); value {
+ case "relay", "management":
+ return value
+ default:
+ return "all"
+ }
+}
+
+func HTTPMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ plane := planeName()
+ start := time.Now()
+ httpInFlight.WithLabelValues(plane).Inc()
+ defer httpInFlight.WithLabelValues(plane).Dec()
+
+ c.Next()
+ route := c.FullPath()
+ if route == "" {
+ route = "unmatched"
+ }
+ routeClass := c.GetString(routeTagContextKey)
+ if routeClass == "" {
+ routeClass = "unknown"
+ }
+ method := c.Request.Method
+ httpRequests.WithLabelValues(plane, routeClass, method, route, strconv.Itoa(c.Writer.Status())).Inc()
+ httpDuration.WithLabelValues(plane, routeClass, method, route).Observe(time.Since(start).Seconds())
+ }
+}
+
+func MetricsAuth() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ expected := strings.TrimSpace(os.Getenv("METRICS_TOKEN"))
+ if expected == "" {
+ c.AbortWithStatus(http.StatusServiceUnavailable)
+ return
+ }
+ provided := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
+ if len(provided) != len(expected) || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 {
+ c.AbortWithStatus(http.StatusUnauthorized)
+ return
+ }
+ c.Next()
+ }
+}
+
+func Handler() http.Handler {
+ return promhttp.Handler()
+}
+
+func ObserveWebVital(name, rating string, value float64) {
+ webVitals.WithLabelValues(name, rating).Observe(value)
+}
diff --git a/pkg/observability/metrics_test.go b/pkg/observability/metrics_test.go
new file mode 100644
index 000000000000..3491b25433a4
--- /dev/null
+++ b/pkg/observability/metrics_test.go
@@ -0,0 +1,39 @@
+package observability
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestMetricsAuthFailsClosedWithoutConfiguredToken(t *testing.T) {
+ t.Setenv("METRICS_TOKEN", "")
+ require.Equal(t, http.StatusServiceUnavailable, metricsAuthStatus(t, ""))
+}
+
+func TestMetricsAuthRejectsInvalidToken(t *testing.T) {
+ t.Setenv("METRICS_TOKEN", "metrics-secret")
+ require.Equal(t, http.StatusUnauthorized, metricsAuthStatus(t, "Bearer wrong"))
+}
+
+func TestMetricsAuthAcceptsConfiguredToken(t *testing.T) {
+ t.Setenv("METRICS_TOKEN", "metrics-secret")
+ require.Equal(t, http.StatusNoContent, metricsAuthStatus(t, "Bearer metrics-secret"))
+}
+
+func metricsAuthStatus(t *testing.T, authorization string) int {
+ t.Helper()
+ engine := gin.New()
+ engine.Use(MetricsAuth())
+ engine.GET("/metrics", func(c *gin.Context) {
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodGet, "/metrics", nil)
+ request.Header.Set("Authorization", authorization)
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, request)
+ return recorder.Code
+}
diff --git a/pkg/perf_metrics/metrics.go b/pkg/perf_metrics/metrics.go
index 33b79ee478e9..5a2966d0a075 100644
--- a/pkg/perf_metrics/metrics.go
+++ b/pkg/perf_metrics/metrics.go
@@ -56,6 +56,7 @@ func RecordRelaySample(info *relaycommon.RelayInfo, success bool, outputTokens i
func Record(sample Sample) {
setting := perf_metrics_setting.GetSetting()
+ sample.Model = NormalizeModelName(sample.Model)
if !setting.Enabled || sample.Model == "" {
return
}
@@ -83,6 +84,10 @@ func Query(params QueryParams) (QueryResult, error) {
if params.Hours > 24*30 {
params.Hours = 24 * 30
}
+ params.Model = NormalizeModelName(params.Model)
+ if params.Model == "" {
+ return QueryResult{SeriesSchema: seriesSchema, Groups: []GroupResult{}}, nil
+ }
endTs := time.Now().Unix()
startTs := endTs - int64(params.Hours)*3600
@@ -92,8 +97,9 @@ func Query(params QueryParams) (QueryResult, error) {
return QueryResult{}, err
}
for _, row := range rows {
+ // Historical rows may still use mixed casing; fold into the normalized key.
mergeCounters(merged, bucketKey{
- model: row.ModelName,
+ model: params.Model,
group: row.Group,
bucketTs: row.BucketTs,
}, counters{
@@ -109,13 +115,18 @@ func Query(params QueryParams) (QueryResult, error) {
hotBuckets.Range(func(key, value any) bool {
k := key.(bucketKey)
- if k.model != params.Model || k.bucketTs < startTs || k.bucketTs > endTs {
+ if NormalizeModelName(k.model) != params.Model || k.bucketTs < startTs || k.bucketTs > endTs {
return true
}
if params.Group != "" && k.group != params.Group {
return true
}
- mergeCounters(merged, k, value.(*atomicBucket).snapshot())
+ // Re-key under the normalized model so mixed-case hot buckets collapse.
+ mergeCounters(merged, bucketKey{
+ model: params.Model,
+ group: k.group,
+ bucketTs: k.bucketTs,
+ }, value.(*atomicBucket).snapshot())
return true
})
@@ -148,8 +159,13 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
outputTokens: row.OutputTokens,
generationMs: row.GenerationMs,
}
- mergeModelTotals(totals, row.ModelName, value)
- mergeModelBucket(modelBuckets, row.ModelName, row.BucketTs, value)
+ // Collapse mixed-case historical rows onto one summary key.
+ name := NormalizeModelName(row.ModelName)
+ if name == "" {
+ continue
+ }
+ mergeModelTotals(totals, name, value)
+ mergeModelBucket(modelBuckets, name, row.BucketTs, value)
}
hotBuckets.Range(func(key, value any) bool {
@@ -166,8 +182,12 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
if snap.requestCount == 0 {
return true
}
- mergeModelTotals(totals, k.model, snap)
- mergeModelBucket(modelBuckets, k.model, k.bucketTs, snap)
+ name := NormalizeModelName(k.model)
+ if name == "" {
+ return true
+ }
+ mergeModelTotals(totals, name, snap)
+ mergeModelBucket(modelBuckets, name, k.bucketTs, snap)
return true
})
@@ -176,6 +196,11 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
if total.requestCount == 0 {
continue
}
+ // Drop image/audio/embedding probe noise from the square summary.
+ // Detail Query still serves any model name for debugging.
+ if !IsChatCapableModelName(name) {
+ continue
+ }
avgLatency := total.totalLatencyMs / total.requestCount
successRate := float64(total.successCount) / float64(total.requestCount) * 100
avgTps := 0.0
diff --git a/pkg/perf_metrics/normalize.go b/pkg/perf_metrics/normalize.go
new file mode 100644
index 000000000000..7c521888d045
--- /dev/null
+++ b/pkg/perf_metrics/normalize.go
@@ -0,0 +1,77 @@
+package perfmetrics
+
+import "strings"
+
+// NormalizeModelName folds model identifiers for perf storage and lookup.
+// Pricing / abilities may store mixed casings of the same model
+// (e.g. deepseek-v4-flash vs Deepseek-V4-Flash); health metrics must key
+// them together so badges and detail views resolve real samples.
+//
+// Only case + surrounding whitespace are changed — path-style names and
+// free-tier suffixes stay distinct (a/b, :free, [free]).
+func NormalizeModelName(name string) string {
+ return strings.ToLower(strings.TrimSpace(name))
+}
+
+// IsChatCapableModelName reports whether a model name is suitable for the
+// model-square health summary. Image / audio / video / embedding / rerank
+// probes produce noisy keys that should not pollute the chat health view.
+// Kept here (not controller) so QuerySummaryAll can filter without import cycles.
+func IsChatCapableModelName(name string) bool {
+ name = NormalizeModelName(name)
+ if name == "" {
+ return false
+ }
+ if isImageLikeModelName(name) || isAudioOrVideoLikeModelName(name) || isEmbeddingOrRerankModelName(name) {
+ return false
+ }
+ return true
+}
+
+func isImageLikeModelName(name string) bool {
+ imageHints := []string{
+ "gpt-image", "dall-e", "dalle", "seedream", "flux", "imagen",
+ "stable-diffusion", "sdxl", "midjourney", "mj-", "image-gen",
+ "text-to-image", "t2i", "cogview", "kolors", "playground-v",
+ }
+ for _, h := range imageHints {
+ if strings.Contains(name, h) {
+ return true
+ }
+ }
+ if strings.Contains(name, "image") &&
+ !strings.Contains(name, "vision") &&
+ !strings.Contains(name, "chat") &&
+ !strings.Contains(name, "embedding") {
+ return true
+ }
+ return false
+}
+
+func isAudioOrVideoLikeModelName(name string) bool {
+ hints := []string{
+ "whisper", "tts-", "tts_", "-tts", "speech", "audio-", "-audio",
+ "sora", "kling", "runway", "luma", "hailuo", "vidu", "cogvideo",
+ "text-to-video", "t2v", "minimax-video",
+ }
+ for _, h := range hints {
+ if strings.Contains(name, h) {
+ return true
+ }
+ }
+ return false
+}
+
+func isEmbeddingOrRerankModelName(name string) bool {
+ if strings.Contains(name, "rerank") {
+ return true
+ }
+ if strings.Contains(name, "embedding") ||
+ strings.Contains(name, "embed") ||
+ strings.HasPrefix(name, "m3e") ||
+ strings.Contains(name, "bge-") ||
+ strings.Contains(name, "text-embedding") {
+ return true
+ }
+ return false
+}
diff --git a/pkg/perf_metrics/normalize_test.go b/pkg/perf_metrics/normalize_test.go
new file mode 100644
index 000000000000..316438b509b1
--- /dev/null
+++ b/pkg/perf_metrics/normalize_test.go
@@ -0,0 +1,50 @@
+package perfmetrics
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNormalizeModelName(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ in, want string
+ }{
+ {"Deepseek-V4-Flash", "deepseek-v4-flash"},
+ {" deepseek-v4-flash ", "deepseek-v4-flash"},
+ {"DeepSeek-V4-Flash", "deepseek-v4-flash"},
+ {"gpt-4o", "gpt-4o"},
+ {"provider/Path/Model", "provider/path/model"},
+ {"model:free", "model:free"},
+ {"model[free]", "model[free]"},
+ {"", ""},
+ {" ", ""},
+ }
+ for _, tc := range cases {
+ tc := tc
+ t.Run(tc.in, func(t *testing.T) {
+ t.Parallel()
+ require.Equal(t, tc.want, NormalizeModelName(tc.in))
+ })
+ }
+}
+
+func TestNormalizeModelNameCollapsesCasingOnly(t *testing.T) {
+ t.Parallel()
+ a := NormalizeModelName("Deepseek-V4-Flash")
+ b := NormalizeModelName("deepseek-v4-flash")
+ require.Equal(t, a, b)
+ require.NotEqual(t, NormalizeModelName("foo:free"), NormalizeModelName("foo"))
+}
+
+func TestIsChatCapableModelName(t *testing.T) {
+ t.Parallel()
+ require.True(t, IsChatCapableModelName("gpt-4o-mini"))
+ for _, name := range []string{
+ "gpt-image-2", "dall-e-3", "whisper-1", "sora-2",
+ "text-embedding-3-small", "bge-m3", "jina-rerank-v2",
+ } {
+ require.Falsef(t, IsChatCapableModelName(name), "%s should not be chat capable for summary", name)
+ }
+}
diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go
index af0717a38d53..0a7c9390fb30 100644
--- a/relay/channel/ali/image.go
+++ b/relay/channel/ali/image.go
@@ -1,6 +1,7 @@
package ali
import (
+ "context"
"encoding/base64"
"errors"
"fmt"
@@ -192,19 +193,19 @@ func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, reque
return &imageRequest, nil
}
-func updateTask(info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) {
+func updateTask(ctx context.Context, info *relaycommon.RelayInfo, taskID string) (*AliResponse, error, []byte) {
url := fmt.Sprintf("%s/api/v1/tasks/%s", info.ChannelBaseUrl, taskID)
var aliResponse AliResponse
- req, err := http.NewRequest("GET", url, nil)
+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return &aliResponse, err, nil
}
req.Header.Set("Authorization", "Bearer "+info.ApiKey)
- client := &http.Client{}
+ client := service.GetHttpClientWithTimeout(30 * time.Second)
resp, err := client.Do(req)
if err != nil {
common.SysLog("updateTask client.Do err: " + err.Error())
@@ -237,7 +238,7 @@ func asyncTaskWait(c *gin.Context, info *relaycommon.RelayInfo, taskID string) (
for {
logger.LogDebug(c, "asyncTaskWait step %d/%d, wait %d seconds", step, maxStep, waitSeconds)
step++
- rsp, err, body := updateTask(info, taskID)
+ rsp, err, body := updateTask(c.Request.Context(), info, taskID)
responseBody = body
if err != nil {
logger.LogWarn(c, "asyncTaskWait UpdateTask err: "+err.Error())
diff --git a/relay/channel/api_request.go b/relay/channel/api_request.go
index 9fae7df078d0..37abe5e60a84 100644
--- a/relay/channel/api_request.go
+++ b/relay/channel/api_request.go
@@ -386,7 +386,7 @@ func DoWssRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
targetHeader.Set(key, value)
}
targetHeader.Set("Content-Type", c.Request.Header.Get("Content-Type"))
- targetConn, _, err := websocket.DefaultDialer.Dial(fullRequestURL, targetHeader)
+ targetConn, _, err := websocket.DefaultDialer.DialContext(c.Request.Context(), fullRequestURL, targetHeader)
if err != nil {
return nil, fmt.Errorf("dial failed to %s: %w", common.SanitizeURLForLog(fullRequestURL), err)
}
@@ -475,6 +475,10 @@ func DoRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http
return doRequest(c, req, info)
}
func doRequest(c *gin.Context, req *http.Request, info *common.RelayInfo) (*http.Response, error) {
+ if c == nil || c.Request == nil {
+ return nil, errors.New("request context is unavailable")
+ }
+ req = req.WithContext(c.Request.Context())
var client *http.Client
var err error
if info.ChannelSetting.Proxy != "" {
diff --git a/relay/channel/baidu/adaptor.go b/relay/channel/baidu/adaptor.go
index b8b4735b3b7d..5af2f47d7817 100644
--- a/relay/channel/baidu/adaptor.go
+++ b/relay/channel/baidu/adaptor.go
@@ -26,8 +26,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/relay/channel/cloudflare/adaptor.go b/relay/channel/cloudflare/adaptor.go
index af3446238316..805d60efeee7 100644
--- a/relay/channel/cloudflare/adaptor.go
+++ b/relay/channel/cloudflare/adaptor.go
@@ -27,8 +27,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
diff --git a/relay/channel/cohere/adaptor.go b/relay/channel/cohere/adaptor.go
index 664eb67841a7..58bbebd23e7e 100644
--- a/relay/channel/cohere/adaptor.go
+++ b/relay/channel/cohere/adaptor.go
@@ -25,8 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/relay/channel/dify/adaptor.go b/relay/channel/dify/adaptor.go
index 4ffee3e60c05..e8659af95dc0 100644
--- a/relay/channel/dify/adaptor.go
+++ b/relay/channel/dify/adaptor.go
@@ -32,8 +32,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
@@ -109,7 +108,6 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
} else {
return difyHandler(c, info, resp)
}
- return
}
func (a *Adaptor) GetModelList() []string {
diff --git a/relay/channel/jina/adaptor.go b/relay/channel/jina/adaptor.go
index 3f2d01d9625f..d4d58e16edb0 100644
--- a/relay/channel/jina/adaptor.go
+++ b/relay/channel/jina/adaptor.go
@@ -27,8 +27,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/relay/channel/mistral/adaptor.go b/relay/channel/mistral/adaptor.go
index 88d72e0fc90d..84c81c82ace6 100644
--- a/relay/channel/mistral/adaptor.go
+++ b/relay/channel/mistral/adaptor.go
@@ -24,8 +24,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/relay/channel/mokaai/adaptor.go b/relay/channel/mokaai/adaptor.go
index f50c1e6be231..a77c239ddca1 100644
--- a/relay/channel/mokaai/adaptor.go
+++ b/relay/channel/mokaai/adaptor.go
@@ -26,8 +26,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/relay/channel/ollama/relay-ollama.go b/relay/channel/ollama/relay-ollama.go
index 06e4d94cd79e..cb85a003a1b2 100644
--- a/relay/channel/ollama/relay-ollama.go
+++ b/relay/channel/ollama/relay-ollama.go
@@ -1,6 +1,7 @@
package ollama
import (
+ "context"
"encoding/json"
"fmt"
"io"
@@ -278,11 +279,11 @@ func ollamaEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
return usage, nil
}
-func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) {
+func FetchOllamaModels(ctx context.Context, baseURL, apiKey string) ([]OllamaModel, error) {
url := fmt.Sprintf("%s/api/tags", baseURL)
- client := &http.Client{}
- request, err := http.NewRequest("GET", url, nil)
+ client := service.GetHttpClientWithTimeout(30 * time.Second)
+ request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %v", err)
}
@@ -318,7 +319,7 @@ func FetchOllamaModels(baseURL, apiKey string) ([]OllamaModel, error) {
}
// 拉取 Ollama 模型 (非流式)
-func PullOllamaModel(baseURL, apiKey, modelName string) error {
+func PullOllamaModel(ctx context.Context, baseURL, apiKey, modelName string) error {
url := fmt.Sprintf("%s/api/pull", baseURL)
pullRequest := OllamaPullRequest{
@@ -331,10 +332,8 @@ func PullOllamaModel(baseURL, apiKey, modelName string) error {
return fmt.Errorf("序列化请求失败: %v", err)
}
- client := &http.Client{
- Timeout: 30 * 60 * 1000 * time.Millisecond, // 30分钟超时,支持大模型
- }
- request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
+ client := service.GetHttpClientWithTimeout(30 * time.Minute)
+ request, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
@@ -359,7 +358,7 @@ func PullOllamaModel(baseURL, apiKey, modelName string) error {
}
// 流式拉取 Ollama 模型 (支持进度回调)
-func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback func(OllamaPullResponse)) error {
+func PullOllamaModelStream(ctx context.Context, baseURL, apiKey, modelName string, progressCallback func(OllamaPullResponse)) error {
url := fmt.Sprintf("%s/api/pull", baseURL)
pullRequest := OllamaPullRequest{
@@ -372,10 +371,8 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
return fmt.Errorf("序列化请求失败: %v", err)
}
- client := &http.Client{
- Timeout: 60 * 60 * 1000 * time.Millisecond, // 1小时超时,支持超大模型
- }
- request, err := http.NewRequest("POST", url, strings.NewReader(string(requestBody)))
+ client := service.GetHttpClientWithTimeout(time.Hour)
+ request, err := http.NewRequestWithContext(ctx, "POST", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
@@ -436,7 +433,7 @@ func PullOllamaModelStream(baseURL, apiKey, modelName string, progressCallback f
}
// 删除 Ollama 模型
-func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
+func DeleteOllamaModel(ctx context.Context, baseURL, apiKey, modelName string) error {
url := fmt.Sprintf("%s/api/delete", baseURL)
deleteRequest := OllamaDeleteRequest{
@@ -448,8 +445,8 @@ func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
return fmt.Errorf("序列化请求失败: %v", err)
}
- client := &http.Client{}
- request, err := http.NewRequest("DELETE", url, strings.NewReader(string(requestBody)))
+ client := service.GetHttpClientWithTimeout(30 * time.Second)
+ request, err := http.NewRequestWithContext(ctx, "DELETE", url, strings.NewReader(string(requestBody)))
if err != nil {
return fmt.Errorf("创建请求失败: %v", err)
}
@@ -473,7 +470,7 @@ func DeleteOllamaModel(baseURL, apiKey, modelName string) error {
return nil
}
-func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
+func FetchOllamaVersion(ctx context.Context, baseURL, apiKey string) (string, error) {
trimmedBase := strings.TrimRight(baseURL, "/")
if trimmedBase == "" {
return "", fmt.Errorf("baseURL 为空")
@@ -481,8 +478,8 @@ func FetchOllamaVersion(baseURL, apiKey string) (string, error) {
url := fmt.Sprintf("%s/api/version", trimmedBase)
- client := &http.Client{Timeout: 10 * time.Second}
- request, err := http.NewRequest("GET", url, nil)
+ client := service.GetHttpClientWithTimeout(10 * time.Second)
+ request, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", fmt.Errorf("创建请求失败: %v", err)
}
diff --git a/relay/channel/openai/usage.go b/relay/channel/openai/usage.go
index 4085a1f392c2..93dbb5fadd0d 100644
--- a/relay/channel/openai/usage.go
+++ b/relay/channel/openai/usage.go
@@ -47,9 +47,27 @@ func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, res
usage.PromptTokensDetails.CachedTokens = cachedTokens
}
}
+ case constant.ChannelTypeXai:
+ // xAI stream/non-stream may put cache hits on non-standard fields.
+ if usage.PromptTokensDetails.CachedTokens == 0 {
+ if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
+ usage.PromptTokensDetails.CachedTokens = usage.InputTokensDetails.CachedTokens
+ } else if cachedTokens, ok := extractCachedTokensFromBody(responseBody); ok {
+ usage.PromptTokensDetails.CachedTokens = cachedTokens
+ } else if usage.PromptCacheHitTokens > 0 {
+ usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens
+ }
+ }
}
}
+// ApplyUsagePostProcessing normalizes provider-specific cache token fields into
+// usage.PromptTokensDetails.CachedTokens for quota settlement. Exported for
+// channel adaptors (e.g. xAI) that build usage outside OaiStreamHandler.
+func ApplyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, responseBody []byte) {
+ applyUsagePostProcessing(info, usage, responseBody)
+}
+
func extractCachedTokensFromBody(body []byte) (int, bool) {
if len(body) == 0 {
return 0, false
diff --git a/relay/channel/palm/adaptor.go b/relay/channel/palm/adaptor.go
index 3c1302d811be..24a61fce54cb 100644
--- a/relay/channel/palm/adaptor.go
+++ b/relay/channel/palm/adaptor.go
@@ -25,8 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/relay/channel/tencent/adaptor.go b/relay/channel/tencent/adaptor.go
index eb698553771b..18d8fbd4bf23 100644
--- a/relay/channel/tencent/adaptor.go
+++ b/relay/channel/tencent/adaptor.go
@@ -33,8 +33,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/relay/channel/volcengine/tts.go b/relay/channel/volcengine/tts.go
index 2b03981d4221..6a443359dbd5 100644
--- a/relay/channel/volcengine/tts.go
+++ b/relay/channel/volcengine/tts.go
@@ -1,7 +1,6 @@
package volcengine
import (
- "context"
"encoding/base64"
"encoding/json"
"errors"
@@ -209,7 +208,7 @@ func handleTTSWebSocketResponse(c *gin.Context, requestURL string, volcRequest V
header := http.Header{}
header.Set("Authorization", fmt.Sprintf("Bearer;%s", token))
- conn, resp, dialErr := websocket.DefaultDialer.DialContext(context.Background(), requestURL, header)
+ conn, resp, dialErr := websocket.DefaultDialer.DialContext(c.Request.Context(), requestURL, header)
if dialErr != nil {
if resp != nil {
return nil, types.NewErrorWithStatusCode(
diff --git a/relay/channel/xai/text.go b/relay/channel/xai/text.go
index f9a8ee2e6f96..83e8901d84cc 100644
--- a/relay/channel/xai/text.go
+++ b/relay/channel/xai/text.go
@@ -21,6 +21,8 @@ func streamResponseXAI2OpenAI(xAIResp *dto.ChatCompletionsStreamResponse, usage
return nil
}
if xAIResp.Usage != nil {
+ // Keep provider usage intact for the client stream; billing uses the
+ // separately accumulated `usage` (see xAIStreamHandler).
xAIResp.Usage.CompletionTokens = usage.CompletionTokens
}
openAIResp := &dto.ChatCompletionsStreamResponse{
@@ -35,6 +37,70 @@ func streamResponseXAI2OpenAI(xAIResp *dto.ChatCompletionsStreamResponse, usage
return openAIResp
}
+// mergeXAIStreamUsage copies stream chunk usage into the accumulated billing usage.
+// xAI may place cached tokens on prompt_tokens_details, input_tokens_details, or
+// top-level prompt_cache_hit_tokens; previously only prompt/total were kept (#6144).
+func mergeXAIStreamUsage(dst *dto.Usage, src *dto.Usage) {
+ if dst == nil || src == nil {
+ return
+ }
+ if src.PromptTokens > 0 {
+ dst.PromptTokens = src.PromptTokens
+ }
+ if src.TotalTokens > 0 {
+ dst.TotalTokens = src.TotalTokens
+ }
+ if src.CompletionTokens > 0 {
+ dst.CompletionTokens = src.CompletionTokens
+ } else if dst.TotalTokens > 0 && dst.PromptTokens > 0 {
+ dst.CompletionTokens = dst.TotalTokens - dst.PromptTokens
+ }
+ if src.InputTokens > 0 {
+ dst.InputTokens = src.InputTokens
+ }
+ if src.OutputTokens > 0 {
+ dst.OutputTokens = src.OutputTokens
+ }
+ if src.PromptCacheHitTokens > 0 {
+ dst.PromptCacheHitTokens = src.PromptCacheHitTokens
+ }
+
+ // Prefer standard prompt_tokens_details.cached_tokens.
+ if src.PromptTokensDetails.CachedTokens > 0 {
+ dst.PromptTokensDetails.CachedTokens = src.PromptTokensDetails.CachedTokens
+ }
+ if src.PromptTokensDetails.CachedCreationTokens > 0 {
+ dst.PromptTokensDetails.CachedCreationTokens = src.PromptTokensDetails.CachedCreationTokens
+ }
+ if src.PromptTokensDetails.TextTokens > 0 {
+ dst.PromptTokensDetails.TextTokens = src.PromptTokensDetails.TextTokens
+ }
+ if src.PromptTokensDetails.AudioTokens > 0 {
+ dst.PromptTokensDetails.AudioTokens = src.PromptTokensDetails.AudioTokens
+ }
+ if src.PromptTokensDetails.ImageTokens > 0 {
+ dst.PromptTokensDetails.ImageTokens = src.PromptTokensDetails.ImageTokens
+ }
+
+ // Fallbacks used by some OpenAI-compatible providers.
+ if dst.PromptTokensDetails.CachedTokens == 0 && src.InputTokensDetails != nil && src.InputTokensDetails.CachedTokens > 0 {
+ dst.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
+ }
+ if dst.PromptTokensDetails.CachedTokens == 0 && src.PromptCacheHitTokens > 0 {
+ dst.PromptTokensDetails.CachedTokens = src.PromptCacheHitTokens
+ }
+
+ if src.CompletionTokenDetails.ReasoningTokens > 0 {
+ dst.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens
+ }
+ if src.CompletionTokenDetails.TextTokens > 0 {
+ dst.CompletionTokenDetails.TextTokens = src.CompletionTokenDetails.TextTokens
+ }
+ if src.CompletionTokenDetails.AudioTokens > 0 {
+ dst.CompletionTokenDetails.AudioTokens = src.CompletionTokenDetails.AudioTokens
+ }
+}
+
func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
usage := &dto.Usage{}
var responseTextBuilder strings.Builder
@@ -51,12 +117,10 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
return
}
- // 把 xAI 的usage转换为 OpenAI 的usage
+ // Preserve full usage for billing — not only prompt/total (#6144).
if xAIResp.Usage != nil {
containStreamUsage = true
- usage.PromptTokens = xAIResp.Usage.PromptTokens
- usage.TotalTokens = xAIResp.Usage.TotalTokens
- usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
+ mergeXAIStreamUsage(usage, xAIResp.Usage)
}
openaiResponse := streamResponseXAI2OpenAI(xAIResp, usage)
@@ -72,6 +136,9 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
usage.CompletionTokens += toolCount * 7
}
+ // Align with OpenAI stream path: recover any remaining cache fields.
+ openai.ApplyUsagePostProcessing(info, usage, nil)
+
helper.Done(c)
service.CloseResponseBodyGracefully(resp)
return usage, nil
@@ -92,6 +159,14 @@ func xAIHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response
if xaiResponse.Usage != nil {
xaiResponse.Usage.CompletionTokens = xaiResponse.Usage.TotalTokens - xaiResponse.Usage.PromptTokens
xaiResponse.Usage.CompletionTokenDetails.TextTokens = xaiResponse.Usage.CompletionTokens - xaiResponse.Usage.CompletionTokenDetails.ReasoningTokens
+ // Normalize cache fields for quota settlement.
+ if xaiResponse.Usage.PromptTokensDetails.CachedTokens == 0 {
+ if xaiResponse.Usage.InputTokensDetails != nil && xaiResponse.Usage.InputTokensDetails.CachedTokens > 0 {
+ xaiResponse.Usage.PromptTokensDetails.CachedTokens = xaiResponse.Usage.InputTokensDetails.CachedTokens
+ } else if xaiResponse.Usage.PromptCacheHitTokens > 0 {
+ xaiResponse.Usage.PromptTokensDetails.CachedTokens = xaiResponse.Usage.PromptCacheHitTokens
+ }
+ }
}
// new body
@@ -102,5 +177,9 @@ func xAIHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response
service.IOCopyBytesGracefully(c, resp, encodeJson)
+ if xaiResponse.Usage != nil {
+ openai.ApplyUsagePostProcessing(info, xaiResponse.Usage, responseBody)
+ }
+
return xaiResponse.Usage, nil
}
diff --git a/relay/channel/xai/text_test.go b/relay/channel/xai/text_test.go
new file mode 100644
index 000000000000..9125f9eafda0
--- /dev/null
+++ b/relay/channel/xai/text_test.go
@@ -0,0 +1,57 @@
+package xai
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/dto"
+)
+
+func TestMergeXAIStreamUsagePreservesCachedTokens(t *testing.T) {
+ dst := &dto.Usage{}
+ src := &dto.Usage{
+ PromptTokens: 100,
+ TotalTokens: 140,
+ CompletionTokens: 40,
+ PromptTokensDetails: dto.InputTokenDetails{
+ CachedTokens: 60,
+ },
+ }
+ mergeXAIStreamUsage(dst, src)
+ if dst.PromptTokens != 100 || dst.CompletionTokens != 40 || dst.TotalTokens != 140 {
+ t.Fatalf("token totals wrong: %+v", dst)
+ }
+ if dst.PromptTokensDetails.CachedTokens != 60 {
+ t.Fatalf("cached tokens=%d want 60", dst.PromptTokensDetails.CachedTokens)
+ }
+}
+
+func TestMergeXAIStreamUsageFallbackPromptCacheHit(t *testing.T) {
+ dst := &dto.Usage{}
+ src := &dto.Usage{
+ PromptTokens: 80,
+ TotalTokens: 100,
+ PromptCacheHitTokens: 50,
+ }
+ mergeXAIStreamUsage(dst, src)
+ if dst.PromptTokensDetails.CachedTokens != 50 {
+ t.Fatalf("cached tokens=%d want 50", dst.PromptTokensDetails.CachedTokens)
+ }
+ if dst.CompletionTokens != 20 {
+ t.Fatalf("completion=%d want 20", dst.CompletionTokens)
+ }
+}
+
+func TestMergeXAIStreamUsageFallbackInputDetails(t *testing.T) {
+ dst := &dto.Usage{}
+ src := &dto.Usage{
+ PromptTokens: 10,
+ TotalTokens: 12,
+ InputTokensDetails: &dto.InputTokenDetails{
+ CachedTokens: 7,
+ },
+ }
+ mergeXAIStreamUsage(dst, src)
+ if dst.PromptTokensDetails.CachedTokens != 7 {
+ t.Fatalf("cached tokens=%d want 7", dst.PromptTokensDetails.CachedTokens)
+ }
+}
diff --git a/relay/channel/xunfei/adaptor.go b/relay/channel/xunfei/adaptor.go
index 686b0cbd2e12..83de2f45c20f 100644
--- a/relay/channel/xunfei/adaptor.go
+++ b/relay/channel/xunfei/adaptor.go
@@ -25,8 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/relay/channel/zhipu/adaptor.go b/relay/channel/zhipu/adaptor.go
index 3ed4b3596112..89f711bf9471 100644
--- a/relay/channel/zhipu/adaptor.go
+++ b/relay/channel/zhipu/adaptor.go
@@ -25,8 +25,7 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
func (a *Adaptor) ConvertClaudeRequest(*gin.Context, *relaycommon.RelayInfo, *dto.ClaudeRequest) (any, error) {
//TODO implement me
- panic("implement me")
- return nil, nil
+ return nil, errors.New("not implemented")
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
diff --git a/router/api-router.go b/router/api-router.go
index 83f9259b2132..31a14be3d0d6 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -22,6 +22,7 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/setup", controller.GetSetup)
apiRouter.POST("/setup", anonymousRequestBodyLimit, controller.PostSetup)
apiRouter.GET("/status", controller.GetStatus)
+ apiRouter.POST("/rum", anonymousRequestBodyLimit, controller.RecordWebVital)
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
@@ -32,8 +33,9 @@ func SetApiRouter(router *gin.Engine) {
//apiRouter.GET("/midjourney", controller.GetMidjourney)
apiRouter.GET("/home_page_content", controller.GetHomePageContent)
apiRouter.GET("/pricing", middleware.HeaderNavModuleAuth("pricing"), controller.GetPricing)
+ // Require login — model traffic profiles are not public intel.
perfMetricsRoute := apiRouter.Group("/perf-metrics")
- perfMetricsRoute.Use(middleware.HeaderNavModulePublicOrUserAuth("pricing"))
+ perfMetricsRoute.Use(middleware.UserAuth())
{
perfMetricsRoute.GET("/summary", controller.GetPerfMetricsSummary)
perfMetricsRoute.GET("", controller.GetPerfMetrics)
@@ -75,7 +77,7 @@ func SetApiRouter(router *gin.Engine) {
userRoute.GET("/logout", controller.Logout)
userRoute.POST("/epay/notify", anonymousRequestBodyLimit, controller.EpayNotify)
userRoute.GET("/epay/notify", controller.EpayNotify)
- userRoute.GET("/groups", controller.GetUserGroups)
+ userRoute.GET("/groups", middleware.UserAuth(), controller.GetUserGroups)
selfRoute := userRoute.Group("/")
selfRoute.Use(middleware.UserAuth())
@@ -269,6 +271,7 @@ func SetApiRouter(router *gin.Engine) {
// Legacy synchronous direct-delete route used only by the classic frontend.
// TODO: remove once the classic frontend is removed; the default frontend uses /system-task/log-cleanup.
logRoute.DELETE("/", middleware.RootAuth(), controller.DeleteHistoryLogs)
+ logRoute.GET("/trace/:trace_id", middleware.AdminAuth(), controller.GetTraceLogs)
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats)
diff --git a/router/channel-router.go b/router/channel-router.go
index b85cbd884b77..c7d8f658bef6 100644
--- a/router/channel-router.go
+++ b/router/channel-router.go
@@ -69,6 +69,7 @@ var channelPermissionRoutes = []permissionRoute{
{method: http.MethodDelete, path: "/ollama/delete", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaDeleteModel},
{method: http.MethodGet, path: "/ollama/version/:id", permission: authz.ChannelSensitiveWrite, handler: controller.OllamaVersion},
{method: http.MethodPost, path: "/batch/tag", permission: authz.ChannelWrite, handler: controller.BatchSetChannelTag},
+ {method: http.MethodPost, path: "/batch/skip_auto_test", permission: authz.ChannelWrite, handler: controller.BatchSetChannelSkipAutoTest},
{method: http.MethodGet, path: "/tag/models", permission: authz.ChannelRead, handler: controller.GetTagModels},
{method: http.MethodPost, path: "/copy/:id", permission: authz.ChannelSensitiveWrite, handler: controller.CopyChannel},
{method: http.MethodPost, path: "/multi_key/manage", permission: authz.ChannelOperate, handler: controller.ManageMultiKeys},
diff --git a/router/main.go b/router/main.go
index d3769bd591ba..f8d10fd864c8 100644
--- a/router/main.go
+++ b/router/main.go
@@ -1,34 +1,162 @@
package router
import (
+ "errors"
"fmt"
"net/http"
+ "net/url"
"os"
"strings"
"github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/controller"
"github.com/QuantumNous/new-api/middleware"
"github.com/gin-gonic/gin"
)
+type Plane string
+
+const (
+ PlaneAll Plane = "all"
+ PlaneRelay Plane = "relay"
+ PlaneManagement Plane = "management"
+)
+
+type frontendMode string
+
+const (
+ frontendModeAuto frontendMode = "auto"
+ frontendModeEmbedded frontendMode = "embedded"
+ frontendModeRedirect frontendMode = "redirect"
+ frontendModeDisabled frontendMode = "disabled"
+)
+
+func ParsePlane(value string) (Plane, error) {
+ switch Plane(strings.ToLower(strings.TrimSpace(value))) {
+ case "", PlaneAll:
+ return PlaneAll, nil
+ case PlaneRelay:
+ return PlaneRelay, nil
+ case PlaneManagement:
+ return PlaneManagement, nil
+ default:
+ return "", errors.New("APP_PLANE must be one of: all, relay, management")
+ }
+}
+
+// parseFrontendMode 解析前端交付模式,空值保持旧版自动选择行为。
+func parseFrontendMode(value string) (frontendMode, error) {
+ switch frontendMode(strings.ToLower(strings.TrimSpace(value))) {
+ case "", frontendModeAuto:
+ return frontendModeAuto, nil
+ case frontendModeEmbedded:
+ return frontendModeEmbedded, nil
+ case frontendModeRedirect:
+ return frontendModeRedirect, nil
+ case frontendModeDisabled:
+ return frontendModeDisabled, nil
+ default:
+ return "", errors.New("FRONTEND_MODE must be one of: auto, embedded, redirect, disabled")
+ }
+}
+
func SetRouter(router *gin.Engine, assets ThemeAssets) {
- SetApiRouter(router)
- SetDashboardRouter(router)
- SetRelayRouter(router)
- SetVideoRouter(router)
- frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL")
- if common.IsMasterNode && frontendBaseUrl != "" {
- frontendBaseUrl = ""
- common.SysLog("FRONTEND_BASE_URL is ignored on master node")
- }
- if frontendBaseUrl == "" {
- SetWebRouter(router, assets)
- } else {
- frontendBaseUrl = strings.TrimSuffix(frontendBaseUrl, "/")
- router.NoRoute(func(c *gin.Context) {
- c.Set(middleware.RouteTagKey, "web")
- c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseUrl, c.Request.RequestURI))
- })
+ _ = SetRouterForPlane(router, assets, PlaneAll)
+}
+
+func SetRouterForPlane(engine *gin.Engine, assets ThemeAssets, plane Plane) error {
+ if _, err := ParsePlane(string(plane)); err != nil {
+ return err
+ }
+ engine.Use(middleware.CORS())
+ livenessHandler := func(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"status": "ok", "plane": plane})
+ }
+ engine.GET("/healthz", livenessHandler)
+ engine.GET("/livez", livenessHandler)
+ engine.GET("/readyz", controller.GetReadiness)
+
+ if plane == PlaneAll || plane == PlaneManagement {
+ SetApiRouter(engine)
+ SetDashboardRouter(engine)
+ }
+ if plane == PlaneAll || plane == PlaneRelay {
+ SetRelayRouter(engine)
+ SetVideoRouter(engine)
+ }
+ if plane == PlaneRelay {
+ return nil
+ }
+ return setFrontendRouter(engine, assets)
+}
+
+// setFrontendRouter 按显式模式注册嵌入页面、外部跳转或纯后端路由。
+func setFrontendRouter(router *gin.Engine, assets ThemeAssets) error {
+ mode, err := parseFrontendMode(os.Getenv("FRONTEND_MODE"))
+ if err != nil {
+ return err
+ }
+
+ switch mode {
+ case frontendModeEmbedded:
+ return registerEmbeddedFrontend(router, assets)
+ case frontendModeRedirect:
+ return registerFrontendRedirect(router, os.Getenv("FRONTEND_BASE_URL"))
+ case frontendModeDisabled:
+ // 纯后端模式故意不注册 NoRoute,未知路径由 Gin 返回 404。
+ return nil
+ case frontendModeAuto:
+ frontendBaseURL := strings.TrimSpace(os.Getenv("FRONTEND_BASE_URL"))
+ if frontendBaseURL != "" && !common.IsMasterNode {
+ return registerFrontendRedirect(router, frontendBaseURL)
+ }
+ if frontendBaseURL != "" {
+ common.SysLog("FRONTEND_BASE_URL is ignored on master node in FRONTEND_MODE=auto")
+ }
+ return registerEmbeddedFrontend(router, assets)
+ default:
+ return fmt.Errorf("unsupported frontend mode %q", mode)
+ }
+}
+
+// registerEmbeddedFrontend 校验嵌入资源存在后注册原有双主题静态路由。
+func registerEmbeddedFrontend(router *gin.Engine, assets ThemeAssets) error {
+ if !assets.Available() {
+ return errors.New("embedded frontend assets are unavailable; use FRONTEND_MODE=disabled or redirect for a frontend_external build")
+ }
+ SetWebRouter(router, assets)
+ return nil
+}
+
+// registerFrontendRedirect 把非 API 页面永久跳转到独立前端入口。
+func registerFrontendRedirect(router *gin.Engine, rawBaseURL string) error {
+ frontendBaseURL, err := normalizeFrontendBaseURL(rawBaseURL)
+ if err != nil {
+ return err
+ }
+ router.NoRoute(func(c *gin.Context) {
+ c.Set(middleware.RouteTagKey, "web")
+ c.Redirect(http.StatusMovedPermanently, fmt.Sprintf("%s%s", frontendBaseURL, c.Request.RequestURI))
+ })
+ return nil
+}
+
+// normalizeFrontendBaseURL 只接受无凭据、无路径、无查询参数的 HTTP(S) 前端源站。
+func normalizeFrontendBaseURL(raw string) (string, error) {
+ value := strings.TrimSpace(raw)
+ if value == "" {
+ return "", errors.New("FRONTEND_BASE_URL is required when FRONTEND_MODE=redirect")
+ }
+ parsed, err := url.Parse(value)
+ if err != nil {
+ return "", fmt.Errorf("invalid FRONTEND_BASE_URL: %w", err)
+ }
+ if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
+ return "", errors.New("FRONTEND_BASE_URL must be an absolute HTTP(S) origin")
+ }
+ if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
+ return "", errors.New("FRONTEND_BASE_URL must not contain credentials, a path, a query, or a fragment")
}
+ return parsed.Scheme + "://" + parsed.Host, nil
}
diff --git a/router/main_test.go b/router/main_test.go
new file mode 100644
index 000000000000..24adaf54862d
--- /dev/null
+++ b/router/main_test.go
@@ -0,0 +1,239 @@
+package router
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParsePlane(t *testing.T) {
+ for input, expected := range map[string]Plane{
+ "": PlaneAll,
+ "all": PlaneAll,
+ "RELAY": PlaneRelay,
+ "management": PlaneManagement,
+ } {
+ actual, err := ParsePlane(input)
+ require.NoError(t, err)
+ require.Equal(t, expected, actual)
+ }
+ _, err := ParsePlane("public")
+ require.Error(t, err)
+}
+
+// TestParseFrontendMode 验证前端交付模式的兼容默认值与非法值拒绝逻辑。
+func TestParseFrontendMode(t *testing.T) {
+ for input, expected := range map[string]frontendMode{
+ "": frontendModeAuto,
+ "AUTO": frontendModeAuto,
+ "embedded": frontendModeEmbedded,
+ "redirect": frontendModeRedirect,
+ "disabled": frontendModeDisabled,
+ } {
+ actual, err := parseFrontendMode(input)
+ require.NoError(t, err)
+ require.Equal(t, expected, actual)
+ }
+ _, err := parseFrontendMode("static")
+ require.Error(t, err)
+}
+
+// TestFrontendModeRequiresAssetsOrExplicitExternalDelivery 验证纯后端构建不会误入嵌入资源路径。
+func TestFrontendModeRequiresAssetsOrExplicitExternalDelivery(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ t.Setenv("FRONTEND_MODE", "embedded")
+ err := SetRouterForPlane(gin.New(), ThemeAssets{}, PlaneManagement)
+ require.ErrorContains(t, err, "embedded frontend assets are unavailable")
+
+ t.Setenv("FRONTEND_MODE", "disabled")
+ require.NoError(t, SetRouterForPlane(gin.New(), ThemeAssets{}, PlaneManagement))
+}
+
+// TestFrontendDisabledReturnsNotFoundForUnknownPage 验证纯后端模式对未知页面返回 404 且 API 仍可用。
+func TestFrontendDisabledReturnsNotFoundForUnknownPage(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ t.Setenv("FRONTEND_MODE", "disabled")
+ t.Setenv("FRONTEND_BASE_URL", "")
+
+ engine := gin.New()
+ require.NoError(t, SetRouterForPlane(engine, ThemeAssets{}, PlaneManagement))
+
+ unknown := httptest.NewRecorder()
+ engine.ServeHTTP(unknown, httptest.NewRequest(http.MethodGet, "/console/settings", nil))
+ require.Equal(t, http.StatusNotFound, unknown.Code)
+
+ status := httptest.NewRecorder()
+ engine.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/api/status", nil))
+ require.NotEqual(t, http.StatusNotFound, status.Code)
+}
+
+// TestIsNonSPARequestPath 验证运维与 Relay 前缀不会被当成前端路由。
+func TestIsNonSPARequestPath(t *testing.T) {
+ for _, path := range []string{
+ "/metrics",
+ "/metrics?foo=1",
+ "/v1/models",
+ "/v1beta/models",
+ "/api/status",
+ "/pg/chat/completions",
+ "/mj/submit",
+ "/suno/submit",
+ "/kling/v1/videos/text2video",
+ "/jimeng/",
+ "/dashboard/billing/usage",
+ "/frontend-healthz",
+ "/readyz",
+ "/fast/mj/task",
+ } {
+ require.Truef(t, isNonSPARequestPath(path), "expected non-SPA: %s", path)
+ }
+ for _, path := range []string{
+ "/",
+ "/console",
+ "/pricing",
+ "/about",
+ "/sign-in",
+ "/static/js/index.js",
+ } {
+ require.Falsef(t, isNonSPARequestPath(path), "expected SPA-capable: %s", path)
+ }
+}
+
+// TestEmbeddedFrontendDoesNotServeSPAForMetrics 验证嵌入模式下 /metrics 未启用时不是 HTML 200。
+func TestEmbeddedFrontendDoesNotServeSPAForMetrics(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ t.Setenv("FRONTEND_MODE", "embedded")
+ t.Setenv("METRICS_ENABLED", "")
+ t.Setenv("METRICS_TOKEN", "")
+
+ assets := ThemeAssets{
+ DefaultIndexPage: []byte("default"),
+ ClassicIndexPage: []byte("classic"),
+ }
+ engine := gin.New()
+ require.NoError(t, SetRouterForPlane(engine, assets, PlaneManagement))
+
+ recorder := httptest.NewRecorder()
+ engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/metrics", nil))
+ require.Equal(t, http.StatusNotFound, recorder.Code)
+ require.NotContains(t, recorder.Header().Get("Content-Type"), "text/html")
+ require.NotContains(t, recorder.Body.String(), "default")
+
+ // 真正的前端路由仍应回退 index。
+ home := httptest.NewRecorder()
+ engine.ServeHTTP(home, httptest.NewRequest(http.MethodGet, "/console", nil))
+ require.Equal(t, http.StatusOK, home.Code)
+ require.Contains(t, home.Body.String(), "default")
+}
+
+// TestFrontendModeAutoIgnoresBaseURLOnMaster 验证 auto 模式下 master 忽略 FRONTEND_BASE_URL,且无资源时失败。
+func TestFrontendModeAutoIgnoresBaseURLOnMaster(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ previousMaster := common.IsMasterNode
+ common.IsMasterNode = true
+ t.Cleanup(func() { common.IsMasterNode = previousMaster })
+
+ t.Setenv("FRONTEND_MODE", "auto")
+ t.Setenv("FRONTEND_BASE_URL", "https://console.example")
+ err := SetRouterForPlane(gin.New(), ThemeAssets{}, PlaneManagement)
+ require.ErrorContains(t, err, "embedded frontend assets are unavailable")
+}
+
+// TestExplicitFrontendRedirectWorksOnMaster 验证明示 redirect 可覆盖旧版 master 自动嵌入行为。
+func TestExplicitFrontendRedirectWorksOnMaster(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ previousMaster := common.IsMasterNode
+ common.IsMasterNode = true
+ t.Cleanup(func() { common.IsMasterNode = previousMaster })
+ t.Setenv("FRONTEND_MODE", "redirect")
+ t.Setenv("FRONTEND_BASE_URL", "https://console.example/")
+
+ engine := gin.New()
+ require.NoError(t, SetRouterForPlane(engine, ThemeAssets{}, PlaneManagement))
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodGet, "/settings?tab=security", nil)
+ engine.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusMovedPermanently, recorder.Code)
+ require.Equal(t, "https://console.example/settings?tab=security", recorder.Header().Get("Location"))
+}
+
+// TestFrontendRedirectRejectsNonOriginURL 验证跳转配置不能携带路径或非 HTTP(S) 协议。
+func TestFrontendRedirectRejectsNonOriginURL(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ t.Setenv("FRONTEND_MODE", "redirect")
+
+ t.Setenv("FRONTEND_BASE_URL", "https://console.example/admin")
+ require.Error(t, SetRouterForPlane(gin.New(), ThemeAssets{}, PlaneManagement))
+
+ t.Setenv("FRONTEND_BASE_URL", "javascript:alert(1)")
+ require.Error(t, SetRouterForPlane(gin.New(), ThemeAssets{}, PlaneManagement))
+}
+
+func TestSetRouterForPlaneIsolatesRelayAndManagementRoutes(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ hasRoute := func(engine *gin.Engine, method, path string) bool {
+ for _, route := range engine.Routes() {
+ if route.Method == method && route.Path == path {
+ return true
+ }
+ }
+ return false
+ }
+
+ relayEngine := gin.New()
+ require.NoError(t, SetRouterForPlane(relayEngine, ThemeAssets{}, PlaneRelay))
+ require.True(t, hasRoute(relayEngine, "GET", "/healthz"))
+ require.True(t, hasRoute(relayEngine, "GET", "/livez"))
+ require.True(t, hasRoute(relayEngine, "GET", "/readyz"))
+ require.True(t, hasRoute(relayEngine, "POST", "/v1/chat/completions"))
+ require.False(t, hasRoute(relayEngine, "GET", "/api/status"))
+
+ previousMaster := common.IsMasterNode
+ common.IsMasterNode = false
+ t.Cleanup(func() { common.IsMasterNode = previousMaster })
+ t.Setenv("FRONTEND_BASE_URL", "https://console.example")
+ managementEngine := gin.New()
+ require.NoError(t, SetRouterForPlane(managementEngine, ThemeAssets{}, PlaneManagement))
+ require.True(t, hasRoute(managementEngine, "GET", "/healthz"))
+ require.True(t, hasRoute(managementEngine, "GET", "/livez"))
+ require.True(t, hasRoute(managementEngine, "GET", "/readyz"))
+ require.True(t, hasRoute(managementEngine, "GET", "/api/status"))
+ require.False(t, hasRoute(managementEngine, "POST", "/v1/chat/completions"))
+}
+
+func TestManagementAPIRoutesApplyCORSAllowlist(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ t.Setenv("CORS_ALLOWED_ORIGINS", "https://console.example")
+ t.Setenv("SESSION_COOKIE_TRUSTED_URL", "")
+ t.Setenv("FRONTEND_BASE_URL", "https://console.example")
+ previousMaster := common.IsMasterNode
+ common.IsMasterNode = false
+ t.Cleanup(func() { common.IsMasterNode = previousMaster })
+
+ engine := gin.New()
+ require.NoError(t, SetRouterForPlane(engine, ThemeAssets{}, PlaneManagement))
+
+ preflight := func(origin string) *httptest.ResponseRecorder {
+ recorder := httptest.NewRecorder()
+ request := httptest.NewRequest(http.MethodOptions, "/api/status", nil)
+ request.Header.Set("Origin", origin)
+ request.Header.Set("Access-Control-Request-Method", http.MethodGet)
+ engine.ServeHTTP(recorder, request)
+ return recorder
+ }
+
+ trusted := preflight("https://console.example")
+ require.Equal(t, http.StatusNoContent, trusted.Code)
+ require.Equal(t, "https://console.example", trusted.Header().Get("Access-Control-Allow-Origin"))
+ require.Equal(t, "true", trusted.Header().Get("Access-Control-Allow-Credentials"))
+
+ untrusted := preflight("https://evil.example")
+ require.Equal(t, http.StatusForbidden, untrusted.Code)
+ require.Empty(t, untrusted.Header().Get("Access-Control-Allow-Origin"))
+}
diff --git a/router/web-router.go b/router/web-router.go
index 0d475e90d54d..6734882642e7 100644
--- a/router/web-router.go
+++ b/router/web-router.go
@@ -13,7 +13,7 @@ import (
"github.com/gin-gonic/gin"
)
-// ThemeAssets holds the embedded frontend assets for both themes.
+// ThemeAssets 保存默认主题与经典主题的一体化嵌入资源。
type ThemeAssets struct {
DefaultBuildFS embed.FS
DefaultIndexPage []byte
@@ -21,6 +21,57 @@ type ThemeAssets struct {
ClassicIndexPage []byte
}
+// Available 判断两个主题的首页是否同时存在,防止纯后端构建误入嵌入模式后 panic。
+func (assets ThemeAssets) Available() bool {
+ return len(assets.DefaultIndexPage) > 0 && len(assets.ClassicIndexPage) > 0
+}
+
+// nonSPAPathPrefixes 是绝不能回退到 SPA HTML 的后端/运维路径前缀。
+// 未注册时必须返回 API 风格 404,避免 /metrics 等被 index.html 伪装成 200。
+var nonSPAPathPrefixes = []string{
+ "/api",
+ "/v1",
+ "/v1beta",
+ "/assets",
+ "/metrics",
+ "/pg",
+ "/mj",
+ "/suno",
+ "/kling",
+ "/jimeng",
+ "/dashboard",
+ "/healthz",
+ "/livez",
+ "/readyz",
+ "/frontend-healthz",
+}
+
+// isNonSPARequestPath 判断路径是否属于 API/Relay/运维端点,禁止 SPA NoRoute 接管。
+func isNonSPARequestPath(requestURI string) bool {
+ path := requestURI
+ if i := strings.IndexByte(path, '?'); i >= 0 {
+ path = path[:i]
+ }
+ if path == "" {
+ path = "/"
+ }
+ for _, prefix := range nonSPAPathPrefixes {
+ if path == prefix || strings.HasPrefix(path, prefix+"/") {
+ return true
+ }
+ }
+ // Midjourney 模式前缀:/:mode/mj 或 /:mode/mj/...
+ trimmed := strings.Trim(path, "/")
+ if trimmed == "" {
+ return false
+ }
+ parts := strings.Split(trimmed, "/")
+ if len(parts) >= 2 && parts[1] == "mj" {
+ return true
+ }
+ return false
+}
+
func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
defaultFS := common.EmbedFolder(assets.DefaultBuildFS, "web/default/dist")
classicFS := common.EmbedFolder(assets.ClassicBuildFS, "web/classic/dist")
@@ -32,7 +83,8 @@ func SetWebRouter(router *gin.Engine, assets ThemeAssets) {
router.Use(static.Serve("/", themeFS))
router.NoRoute(func(c *gin.Context) {
c.Set(middleware.RouteTagKey, "web")
- if strings.HasPrefix(c.Request.RequestURI, "/v1") || strings.HasPrefix(c.Request.RequestURI, "/api") || strings.HasPrefix(c.Request.RequestURI, "/assets") {
+ if isNonSPARequestPath(c.Request.RequestURI) {
+ // 未注册的后端/运维路径返回 JSON 404,禁止回退 HTML。
controller.RelayNotFound(c)
return
}
diff --git a/runtime_mode.go b/runtime_mode.go
new file mode 100644
index 000000000000..db5fa686920e
--- /dev/null
+++ b/runtime_mode.go
@@ -0,0 +1,66 @@
+package main
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/QuantumNous/new-api/router"
+)
+
+type runMode string
+
+const (
+ runModeAll runMode = "all"
+ runModeServe runMode = "serve"
+ runModeWorker runMode = "worker"
+ runModeScheduler runMode = "scheduler"
+ runModeMigrate runMode = "migrate"
+)
+
+func parseRunMode(value string) (runMode, error) {
+ switch runMode(strings.ToLower(strings.TrimSpace(value))) {
+ case "", runModeAll:
+ return runModeAll, nil
+ case runModeServe:
+ return runModeServe, nil
+ case runModeWorker:
+ return runModeWorker, nil
+ case runModeScheduler:
+ return runModeScheduler, nil
+ case runModeMigrate:
+ return runModeMigrate, nil
+ default:
+ return "", errors.New("RUN_MODE must be one of: all, serve, worker, scheduler, migrate")
+ }
+}
+
+func (mode runMode) servesHTTP() bool {
+ return mode == runModeAll || mode == runModeServe
+}
+
+func (mode runMode) runsWorker() bool {
+ return mode == runModeAll || mode == runModeWorker
+}
+
+func (mode runMode) runsScheduler() bool {
+ return mode == runModeAll || mode == runModeScheduler
+}
+
+func parseRuntimeConfig(runModeValue, planeValue, nodeType string) (runMode, router.Plane, error) {
+ mode, err := parseRunMode(runModeValue)
+ if err != nil {
+ return "", "", err
+ }
+ plane, err := router.ParsePlane(planeValue)
+ if err != nil {
+ return "", "", err
+ }
+ if mode == runModeMigrate && strings.EqualFold(strings.TrimSpace(nodeType), "slave") {
+ return "", "", fmt.Errorf("RUN_MODE=migrate requires NODE_TYPE to be master or unset")
+ }
+ if (mode == runModeWorker || mode == runModeScheduler) && strings.EqualFold(strings.TrimSpace(nodeType), "slave") {
+ return "", "", fmt.Errorf("RUN_MODE=%s requires NODE_TYPE to be master or unset", mode)
+ }
+ return mode, plane, nil
+}
diff --git a/runtime_mode_test.go b/runtime_mode_test.go
new file mode 100644
index 000000000000..340ffa9f00b1
--- /dev/null
+++ b/runtime_mode_test.go
@@ -0,0 +1,52 @@
+package main
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseRunMode(t *testing.T) {
+ for input, expected := range map[string]runMode{
+ "": runModeAll,
+ "all": runModeAll,
+ "SERVE": runModeServe,
+ "worker": runModeWorker,
+ "scheduler": runModeScheduler,
+ "migrate": runModeMigrate,
+ } {
+ actual, err := parseRunMode(input)
+ require.NoError(t, err)
+ require.Equal(t, expected, actual)
+ }
+ _, err := parseRunMode("cron")
+ require.Error(t, err)
+}
+
+func TestRunModeCapabilities(t *testing.T) {
+ require.True(t, runModeAll.servesHTTP())
+ require.True(t, runModeAll.runsWorker())
+ require.True(t, runModeAll.runsScheduler())
+ require.True(t, runModeServe.servesHTTP())
+ require.False(t, runModeServe.runsWorker())
+ require.False(t, runModeWorker.servesHTTP())
+ require.True(t, runModeWorker.runsWorker())
+ require.True(t, runModeScheduler.runsScheduler())
+}
+
+func TestParseRuntimeConfigRejectsInvalidCombinations(t *testing.T) {
+ _, _, err := parseRuntimeConfig("all", "public", "")
+ require.Error(t, err)
+
+ _, _, err = parseRuntimeConfig("migrate", "all", "slave")
+ require.Error(t, err)
+ _, _, err = parseRuntimeConfig("worker", "all", "slave")
+ require.Error(t, err)
+ _, _, err = parseRuntimeConfig("scheduler", "all", "slave")
+ require.Error(t, err)
+
+ mode, plane, err := parseRuntimeConfig("serve", "relay", "slave")
+ require.NoError(t, err)
+ require.Equal(t, runModeServe, mode)
+ require.Equal(t, "relay", string(plane))
+}
diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1
new file mode 100644
index 000000000000..657ccc92fc5f
--- /dev/null
+++ b/scripts/build-release.ps1
@@ -0,0 +1,185 @@
+[CmdletBinding()]
+param(
+ [string]$ExistingBinary = "",
+ [string]$OutputDirectory = "artifacts",
+ [switch]$SkipWebBuild,
+ [switch]$AllowDirty
+)
+
+$ErrorActionPreference = "Stop"
+
+function Assert-ExitCode([string]$Step) {
+ if ($LASTEXITCODE -ne 0) {
+ throw "$Step failed with exit code $LASTEXITCODE"
+ }
+}
+
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path.TrimEnd("\")
+$gitRoot = (& git -C $repoRoot rev-parse --show-toplevel).Trim().Replace("/", "\").TrimEnd("\")
+Assert-ExitCode "git root detection"
+if (-not [string]::Equals($repoRoot, $gitRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
+ throw "Build root mismatch: expected $repoRoot, got $gitRoot"
+}
+if ((Split-Path $repoRoot -Leaf) -eq "_qn_tmp") {
+ throw "Refusing to build from the upstream reference tree"
+}
+
+$versionFile = Join-Path $repoRoot "VERSION"
+$versionFallback = (Get-Content -LiteralPath $versionFile -Raw).Trim()
+if ([string]::IsNullOrWhiteSpace($versionFallback)) {
+ throw "VERSION must not be empty"
+}
+
+$head = (& git -C $repoRoot rev-parse HEAD).Trim()
+Assert-ExitCode "git revision detection"
+$branch = (& git -C $repoRoot branch --show-current).Trim()
+Assert-ExitCode "git branch detection"
+$describe = (& git -C $repoRoot describe --tags --always --dirty).Trim()
+Assert-ExitCode "git version detection"
+$statusLines = @(& git -C $repoRoot status --porcelain)
+Assert-ExitCode "git status detection"
+$isDirty = $statusLines.Count -gt 0
+if ($isDirty -and -not $AllowDirty) {
+ throw "Release builds require a clean working tree. Use -AllowDirty only for diagnostics."
+}
+
+if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
+ throw "OutputDirectory must not be empty"
+}
+if (-not [System.IO.Path]::IsPathRooted($OutputDirectory)) {
+ $OutputDirectory = Join-Path $repoRoot $OutputDirectory
+}
+New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
+$OutputDirectory = (Resolve-Path $OutputDirectory).Path
+
+$version = if ([string]::IsNullOrWhiteSpace($describe)) { $versionFallback } else { $describe }
+$buildCommand = "existing-binary"
+$isExistingBinary = -not [string]::IsNullOrWhiteSpace($ExistingBinary)
+
+if (-not $isExistingBinary) {
+ if (-not $SkipWebBuild) {
+ $oldFrontendVersion = $env:VITE_REACT_APP_VERSION
+ $oldDisableEslint = $env:DISABLE_ESLINT_PLUGIN
+ Push-Location (Join-Path $repoRoot "web")
+ try {
+ & bun install --frozen-lockfile
+ Assert-ExitCode "bun install"
+ $env:VITE_REACT_APP_VERSION = $version
+ $env:DISABLE_ESLINT_PLUGIN = "true"
+ Push-Location "default"
+ try {
+ & bun run build
+ Assert-ExitCode "default frontend build"
+ } finally {
+ Pop-Location
+ }
+ Push-Location "classic"
+ try {
+ & bun run build
+ Assert-ExitCode "classic frontend build"
+ } finally {
+ Pop-Location
+ }
+ } finally {
+ Pop-Location
+ $env:VITE_REACT_APP_VERSION = $oldFrontendVersion
+ $env:DISABLE_ESLINT_PLUGIN = $oldDisableEslint
+ }
+ } else {
+ foreach ($indexPath in @("web\default\dist\index.html", "web\classic\dist\index.html")) {
+ if (-not (Test-Path -LiteralPath (Join-Path $repoRoot $indexPath))) {
+ throw "Missing embedded frontend asset: $indexPath"
+ }
+ }
+ }
+
+ $safeVersion = [regex]::Replace($version, "[^0-9A-Za-z._-]", "_")
+ $artifactPath = Join-Path $OutputDirectory "new-api-$safeVersion.exe"
+ $ldflags = "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$version'"
+ Push-Location $repoRoot
+ try {
+ & go build -trimpath -buildvcs=true -ldflags $ldflags -o $artifactPath .
+ Assert-ExitCode "Go release build"
+ } finally {
+ Pop-Location
+ }
+ $buildCommand = "go build -trimpath -buildvcs=true -ldflags "
+} else {
+ $artifactPath = (Resolve-Path $ExistingBinary).Path
+}
+
+$artifact = Get-Item -LiteralPath $artifactPath
+$hash = Get-FileHash -Algorithm SHA256 -LiteralPath $artifactPath
+$buildInfo = (& go version -m $artifactPath 2>&1 | Out-String).Trim()
+Assert-ExitCode "Go build info extraction"
+$revisionMatch = [regex]::Match($buildInfo, "vcs\.revision=([0-9a-fA-F]+)")
+$modifiedMatch = [regex]::Match($buildInfo, "vcs\.modified=(true|false)")
+$moduleVersionMatch = [regex]::Match($buildInfo, "(?m)^\s*mod\s+\S+\s+(\S+)")
+$embeddedRevision = if ($revisionMatch.Success) { $revisionMatch.Groups[1].Value } else { "" }
+$embeddedModified = if ($modifiedMatch.Success) { $modifiedMatch.Groups[1].Value } else { "unknown" }
+$embeddedModuleVersion = if ($moduleVersionMatch.Success) { $moduleVersionMatch.Groups[1].Value } else { "" }
+$revisionMatchesHead = $embeddedRevision -ne "" -and $embeddedRevision -eq $head
+
+$goVersion = (& go version).Trim()
+Assert-ExitCode "Go version detection"
+$bunVersion = "unavailable"
+if (Get-Command bun -ErrorAction SilentlyContinue) {
+ $bunVersion = (& bun --version).Trim()
+ Assert-ExitCode "Bun version detection"
+}
+$signatureStatus = "not-applicable"
+if (Get-Command Get-AuthenticodeSignature -ErrorAction SilentlyContinue) {
+ $signatureStatus = [string](Get-AuthenticodeSignature -LiteralPath $artifactPath).Status
+}
+
+$artifactFileName = $artifact.Name
+$buildInfoPath = Join-Path $OutputDirectory ($artifactFileName + ".buildinfo.txt")
+$checksumPath = Join-Path $OutputDirectory ($artifactFileName + ".sha256")
+$manifestPath = Join-Path $OutputDirectory ($artifactFileName + ".manifest.json")
+$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
+[System.IO.File]::WriteAllText($buildInfoPath, $buildInfo + [Environment]::NewLine, $utf8NoBom)
+[System.IO.File]::WriteAllText($checksumPath, $hash.Hash.ToLowerInvariant() + " " + $artifactFileName + [Environment]::NewLine, $utf8NoBom)
+
+$manifest = [ordered]@{
+ schemaVersion = 1
+ generatedAtUtc = (Get-Date).ToUniversalTime().ToString("o")
+ artifact = [ordered]@{
+ fileName = $artifactFileName
+ path = $artifact.FullName
+ sizeBytes = $artifact.Length
+ sha256 = $hash.Hash.ToLowerInvariant()
+ authenticodeStatus = $signatureStatus
+ }
+ source = [ordered]@{
+ authoritativeRoot = $repoRoot
+ branch = $branch
+ currentHead = $head
+ currentDescribe = $describe
+ currentWorkingTreeDirty = $isDirty
+ embeddedRevision = $embeddedRevision
+ embeddedModified = $embeddedModified
+ embeddedModuleVersion = $embeddedModuleVersion
+ embeddedRevisionMatchesCurrentHead = $revisionMatchesHead
+ }
+ build = [ordered]@{
+ command = $buildCommand
+ versionFallback = $versionFallback
+ resolvedVersion = $(if ($isExistingBinary) { "not-derived-from-current-tree" } else { $version })
+ goVersion = $goVersion
+ bunVersion = $bunVersion
+ }
+ evidence = [ordered]@{
+ buildInfo = $buildInfoPath
+ checksum = $checksumPath
+ dependencyInventoryFormat = "go version -m"
+ standardizedSbomGenerated = $false
+ }
+}
+$manifestJson = $manifest | ConvertTo-Json -Depth 6
+[System.IO.File]::WriteAllText($manifestPath, $manifestJson + [Environment]::NewLine, $utf8NoBom)
+
+Write-Output "artifact=$artifactPath"
+Write-Output "manifest=$manifestPath"
+Write-Output "checksum=$checksumPath"
+Write-Output "build_info=$buildInfoPath"
+Write-Output "embedded_revision_matches_head=$revisionMatchesHead"
diff --git a/scripts/local-ops.ps1 b/scripts/local-ops.ps1
new file mode 100644
index 000000000000..36636a2cafff
--- /dev/null
+++ b/scripts/local-ops.ps1
@@ -0,0 +1,108 @@
+# Local Windows helpers for the authoritative new-api tree (D:\newapi\src).
+# Does not touch the production new-api-fixed.exe process.
+
+param(
+ [ValidateSet('check', 'backend', 'test-router', 'inventory')]
+ [string]$Action = 'check',
+ [string]$OutputDirectory = ''
+)
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
+
+function Assert-RepoRoot {
+ $prefix = (& git -C $repoRoot rev-parse --show-prefix).Trim()
+ if ($prefix -ne '') {
+ throw "Run from the authoritative repository root scripts: expected empty git prefix, got '$prefix'"
+ }
+ $leaf = Split-Path (& git -C $repoRoot rev-parse --show-toplevel).Trim() -Leaf
+ if ($leaf -eq '_qn_tmp') {
+ throw 'Refusing to operate on the upstream reference tree _qn_tmp'
+ }
+}
+
+function Invoke-Check {
+ Assert-RepoRoot
+ Push-Location $repoRoot
+ try {
+ Write-Host 'gofmt (delivery seam files)...'
+ $targets = @(
+ 'frontend_assets_embedded.go',
+ 'frontend_assets_external.go',
+ 'main.go',
+ 'router/main.go',
+ 'router/main_test.go',
+ 'router/web-router.go'
+ )
+ $dirty = @(gofmt -l @targets)
+ if ($dirty.Count -gt 0) {
+ throw "gofmt dirty: $($dirty -join ', ')"
+ }
+ Write-Host 'go test ./router...'
+ go test ./router -count=1
+ if ($LASTEXITCODE -ne 0) { throw "router tests failed: $LASTEXITCODE" }
+ Write-Host 'go test -tags frontend_external .'
+ go test -tags frontend_external . -count=1
+ if ($LASTEXITCODE -ne 0) { throw "frontend_external tests failed: $LASTEXITCODE" }
+ Write-Host 'go vet ./router .'
+ go vet ./router .
+ if ($LASTEXITCODE -ne 0) { throw "go vet failed: $LASTEXITCODE" }
+ Write-Host 'local check OK'
+ } finally {
+ Pop-Location
+ }
+}
+
+function Invoke-BackendBuild {
+ Assert-RepoRoot
+ Push-Location $repoRoot
+ try {
+ $out = Join-Path $repoRoot 'new-api-backend.exe'
+ Write-Host "building pure backend -> $out"
+ go build -trimpath -buildvcs=true -tags frontend_external `
+ -ldflags "-s -w -X github.com/QuantumNous/new-api/common.Version=$((Get-Content VERSION -Raw).Trim())" `
+ -o $out .
+ if ($LASTEXITCODE -ne 0) { throw "backend build failed: $LASTEXITCODE" }
+ $hash = (Get-FileHash -LiteralPath $out -Algorithm SHA256).Hash.ToLowerInvariant()
+ Write-Host "backend=$out size=$((Get-Item $out).Length) sha256=$hash"
+ Write-Host 'Run with FRONTEND_MODE=disabled (or redirect). Do not replace production new-api-fixed.exe unless explicitly promoted.'
+ } finally {
+ Pop-Location
+ }
+}
+
+function Invoke-Inventory {
+ Assert-RepoRoot
+ $script = Join-Path $repoRoot 'scripts\build-release.ps1'
+ if (-not (Test-Path -LiteralPath $script)) {
+ throw "missing $script"
+ }
+ $outDir = $OutputDirectory
+ if ([string]::IsNullOrWhiteSpace($outDir)) {
+ $outDir = 'D:\newapi\release-manifests'
+ }
+ $existing = 'D:\newapi\new-api-fixed.exe'
+ if (-not (Test-Path -LiteralPath $existing)) {
+ throw "production binary missing: $existing"
+ }
+ Write-Host "inventory existing binary (AllowDirty diagnostic only) -> $outDir"
+ & powershell -NoProfile -ExecutionPolicy Bypass -File $script `
+ -ExistingBinary $existing `
+ -OutputDirectory $outDir `
+ -AllowDirty
+ if ($LASTEXITCODE -ne 0) { throw "inventory failed: $LASTEXITCODE" }
+}
+
+switch ($Action) {
+ 'check' { Invoke-Check }
+ 'backend' { Invoke-BackendBuild }
+ 'test-router' {
+ Assert-RepoRoot
+ Push-Location $repoRoot
+ try {
+ go test ./router -count=1
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ } finally { Pop-Location }
+ }
+ 'inventory' { Invoke-Inventory }
+}
diff --git a/service/billing_session.go b/service/billing_session.go
index 32344eaf405c..b1aa77c10089 100644
--- a/service/billing_session.go
+++ b/service/billing_session.go
@@ -68,6 +68,7 @@ func (s *BillingSession) Settle(actualQuota int) error {
// 资金来源已提交,令牌调整失败只能记录日志;标记 settled 防止 Refund 误退资金
common.SysLog(fmt.Sprintf("error adjusting token quota after funding settled (userId=%d, tokenId=%d, delta=%d): %s",
s.relayInfo.UserId, s.relayInfo.TokenId, delta, tokenErr.Error()))
+ return tokenErr
}
}
// 3) 更新 relayInfo 上的订阅 PostDelta(用于日志)
@@ -75,7 +76,7 @@ func (s *BillingSession) Settle(actualQuota int) error {
s.relayInfo.SubscriptionPostDelta += int64(delta)
}
s.settled = true
- return tokenErr
+ return nil
}
// Refund 退还所有预扣费,幂等安全,异步执行。
diff --git a/service/billing_session_test.go b/service/billing_session_test.go
new file mode 100644
index 000000000000..4b52eff7178a
--- /dev/null
+++ b/service/billing_session_test.go
@@ -0,0 +1,67 @@
+package service
+
+import (
+ "testing"
+
+ "github.com/QuantumNous/new-api/model"
+ relaycommon "github.com/QuantumNous/new-api/relay/common"
+ "github.com/stretchr/testify/require"
+)
+
+type billingSessionTestFunding struct {
+ settleCalls int
+ deltas []int
+}
+
+func (f *billingSessionTestFunding) Source() string {
+ return BillingSourceWallet
+}
+
+func (f *billingSessionTestFunding) PreConsume(int) error {
+ return nil
+}
+
+func (f *billingSessionTestFunding) Settle(delta int) error {
+ f.settleCalls++
+ f.deltas = append(f.deltas, delta)
+ return nil
+}
+
+func (f *billingSessionTestFunding) Refund() error {
+ return nil
+}
+
+func TestBillingSessionSettleRetriesTokenAdjustmentWithoutSettlingFundingTwice(t *testing.T) {
+ truncate(t)
+ seedToken(t, 901, 801, "billing-session-token", 5)
+
+ funding := &billingSessionTestFunding{}
+ session := &BillingSession{
+ relayInfo: &relaycommon.RelayInfo{
+ UserId: 801,
+ TokenId: 901,
+ TokenKey: "billing-session-token",
+ },
+ funding: funding,
+ }
+
+ err := session.Settle(10)
+ require.Error(t, err)
+ require.True(t, session.fundingSettled)
+ require.False(t, session.settled)
+ require.Equal(t, 1, funding.settleCalls)
+ require.Equal(t, []int{10}, funding.deltas)
+
+ require.NoError(t, model.DB.Model(&model.Token{}).
+ Where("id = ?", session.relayInfo.TokenId).
+ Update("remain_quota", 20).Error)
+
+ require.NoError(t, session.Settle(10))
+ require.True(t, session.settled)
+ require.Equal(t, 1, funding.settleCalls)
+
+ var token model.Token
+ require.NoError(t, model.DB.First(&token, session.relayInfo.TokenId).Error)
+ require.Equal(t, 10, token.RemainQuota)
+ require.Equal(t, 10, token.UsedQuota)
+}
diff --git a/service/channel_adaptive.go b/service/channel_adaptive.go
new file mode 100644
index 000000000000..09840325415b
--- /dev/null
+++ b/service/channel_adaptive.go
@@ -0,0 +1,329 @@
+package service
+
+import (
+ "fmt"
+ "math/rand/v2"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+ "github.com/QuantumNous/new-api/logger"
+ "github.com/QuantumNous/new-api/model"
+ "github.com/gin-gonic/gin"
+)
+
+var (
+ adaptiveLogEnabled bool
+ adaptiveLogSample = 0.01 // 采样率
+)
+
+// 请求上下文 key
+type adaptiveContextKey string
+
+const (
+ ctxKeyAdaptiveUsedChannels adaptiveContextKey = "adaptive_used_channels"
+ ctxKeyAdaptiveGroup adaptiveContextKey = "adaptive_group"
+ ctxKeyAdaptiveModel adaptiveContextKey = "adaptive_model"
+ ctxKeyAdaptiveSelected adaptiveContextKey = "adaptive_selected"
+ ctxKeyAdaptiveScores adaptiveContextKey = "adaptive_scores"
+ ctxKeyAdaptiveCircuitPermit adaptiveContextKey = "adaptive_circuit_permit"
+)
+
+// AdaptiveSelectChannel 动态评分调度器主入口。
+// 所有回退必须调用 cacheGetRandomSatisfiedChannelLegacy,禁止再进 CacheGetRandomSatisfiedChannel。
+func AdaptiveSelectChannel(param *RetryParam) (*model.Channel, string, error) {
+ ctx := param.Ctx
+
+ // 未开启完整自适应:仅 legacy(含「只开 shadow」旧行为,避免递归)
+ if !constant.AdaptiveBalanceEnabled {
+ return cacheGetRandomSatisfiedChannelLegacy(param)
+ }
+
+ // 提取 group 和 model
+ group := common.GetContextKeyString(ctx, constant.ContextKeyUsingGroup)
+ if group == "" {
+ group = param.TokenGroup
+ }
+ modelName := param.ModelName
+
+ // 获取该 group+model 下的可用渠道
+ channels, err := getCandidateChannels(group, modelName, param)
+ if err != nil {
+ return nil, group, err
+ }
+ if len(channels) == 0 {
+ return cacheGetRandomSatisfiedChannelLegacy(param)
+ }
+
+ // 获取亲和偏好 channel
+ preferredID := getPreferredChannelID(ctx, modelName, group)
+
+ // 评分
+ candidates := ScoreCandidates(channels, group, modelName, preferredID)
+
+ usedIDs := getAdaptiveUsedChannels(ctx)
+ filtered, permits := filterAdaptiveCandidates(
+ candidates, group, modelName, preferredID, usedIDs, constant.AdaptiveBalanceShadowMode,
+ )
+
+ if len(filtered) == 0 {
+ if shouldFallbackToLegacy(len(candidates), len(filtered), constant.AdaptiveBalanceShadowMode) {
+ return cacheGetRandomSatisfiedChannelLegacy(param)
+ }
+ return nil, group, fmt.Errorf("adaptive: no available channels after circuit and retry filtering")
+ }
+
+ // topK 加权随机选择
+ selected := SelectTopKWeighted(filtered, 3)
+ if selected == nil {
+ releaseUnselectedCircuitPermits(permits, 0)
+ return nil, group, fmt.Errorf("adaptive: failed to select an eligible channel")
+ }
+
+ // Shadow Mode:选择仍走旧逻辑,仅记录对比
+ if constant.AdaptiveBalanceShadowMode {
+ oldCh, oldGroup, oldErr := cacheGetRandomSatisfiedChannelLegacy(param)
+
+ // 采样日志
+ if adaptiveLogEnabled || randFloat64() < adaptiveLogSample {
+ logAdaptiveCompare(ctx, modelName, group, selected, oldCh)
+ }
+
+ // shadow mode never changes routing or acquires half-open permits.
+ if oldCh != nil {
+ addAdaptiveUsedChannel(ctx, oldCh.Id)
+ storeAdaptiveSelection(ctx, selected.Channel, group, candidates)
+ }
+ return oldCh, oldGroup, oldErr
+ }
+
+ // 正常模式:使用动态选择的渠道
+ selectGroup := group
+ ch := selected.Channel
+ permit := permits[ch.Id]
+ releaseUnselectedCircuitPermits(permits, ch.Id)
+
+ addAdaptiveUsedChannel(ctx, ch.Id)
+ storeAdaptiveSelection(ctx, ch, group, candidates)
+ ctx.Set(string(ctxKeyAdaptiveCircuitPermit), permit)
+
+ logger.LogDebug(ctx, "adaptive selected channel #%d (score=%.3f) for group=%s model=%s",
+ ch.Id, selected.Score, group, modelName)
+
+ return ch, selectGroup, nil
+}
+
+func filterAdaptiveCandidates(
+ candidates []CandidateScore,
+ group string,
+ modelName string,
+ preferredID int,
+ usedIDs []int,
+ shadowMode bool,
+) ([]CandidateScore, map[int]CircuitPermit) {
+ filtered := make([]CandidateScore, 0, len(candidates))
+ permits := make(map[int]CircuitPermit, len(candidates))
+ for _, candidate := range candidates {
+ channelID := candidate.Channel.Id
+ if containsInt(usedIDs, channelID) {
+ continue
+ }
+ if shadowMode {
+ if IsCircuitOpen(channelID) || candidate.Score <= 0 {
+ continue
+ }
+ filtered = append(filtered, candidate)
+ continue
+ }
+
+ permit, ok := AcquireCircuitPermit(channelID)
+ if !ok {
+ continue
+ }
+ if permit.HalfOpen {
+ candidate = scoreCandidate(candidate.Channel, group, modelName, preferredID, 0.5)
+ }
+ if candidate.Score <= 0 {
+ ReleaseCircuitPermit(permit)
+ continue
+ }
+ permits[channelID] = permit
+ filtered = append(filtered, candidate)
+ }
+ return filtered, permits
+}
+
+func ReleaseAdaptiveCircuitPermit(c *gin.Context, channelID int) {
+ if c == nil || channelID <= 0 {
+ return
+ }
+ permitAny, ok := c.Get(string(ctxKeyAdaptiveCircuitPermit))
+ permit, permitOK := permitAny.(CircuitPermit)
+ if !ok || !permitOK || permit.ChannelID != channelID {
+ return
+ }
+ ReleaseCircuitPermit(permit)
+ c.Set(string(ctxKeyAdaptiveCircuitPermit), CircuitPermit{})
+}
+
+func shouldFallbackToLegacy(candidateCount, filteredCount int, shadowMode bool) bool {
+ return candidateCount == 0 || (shadowMode && filteredCount == 0)
+}
+
+func releaseUnselectedCircuitPermits(permits map[int]CircuitPermit, selectedChannelID int) {
+ for channelID, permit := range permits {
+ if channelID != selectedChannelID {
+ ReleaseCircuitPermit(permit)
+ }
+ }
+}
+
+// getCandidateChannels 获取 group+model 全部候选(非单渠道路由)
+func getCandidateChannels(group, modelName string, param *RetryParam) ([]*model.Channel, error) {
+ // auto 分组:优先用上下文已解析的 auto group,否则 legacy 解析一次
+ if group == "auto" || param.TokenGroup == "auto" {
+ if g := common.GetContextKeyString(param.Ctx, constant.ContextKeyAutoGroup); g != "" {
+ group = g
+ } else {
+ // 用 legacy 解析 auto → 具体 group,再拉全量候选
+ ch, selectGroup, err := cacheGetRandomSatisfiedChannelLegacy(param)
+ if err != nil {
+ return nil, err
+ }
+ if ch == nil {
+ return nil, nil
+ }
+ if selectGroup != "" {
+ group = selectGroup
+ }
+ // 继续用解析后的 group 拉全量;若失败至少返回当前渠道
+ list, listErr := model.GetSatisfiedChannels(group, modelName, param.RequestPath)
+ if listErr != nil {
+ return []*model.Channel{ch}, nil
+ }
+ if len(list) == 0 {
+ return []*model.Channel{ch}, nil
+ }
+ return list, nil
+ }
+ }
+
+ return model.GetSatisfiedChannels(group, modelName, param.RequestPath)
+}
+
+// getPreferredChannelID 读取亲和偏好(如果有)
+func getPreferredChannelID(ctx *gin.Context, modelName, group string) int {
+ if !common.MemoryCacheEnabled {
+ return 0
+ }
+ id, found := GetPreferredChannelByAffinity(ctx, modelName, group)
+ if found {
+ return id
+ }
+ return 0
+}
+
+// getAdaptiveUsedChannels 获取本次请求已用过的渠道 ID 列表
+func getAdaptiveUsedChannels(c *gin.Context) []int {
+ v, ok := c.Get(string(ctxKeyAdaptiveUsedChannels))
+ if !ok {
+ return nil
+ }
+ ids, _ := v.([]int)
+ return ids
+}
+
+// addAdaptiveUsedChannel 记录本次请求使用过的渠道
+func addAdaptiveUsedChannel(c *gin.Context, channelID int) {
+ existing := getAdaptiveUsedChannels(c)
+ existing = append(existing, channelID)
+ c.Set(string(ctxKeyAdaptiveUsedChannels), existing)
+}
+
+func MarkChannelUsed(c *gin.Context, channelID int) {
+ if c == nil || channelID <= 0 || containsInt(getAdaptiveUsedChannels(c), channelID) {
+ return
+ }
+ addAdaptiveUsedChannel(c, channelID)
+}
+
+func adaptiveUsedChannelSet(c *gin.Context) map[int]struct{} {
+ used := getAdaptiveUsedChannels(c)
+ if len(used) == 0 {
+ return nil
+ }
+ excluded := make(map[int]struct{}, len(used))
+ for _, channelID := range used {
+ excluded[channelID] = struct{}{}
+ }
+ return excluded
+}
+
+// storeAdaptiveSelection 保存本次选择结果到上下文(供失败回写用)
+func storeAdaptiveSelection(c *gin.Context, ch *model.Channel, group string, candidates []CandidateScore) {
+ c.Set(string(ctxKeyAdaptiveSelected), ch.Id)
+ c.Set(string(ctxKeyAdaptiveGroup), group)
+ if len(candidates) > 0 {
+ c.Set(string(ctxKeyAdaptiveScores), candidates)
+ }
+}
+
+// logAdaptiveCompare shadow mode 日志
+func logAdaptiveCompare(c *gin.Context, modelName, group string, selected *CandidateScore, oldCh *model.Channel) {
+ oldID := 0
+ if oldCh != nil {
+ oldID = oldCh.Id
+ }
+ logger.LogDebug(c, "[shadow] model=%s group=%s adaptive=#%d(%.3f) orig=#%d",
+ modelName, group, selected.Channel.Id, selected.Score, oldID)
+}
+
+// RecordAdaptiveResult 请求完成后回调:更新指标 + 熔断状态
+func RecordAdaptiveResult(c *gin.Context, channelID int, group, modelName string, statusCode int, latency time.Duration, err error) {
+ if !constant.AdaptiveBalanceEnabled {
+ return
+ }
+ if channelID <= 0 {
+ return
+ }
+
+ succeeded := err == nil && statusCode < 400
+ if succeeded {
+ ObserveSuccess(channelID, group, modelName, latency)
+ } else {
+ ObserveFailure(channelID, group, modelName, statusCode, latency)
+ }
+
+ if constant.AdaptiveBalanceShadowMode {
+ return
+ }
+ permitAny, ok := c.Get(string(ctxKeyAdaptiveCircuitPermit))
+ permit, permitOK := permitAny.(CircuitPermit)
+ if !ok || !permitOK || permit.ChannelID != channelID {
+ return
+ }
+ if succeeded {
+ RecordCircuitSuccessWithPermit(permit)
+ } else if statusCode >= 500 || statusCode == 429 {
+ RecordCircuitFailureWithPermit(permit, fmt.Sprintf("HTTP %d", statusCode))
+ } else {
+ // A client/input error still proves the upstream is reachable. Do not
+ // leave a half-open permit stuck or preserve an old failure streak.
+ RecordCircuitSuccessWithPermit(permit)
+ }
+}
+
+// containsInt 检查 int 切片是否包含某值
+func containsInt(slice []int, val int) bool {
+ for _, v := range slice {
+ if v == val {
+ return true
+ }
+ }
+ return false
+}
+
+// randFloat64 生成 [0,1) 随机数
+var randFloat64 = func() float64 {
+ return rand.Float64()
+}
diff --git a/service/channel_adaptive_test.go b/service/channel_adaptive_test.go
new file mode 100644
index 000000000000..ff5c4615a953
--- /dev/null
+++ b/service/channel_adaptive_test.go
@@ -0,0 +1,295 @@
+package service
+
+import (
+ "fmt"
+ "testing"
+ "time"
+
+ "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/stretchr/testify/require"
+)
+
+// 测试辅助:构造测试渠道
+func testChannel(id int, weight uint, priority int64) *model.Channel {
+ w := weight
+ return &model.Channel{
+ Id: id,
+ Weight: &w,
+ Priority: &priority,
+ Name: fmt.Sprintf("ch-%d", id),
+ Status: common.ChannelStatusEnabled,
+ }
+}
+
+func init() {
+ // 测试前重置全局状态
+ globalSnapshot.mu.Lock()
+ globalSnapshot.metrics = make(map[metricsKey]*ChannelMetrics)
+ globalSnapshot.mu.Unlock()
+
+ constant.EwmaAlpha = 0.3
+ constant.MaxChannelConcurrency = 10
+ constant.ChannelCircuitBreakerEnabled = true
+ constant.AdaptiveBalanceEnabled = true
+ constant.AdaptiveBalanceShadowMode = false
+}
+
+// 测试1:高延迟渠道会被降权
+func TestHighLatencyDowngraded(t *testing.T) {
+ ch1 := testChannel(1, 10, 100)
+ ch2 := testChannel(2, 10, 100)
+
+ // ch1 低延迟,ch2 高延迟
+ ObserveSuccess(1, "test", "gpt-4", 200*time.Millisecond)
+ ObserveSuccess(1, "test", "gpt-4", 150*time.Millisecond)
+ ObserveSuccess(1, "test", "gpt-4", 180*time.Millisecond)
+ ObserveSuccess(2, "test", "gpt-4", 5*time.Second)
+ ObserveSuccess(2, "test", "gpt-4", 6*time.Second)
+ ObserveSuccess(2, "test", "gpt-4", 4*time.Second)
+
+ channels := []*model.Channel{ch1, ch2}
+ candidates := ScoreCandidates(channels, "test", "gpt-4", 0)
+
+ require.GreaterOrEqual(t, len(candidates), 2)
+ // ch1(低延迟)的分数应显著高于 ch2
+ require.Equal(t, 1, candidates[0].Channel.Id, "expected ch1 (low latency) to rank first")
+ scoreDiff := candidates[0].Score - candidates[1].Score
+ require.Greater(t, scoreDiff, 0.1, "expected significant score difference")
+ t.Logf("ch1 (low latency) score=%.4f, ch2 (high latency) score=%.4f", candidates[0].Score, candidates[1].Score)
+}
+
+// 测试2:429 渠道不会完全排除但会被降权
+func TestRateLimitedChannelDowngraded(t *testing.T) {
+ ch1 := testChannel(10, 10, 100)
+ ch2 := testChannel(11, 10, 100)
+
+ // ch1 正常,ch2 有 429
+ ObserveSuccess(10, "test", "gpt-4", 300*time.Millisecond)
+ ObserveSuccess(10, "test", "gpt-4", 250*time.Millisecond)
+ ObserveFailure(11, "test", "gpt-4", 429, 100*time.Millisecond)
+ ObserveFailure(11, "test", "gpt-4", 429, 100*time.Millisecond)
+ ObserveFailure(11, "test", "gpt-4", 429, 100*time.Millisecond)
+
+ channels := []*model.Channel{ch1, ch2}
+ candidates := ScoreCandidates(channels, "test", "gpt-4", 0)
+
+ require.GreaterOrEqual(t, len(candidates), 2)
+ // 正常渠道应排名更高
+ require.Equal(t, 10, candidates[0].Channel.Id, "expected ch10 (normal) to rank first")
+ t.Logf("ch10 (normal) score=%.4f rate_limit_factor=%.4f", candidates[0].Score, candidates[0].RateLimitFactor)
+ t.Logf("ch11 (429) score=%.4f rate_limit_factor=%.4f", candidates[1].Score, candidates[1].RateLimitFactor)
+ require.Less(t, candidates[1].RateLimitFactor, candidates[0].RateLimitFactor)
+}
+
+// 测试3:熔断渠道不会被选中
+func TestCircuitBreakerChannelExcluded(t *testing.T) {
+ ch := testChannel(20, 10, 100)
+
+ // 模拟三次连续失败,触发熔断
+ RecordCircuitFailure(20, "500 Internal Server Error")
+ RecordCircuitFailure(20, "500 Internal Server Error")
+ RecordCircuitFailure(20, "500 Internal Server Error")
+
+ require.True(t, IsCircuitOpen(20), "expected circuit to be open after 3 consecutive failures")
+
+ // 评分中应过滤掉熔断渠道
+ channels := []*model.Channel{ch}
+ _ = ScoreCandidates(channels, "test", "gpt-4", 0)
+
+ // channel_adaptive.go 中过滤逻辑会跳过 open 渠道
+ require.True(t, IsCircuitOpen(20))
+}
+
+// 测试4:多渠道重试不会重复选择同一个渠道
+func TestNoDuplicateChannelInRetry(t *testing.T) {
+ ch1 := testChannel(30, 10, 100)
+ ch2 := testChannel(31, 10, 100)
+ ch3 := testChannel(32, 10, 100)
+
+ // 全部成功
+ for _, id := range []int{30, 31, 32} {
+ ObserveSuccess(id, "test", "gpt-4", 200*time.Millisecond)
+ }
+
+ channels := []*model.Channel{ch1, ch2, ch3}
+ candidates := ScoreCandidates(channels, "test", "gpt-4", 0)
+
+ // 模拟已使用的渠道
+ usedIDs := []int{30}
+
+ // 过滤掉已使用的渠道
+ var filtered []CandidateScore
+ for _, c := range candidates {
+ if containsInt(usedIDs, c.Channel.Id) {
+ continue
+ }
+ filtered = append(filtered, c)
+ }
+
+ require.Len(t, filtered, 2)
+ selected := SelectTopKWeighted(filtered, 3)
+ require.NotNil(t, selected)
+ require.NotEqual(t, 30, selected.Channel.Id)
+ t.Logf("selected ch%d (ch30 excluded)", selected.Channel.Id)
+}
+
+// 测试5:TopK 加权随机不会全部集中在最高分渠道
+func TestTopKWeightedRandomFairness(t *testing.T) {
+ channels := make([]*model.Channel, 10)
+ for i := 0; i < 10; i++ {
+ channels[i] = testChannel(100+i, 10, 100)
+ ObserveSuccess(100+i, "test", "gpt-4", time.Duration(200+(i*100))*time.Millisecond)
+ }
+
+ candidates := ScoreCandidates(channels, "test", "gpt-4", 0)
+ require.GreaterOrEqual(t, len(candidates), 10)
+
+ // 模拟多次选择,统计分布
+ selectionCount := make(map[int]int)
+ trials := 1000
+ for i := 0; i < trials; i++ {
+ selected := SelectTopKWeighted(candidates, 3)
+ if selected != nil {
+ selectionCount[selected.Channel.Id]++
+ }
+ }
+
+ // TopK 的前三名应该都有一定比例
+ for _, c := range candidates[:3] {
+ count := selectionCount[c.Channel.Id]
+ ratio := float64(count) / float64(trials)
+ t.Logf("ch%d selection rate: %.2f%% (score=%.3f)", c.Channel.Id, ratio*100, c.Score)
+ require.GreaterOrEqual(t, ratio, 0.05, "ch%d selected too few times", c.Channel.Id)
+ }
+}
+
+// 测试6:EWMA 计算正确
+func TestEwmaUpdate(t *testing.T) {
+ alpha := 0.3
+
+ // 初始 1.0,观察到 0.5
+ result := EwmaUpdate(1.0, 0.5, alpha)
+ expected := 0.3*0.5 + 0.7*1.0 // = 0.85
+ require.InDelta(t, expected, result, 0.001)
+
+ // 再次衰减
+ result2 := EwmaUpdate(result, 0.5, alpha)
+ expected2 := 0.3*0.5 + 0.7*0.85 // = 0.745
+ require.InDelta(t, expected2, result2, 0.001)
+}
+
+// 测试7:延迟桶边界
+func TestLatencyBucket(t *testing.T) {
+ tests := []struct {
+ latency time.Duration
+ bucket int
+ }{
+ {100 * time.Millisecond, 0},
+ {500 * time.Millisecond, 0},
+ {600 * time.Millisecond, 1},
+ {1 * time.Second, 1},
+ {1500 * time.Millisecond, 2},
+ {3 * time.Second, 3},
+ {7 * time.Second, 4},
+ {15 * time.Second, 5},
+ }
+
+ for _, tt := range tests {
+ b := latencyBucket(tt.latency)
+ require.Equal(t, tt.bucket, b, "latencyBucket(%v)", tt.latency)
+ }
+}
+
+func TestAdaptiveFilteringRecomputesHalfOpenProbeScore(t *testing.T) {
+ channelID := 220
+ circuitBreakers.Delete(channelID)
+ t.Cleanup(func() { circuitBreakers.Delete(channelID) })
+
+ cb := getCircuitBreaker(channelID)
+ cb.mu.Lock()
+ cb.State = CircuitOpen
+ cb.OpenUntil = time.Now().Add(-time.Second)
+ cb.ConsecutiveFailure = 3
+ cb.mu.Unlock()
+
+ ch := testChannel(channelID, 10, 100)
+ candidates := ScoreCandidates([]*model.Channel{ch}, "test", "gpt-4", 0)
+ require.Len(t, candidates, 1)
+ require.Zero(t, candidates[0].Score)
+
+ filtered, permits := filterAdaptiveCandidates(
+ candidates,
+ "test",
+ "gpt-4",
+ 0,
+ nil,
+ false,
+ )
+ require.Len(t, filtered, 1)
+ require.Greater(t, filtered[0].Score, 0.0)
+ require.True(t, permits[channelID].HalfOpen)
+ ReleaseCircuitPermit(permits[channelID])
+}
+
+func TestAdaptiveLegacyFallbackPolicyDoesNotBypassFiltering(t *testing.T) {
+ require.True(t, shouldFallbackToLegacy(0, 0, false))
+ require.False(t, shouldFallbackToLegacy(2, 0, false))
+ require.True(t, shouldFallbackToLegacy(2, 0, true))
+}
+
+func TestOpenCircuitIgnoresLateSuccess(t *testing.T) {
+ channelID := 221
+ circuitBreakers.Delete(channelID)
+ t.Cleanup(func() { circuitBreakers.Delete(channelID) })
+
+ RecordCircuitFailure(channelID, "500")
+ RecordCircuitFailure(channelID, "500")
+ RecordCircuitFailure(channelID, "500")
+ require.True(t, IsCircuitOpen(channelID))
+
+ RecordCircuitSuccess(channelID)
+ state, failures, _ := GetCircuitState(channelID)
+ require.Equal(t, CircuitOpen, state)
+ require.Equal(t, 3, failures)
+}
+
+func TestGetMetricsReturnsImmutableSnapshot(t *testing.T) {
+ channelID := 222
+ ObserveSuccess(channelID, "snapshot", "gpt-4", 250*time.Millisecond)
+
+ first := GetMetrics(channelID, "snapshot", "gpt-4")
+ first.SuccessRate = 0
+ first.AvgLatency = 99 * time.Second
+
+ second := GetMetrics(channelID, "snapshot", "gpt-4")
+ require.Greater(t, second.SuccessRate, 0.0)
+ require.NotEqual(t, 99*time.Second, second.AvgLatency)
+}
+
+func TestHalfOpenClientErrorReleasesProbeAndClosesCircuit(t *testing.T) {
+ channelID := 223
+ circuitBreakers.Delete(channelID)
+ t.Cleanup(func() { circuitBreakers.Delete(channelID) })
+
+ cb := getCircuitBreaker(channelID)
+ cb.mu.Lock()
+ cb.State = CircuitOpen
+ cb.OpenUntil = time.Now().Add(-time.Second)
+ cb.ConsecutiveFailure = 3
+ cb.mu.Unlock()
+ permit, ok := AcquireCircuitPermit(channelID)
+ require.True(t, ok)
+ require.True(t, permit.HalfOpen)
+
+ ctx, _ := gin.CreateTestContext(nil)
+ ctx.Set(string(ctxKeyAdaptiveCircuitPermit), permit)
+ RecordAdaptiveResult(ctx, channelID, "test", "gpt-4", 400, time.Millisecond, fmt.Errorf("bad request"))
+
+ state, failures, _ := GetCircuitState(channelID)
+ require.Equal(t, CircuitClosed, state)
+ require.Zero(t, failures)
+}
diff --git a/service/channel_affinity.go b/service/channel_affinity.go
index 96ec13e248cc..076ac468cfaf 100644
--- a/service/channel_affinity.go
+++ b/service/channel_affinity.go
@@ -1,6 +1,7 @@
package service
import (
+ "context"
"fmt"
"hash/fnv"
"regexp"
@@ -27,6 +28,7 @@ const (
ginKeyChannelAffinitySkipRetry = "channel_affinity_skip_retry_on_failure"
channelAffinityCacheNamespace = "new-api:channel_affinity:v1"
+ channelAffinityRedisLRUIndex = "new-api:channel_affinity_lru:v1"
channelAffinityUsageCacheStatsNamespace = "new-api:channel_affinity_usage_cache_stats:v1"
)
@@ -207,6 +209,13 @@ func ClearChannelAffinityCacheAll() int {
common.SysError(fmt.Sprintf("channel affinity cache delete many failed: err=%v", err))
}
}
+ if common.RedisEnabled && common.RDB != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := common.RDB.Del(ctx, channelAffinityRedisLRUIndex).Err(); err != nil {
+ common.SysError(fmt.Sprintf("channel affinity LRU index clear failed: err=%v", err))
+ }
+ }
return len(keys)
}
@@ -238,10 +247,42 @@ func ClearChannelAffinityCacheByRuleName(ruleName string) (int, error) {
}
cache := getChannelAffinityCache()
- deleted, err := cache.DeleteByPrefix(ruleName)
+ if !common.RedisEnabled || common.RDB == nil {
+ return cache.DeleteByPrefix(ruleName)
+ }
+ keys, err := cache.Keys()
if err != nil {
return 0, err
}
+ prefix := cache.FullKey(ruleName) + ":"
+ matched := make([]string, 0, len(keys))
+ for _, key := range keys {
+ if strings.HasPrefix(key, prefix) {
+ matched = append(matched, key)
+ }
+ }
+ if len(matched) == 0 {
+ return 0, nil
+ }
+ deletedMap, err := cache.DeleteMany(matched)
+ if err != nil {
+ return 0, err
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ members := make([]interface{}, 0, len(matched))
+ for _, key := range matched {
+ members = append(members, key)
+ }
+ if err := common.RDB.ZRem(ctx, channelAffinityRedisLRUIndex, members...).Err(); err != nil {
+ return 0, err
+ }
+ deleted := 0
+ for _, ok := range deletedMap {
+ if ok {
+ deleted++
+ }
+ }
return deleted, nil
}
@@ -334,18 +375,35 @@ func extractChannelAffinityValue(c *gin.Context, src operation_setting.ChannelAf
}
}
-func buildChannelAffinityCacheKeySuffix(rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
+func fixedAffinityKeyPart(value string) string {
+ return fmt.Sprintf("%x", common.Sha256Raw([]byte(value)))
+}
+
+func channelAffinityCredentialScope(c *gin.Context) string {
+ if c != nil {
+ if tokenID := c.GetInt("token_id"); tokenID > 0 {
+ return fmt.Sprintf("token:%d", tokenID)
+ }
+ if userID := c.GetInt("id"); userID > 0 {
+ return fmt.Sprintf("user:%d", userID)
+ }
+ }
+ return "anonymous"
+}
+
+func buildChannelAffinityCacheKeySuffix(c *gin.Context, rule operation_setting.ChannelAffinityRule, modelName string, usingGroup string, affinityValue string) string {
parts := make([]string, 0, 4)
if rule.IncludeRuleName && rule.Name != "" {
parts = append(parts, rule.Name)
}
if rule.IncludeModelName && modelName != "" {
- parts = append(parts, modelName)
+ parts = append(parts, fixedAffinityKeyPart(modelName)[:16])
}
if rule.IncludeUsingGroup && usingGroup != "" {
- parts = append(parts, usingGroup)
+ parts = append(parts, fixedAffinityKeyPart(usingGroup)[:16])
}
- parts = append(parts, affinityValue)
+ scopedValue := channelAffinityCredentialScope(c) + "\x00" + affinityValue
+ parts = append(parts, fixedAffinityKeyPart(scopedValue))
return strings.Join(parts, ":")
}
@@ -591,7 +649,7 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
if ttlSeconds <= 0 {
ttlSeconds = setting.DefaultTTLSeconds
}
- cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, modelName, usingGroup, affinityValue)
+ cacheKeySuffix := buildChannelAffinityCacheKeySuffix(c, rule, modelName, usingGroup, affinityValue)
cacheKeyFull := channelAffinityCacheNamespace + ":" + cacheKeySuffix
setChannelAffinityContext(c, channelAffinityMeta{
CacheKey: cacheKeyFull,
@@ -734,11 +792,50 @@ func RecordChannelAffinity(c *gin.Context, channelID int) {
ttlSeconds = 3600
}
cache := getChannelAffinityCache()
- if err := cache.SetWithTTL(cacheKey, channelID, time.Duration(ttlSeconds)*time.Second); err != nil {
+ if err := setChannelAffinityWithLimit(cache, cacheKey, channelID, time.Duration(ttlSeconds)*time.Second, setting.MaxEntries); err != nil {
common.SysError(fmt.Sprintf("channel affinity cache set failed: key=%s, err=%v", cacheKey, err))
}
}
+const channelAffinityRedisSetScript = `
+redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
+redis.call('ZADD', KEYS[2], ARGV[3], KEYS[1])
+local max_entries = tonumber(ARGV[4])
+if max_entries and max_entries > 0 then
+ local count = redis.call('ZCARD', KEYS[2])
+ local overflow = count - max_entries
+ if overflow > 0 then
+ local victims = redis.call('ZRANGE', KEYS[2], 0, overflow - 1)
+ for _, key in ipairs(victims) do
+ redis.call('DEL', key)
+ end
+ redis.call('ZREMRANGEBYRANK', KEYS[2], 0, overflow - 1)
+ end
+end
+return 1
+`
+
+func setChannelAffinityWithLimit(cache *cachex.HybridCache[int], key string, channelID int, ttl time.Duration, maxEntries int) error {
+ if !common.RedisEnabled || common.RDB == nil {
+ return cache.SetWithTTL(key, channelID, ttl)
+ }
+ if maxEntries <= 0 {
+ maxEntries = 100_000
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ _, err := common.RDB.Eval(
+ ctx,
+ channelAffinityRedisSetScript,
+ []string{cache.FullKey(key), channelAffinityRedisLRUIndex},
+ strconv.Itoa(channelID),
+ strconv.FormatInt(ttl.Milliseconds(), 10),
+ strconv.FormatInt(time.Now().UnixNano(), 10),
+ strconv.Itoa(maxEntries),
+ ).Result()
+ return err
+}
+
type ChannelAffinityUsageCacheStats struct {
RuleName string `json:"rule_name"`
UsingGroup string `json:"using_group"`
diff --git a/service/channel_affinity_template_test.go b/service/channel_affinity_template_test.go
index fb703a24e720..ed148431f7ed 100644
--- a/service/channel_affinity_template_test.go
+++ b/service/channel_affinity_template_test.go
@@ -205,7 +205,7 @@ func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
}
affinityValue := fmt.Sprintf("header-hit-%d", time.Now().UnixNano())
- cacheKeySuffix := buildChannelAffinityCacheKeySuffix(rule, "gpt-5", "default", affinityValue)
+ cacheKeySuffix := buildChannelAffinityCacheKeySuffix(nil, rule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9528, time.Minute))
@@ -236,6 +236,28 @@ func TestGetPreferredChannelByAffinity_RequestHeaderKeySource(t *testing.T) {
require.Equal(t, buildChannelAffinityKeyHint(affinityValue), meta.KeyHint)
}
+func TestChannelAffinityCacheKeyIsScopedAndBounded(t *testing.T) {
+ rule := operation_setting.ChannelAffinityRule{
+ Name: "trace-affinity",
+ IncludeRuleName: true,
+ IncludeModelName: true,
+ IncludeUsingGroup: true,
+ }
+ longTrace := strings.Repeat("attacker-controlled-trace-", 100)
+
+ ctx1, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx1.Set("token_id", 101)
+ ctx2, _ := gin.CreateTestContext(httptest.NewRecorder())
+ ctx2.Set("token_id", 202)
+
+ key1 := buildChannelAffinityCacheKeySuffix(ctx1, rule, strings.Repeat("model", 100), "default", longTrace)
+ key2 := buildChannelAffinityCacheKeySuffix(ctx2, rule, strings.Repeat("model", 100), "default", longTrace)
+
+ require.NotEqual(t, key1, key2)
+ require.NotContains(t, key1, longTrace)
+ require.Less(t, len(key1), 160)
+}
+
func TestClearCurrentChannelAffinityCache(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -280,7 +302,7 @@ func TestChannelAffinityHitCodexTemplatePassHeadersEffective(t *testing.T) {
require.NotNil(t, codexRule)
affinityValue := fmt.Sprintf("pc-hit-%d", time.Now().UnixNano())
- cacheKeySuffix := buildChannelAffinityCacheKeySuffix(*codexRule, "gpt-5", "default", affinityValue)
+ cacheKeySuffix := buildChannelAffinityCacheKeySuffix(nil, *codexRule, "gpt-5", "default", affinityValue)
cache := getChannelAffinityCache()
require.NoError(t, cache.SetWithTTL(cacheKeySuffix, 9527, time.Minute))
diff --git a/service/channel_affinity_usage_cache_test.go b/service/channel_affinity_usage_cache_test.go
index 64d3d715b547..2af84f6da178 100644
--- a/service/channel_affinity_usage_cache_test.go
+++ b/service/channel_affinity_usage_cache_test.go
@@ -4,7 +4,6 @@ import (
"fmt"
"net/http/httptest"
"testing"
- "time"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/types"
@@ -12,7 +11,16 @@ import (
"github.com/stretchr/testify/require"
)
-func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string) *gin.Context {
+func buildChannelAffinityStatsContextForTest(t *testing.T) (*gin.Context, string, string, string) {
+ t.Helper()
+ ruleName := fmt.Sprintf("rule_%s", t.Name())
+ usingGroup := "default"
+ keyFP := fmt.Sprintf("fp_%s", t.Name())
+ entryKey := channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFP)
+ t.Cleanup(func() {
+ _, _ = getChannelAffinityUsageCacheStatsCache().DeleteMany([]string{entryKey})
+ })
+
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
setChannelAffinityContext(ctx, channelAffinityMeta{
@@ -22,14 +30,11 @@ func buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP string)
UsingGroup: usingGroup,
KeyFingerprint: keyFP,
})
- return ctx
+ return ctx, ruleName, usingGroup, keyFP
}
func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T) {
- ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
- usingGroup := "default"
- keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
- ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
+ ctx, ruleName, usingGroup, keyFP := buildChannelAffinityStatsContextForTest(t)
usage := &dto.Usage{
PromptTokens: 100,
@@ -53,10 +58,7 @@ func TestObserveChannelAffinityUsageCacheByRelayFormat_ClaudeMode(t *testing.T)
}
func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) {
- ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
- usingGroup := "default"
- keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
- ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
+ ctx, ruleName, usingGroup, keyFP := buildChannelAffinityStatsContextForTest(t)
openAIUsage := &dto.Usage{
PromptTokens: 100,
@@ -83,10 +85,7 @@ func TestObserveChannelAffinityUsageCacheByRelayFormat_MixedMode(t *testing.T) {
}
func TestObserveChannelAffinityUsageCacheByRelayFormat_UnsupportedModeKeepsEmpty(t *testing.T) {
- ruleName := fmt.Sprintf("rule_%d", time.Now().UnixNano())
- usingGroup := "default"
- keyFP := fmt.Sprintf("fp_%d", time.Now().UnixNano())
- ctx := buildChannelAffinityStatsContextForTest(ruleName, usingGroup, keyFP)
+ ctx, ruleName, usingGroup, keyFP := buildChannelAffinityStatsContextForTest(t)
usage := &dto.Usage{
PromptTokens: 100,
diff --git a/service/channel_circuit.go b/service/channel_circuit.go
new file mode 100644
index 000000000000..d3b8185f5b51
--- /dev/null
+++ b/service/channel_circuit.go
@@ -0,0 +1,257 @@
+package service
+
+import (
+ "sync"
+ "time"
+
+ "github.com/QuantumNous/new-api/constant"
+)
+
+// CircuitState 熔断器状态
+type CircuitState string
+
+const (
+ CircuitClosed CircuitState = "closed" // 正常
+ CircuitOpen CircuitState = "open" // 熔断打开,不选
+ CircuitHalfOpen CircuitState = "half_open" // 半开,允许探测
+)
+
+// ChannelCircuitBreaker 渠道熔断器(本地状态 + Redis 同步)
+// 约定:请求路径上只读本地状态,不访问 Redis。
+type ChannelCircuitBreaker struct {
+ mu sync.RWMutex
+
+ State CircuitState
+ ConsecutiveFailure int // 连续失败计数
+ OpenUntil time.Time // open 状态过期时间
+ HalfOpenLimit int // half-open 最大探测数
+ HalfOpenInFlight int // half-open 进行中的探测数
+ HalfOpenSince time.Time // when current half-open probe started
+ LastError string // 最近一次错误信息
+ Generation uint64 // invalidates results from requests started before a transition
+}
+
+type CircuitPermit struct {
+ ChannelID int
+ Generation uint64
+ HalfOpen bool
+}
+
+var (
+ circuitBreakers sync.Map // map[int]*ChannelCircuitBreaker, key=channelID
+)
+
+// getCircuitBreaker 获取或创建渠道熔断器
+func getCircuitBreaker(channelID int) *ChannelCircuitBreaker {
+ v, _ := circuitBreakers.LoadOrStore(channelID, &ChannelCircuitBreaker{
+ State: CircuitClosed,
+ HalfOpenLimit: 1,
+ })
+ return v.(*ChannelCircuitBreaker)
+}
+
+// IsCircuitOpen 判断渠道是否熔断(请求路径使用,读本地状态)
+func IsCircuitOpen(channelID int) bool {
+ if !constant.ChannelCircuitBreakerEnabled {
+ return false
+ }
+
+ cb := getCircuitBreaker(channelID)
+ cb.mu.RLock()
+ defer cb.mu.RUnlock()
+
+ if cb.State == CircuitClosed {
+ return false
+ }
+
+ if cb.State == CircuitOpen && time.Now().After(cb.OpenUntil) {
+ // Cooldown elapsed: still report open so selector must call ProbeHalfOpen.
+ return true
+ }
+
+ return true
+}
+
+// IsInCooldown 判断渠道是否在 429 cooldown 中
+func IsInCooldown(channelID int, cooldownUntil time.Time) bool {
+ if cooldownUntil.IsZero() {
+ return false
+ }
+ return time.Now().Before(cooldownUntil)
+}
+
+// RecordSuccess 成功调用 -> 重置熔断状态
+func RecordCircuitSuccess(channelID int) {
+ if !constant.ChannelCircuitBreakerEnabled {
+ return
+ }
+
+ cb := getCircuitBreaker(channelID)
+ cb.mu.Lock()
+ defer cb.mu.Unlock()
+
+ // A success without a selection-time permit may be a late result from a
+ // request that started before the circuit opened. It must not close an
+ // open/half-open circuit.
+ if cb.State != CircuitClosed {
+ return
+ }
+ cb.ConsecutiveFailure = 0
+ cb.OpenUntil = time.Time{}
+ cb.LastError = ""
+}
+
+func RecordCircuitSuccessWithPermit(permit CircuitPermit) {
+ if !constant.ChannelCircuitBreakerEnabled || permit.ChannelID <= 0 {
+ return
+ }
+
+ cb := getCircuitBreaker(permit.ChannelID)
+ cb.mu.Lock()
+ defer cb.mu.Unlock()
+
+ if permit.Generation != cb.Generation {
+ return
+ }
+ if permit.HalfOpen {
+ if cb.State != CircuitHalfOpen {
+ return
+ }
+ if cb.HalfOpenInFlight > 0 {
+ cb.HalfOpenInFlight--
+ }
+ cb.State = CircuitClosed
+ cb.Generation++
+ cb.HalfOpenSince = time.Time{}
+ } else if cb.State != CircuitClosed {
+ return
+ }
+
+ cb.ConsecutiveFailure = 0
+ cb.OpenUntil = time.Time{}
+ cb.LastError = ""
+}
+
+// RecordFailure 失败调用 -> 可能触发熔断
+func RecordCircuitFailure(channelID int, errMsg string) {
+ if !constant.ChannelCircuitBreakerEnabled {
+ return
+ }
+ cb := getCircuitBreaker(channelID)
+ cb.mu.RLock()
+ permit := CircuitPermit{ChannelID: channelID, Generation: cb.Generation}
+ cb.mu.RUnlock()
+ RecordCircuitFailureWithPermit(permit, errMsg)
+}
+
+func RecordCircuitFailureWithPermit(permit CircuitPermit, errMsg string) {
+ if !constant.ChannelCircuitBreakerEnabled || permit.ChannelID <= 0 {
+ return
+ }
+
+ cb := getCircuitBreaker(permit.ChannelID)
+ cb.mu.Lock()
+ defer cb.mu.Unlock()
+
+ if permit.Generation != cb.Generation {
+ return
+ }
+ if permit.HalfOpen {
+ if cb.State != CircuitHalfOpen {
+ return
+ }
+ if cb.HalfOpenInFlight > 0 {
+ cb.HalfOpenInFlight--
+ }
+ cb.HalfOpenSince = time.Time{}
+ cb.State = CircuitOpen
+ cb.Generation++
+ cb.ConsecutiveFailure++
+ cb.LastError = errMsg
+ cb.OpenUntil = time.Now().Add(time.Duration(constant.ChannelCooldownSeconds) * time.Second)
+ return
+ }
+ if cb.State != CircuitClosed {
+ return
+ }
+
+ cb.ConsecutiveFailure++
+ cb.LastError = errMsg
+
+ // closed 状态下连续失败达到阈值 -> open
+ threshold := 3
+ if cb.ConsecutiveFailure >= threshold {
+ cb.State = CircuitOpen
+ cb.Generation++
+ cb.OpenUntil = time.Now().Add(time.Duration(constant.ChannelCooldownSeconds) * time.Second)
+ }
+
+ // 如果配置了熔断但未启用,不做任何事
+}
+
+func AcquireCircuitPermit(channelID int) (CircuitPermit, bool) {
+ if !constant.ChannelCircuitBreakerEnabled {
+ return CircuitPermit{ChannelID: channelID}, true
+ }
+
+ cb := getCircuitBreaker(channelID)
+ cb.mu.Lock()
+ defer cb.mu.Unlock()
+
+ if cb.State == CircuitClosed {
+ return CircuitPermit{ChannelID: channelID, Generation: cb.Generation}, true
+ }
+ if cb.State == CircuitOpen && time.Now().After(cb.OpenUntil) {
+ cb.State = CircuitHalfOpen
+ cb.HalfOpenInFlight = 0
+ cb.HalfOpenSince = time.Time{}
+ }
+ if cb.State != CircuitHalfOpen {
+ return CircuitPermit{}, false
+ }
+
+ const halfOpenProbeTimeout = 60 * time.Second
+ if cb.HalfOpenInFlight > 0 && !cb.HalfOpenSince.IsZero() && time.Since(cb.HalfOpenSince) > halfOpenProbeTimeout {
+ cb.HalfOpenInFlight = 0
+ cb.HalfOpenSince = time.Time{}
+ }
+ if cb.HalfOpenInFlight >= cb.HalfOpenLimit {
+ return CircuitPermit{}, false
+ }
+
+ cb.HalfOpenInFlight++
+ cb.HalfOpenSince = time.Now()
+ return CircuitPermit{ChannelID: channelID, Generation: cb.Generation, HalfOpen: true}, true
+}
+
+func ReleaseCircuitPermit(permit CircuitPermit) {
+ if !constant.ChannelCircuitBreakerEnabled || !permit.HalfOpen || permit.ChannelID <= 0 {
+ return
+ }
+ cb := getCircuitBreaker(permit.ChannelID)
+ cb.mu.Lock()
+ defer cb.mu.Unlock()
+ if cb.State != CircuitHalfOpen || cb.Generation != permit.Generation {
+ return
+ }
+ if cb.HalfOpenInFlight > 0 {
+ cb.HalfOpenInFlight--
+ }
+ if cb.HalfOpenInFlight == 0 {
+ cb.HalfOpenSince = time.Time{}
+ }
+}
+
+// ProbeHalfOpen 申请进入 half-open 探测(由选择器调用)
+func ProbeHalfOpen(channelID int) bool {
+ _, ok := AcquireCircuitPermit(channelID)
+ return ok
+}
+
+// GetCircuitState 读取熔断状态(供日志/观测使用)
+func GetCircuitState(channelID int) (CircuitState, int, string) {
+ cb := getCircuitBreaker(channelID)
+ cb.mu.RLock()
+ defer cb.mu.RUnlock()
+ return cb.State, cb.ConsecutiveFailure, cb.LastError
+}
diff --git a/service/channel_metrics.go b/service/channel_metrics.go
new file mode 100644
index 000000000000..be3bef021068
--- /dev/null
+++ b/service/channel_metrics.go
@@ -0,0 +1,310 @@
+package service
+
+import (
+ "fmt"
+ "math"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/QuantumNous/new-api/common"
+ "github.com/QuantumNous/new-api/constant"
+)
+
+// ChannelMetrics 渠道运行时指标,按 (channelID, group, model) 分桶
+type ChannelMetrics struct {
+ mu sync.Mutex `json:"-"` // 保护并发写
+ SuccessRate float64 `json:"success_rate"` // EWMA
+ ErrorRate float64 `json:"error_rate"` // EWMA
+ RateLimitRate float64 `json:"rate_limit_rate"` // EWMA 429 率
+ Status5xxRate float64 `json:"status_5xx_rate"` // EWMA 5xx 率
+ AvgLatency time.Duration `json:"avg_latency"` // EWMA 平均延迟
+ SampleCount int64 `json:"sample_count"` // 总样本数
+ LastSeen time.Time `json:"last_seen"`
+
+ // 延迟直方图(轻量桶),用于近似 p95
+ LatencyBuckets [6]int64 `json:"latency_buckets"` // <=500ms, <=1s, <=2s, <=5s, <=10s, >10s
+}
+
+// LocalMetricsSnapshot 进程内本地指标快照,定期从 Redis sync 或直接从本地累加
+type LocalMetricsSnapshot struct {
+ mu sync.RWMutex
+ metrics map[metricsKey]*ChannelMetrics
+ updatedAt time.Time
+}
+
+type metricsKey struct {
+ ChannelID int
+ Group string
+ Model string
+}
+
+var globalSnapshot = &LocalMetricsSnapshot{
+ metrics: make(map[metricsKey]*ChannelMetrics),
+}
+
+// ensureKey 获取或创建指定 key 的指标桶
+func (s *LocalMetricsSnapshot) ensureKey(key metricsKey) *ChannelMetrics {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ m, ok := s.metrics[key]
+ if !ok {
+ m = &ChannelMetrics{
+ SuccessRate: 1.0, // 冷启动默认信任
+ AvgLatency: 500 * time.Millisecond,
+ }
+ s.metrics[key] = m
+ }
+ return m
+}
+
+// EwmaUpdate 更新指标的 EWMA 值
+func EwmaUpdate(current, observed, alpha float64) float64 {
+ if alpha <= 0 || alpha > 1 {
+ alpha = constant.EwmaAlpha
+ }
+ return alpha*observed + (1-alpha)*current
+}
+
+// ObserveSuccess 记录一次成功调用
+func ObserveSuccess(channelID int, group, model string, latency time.Duration) {
+ alpha := constant.EwmaAlpha
+ key := metricsKey{channelID, group, model}
+ m := globalSnapshot.ensureKey(key)
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.SampleCount++
+ m.LastSeen = time.Now()
+
+ // 更新延迟 EWMA
+ if m.AvgLatency == 0 {
+ m.AvgLatency = latency
+ } else {
+ m.AvgLatency = time.Duration(EwmaUpdate(float64(m.AvgLatency), float64(latency), alpha))
+ }
+
+ // 更新延迟桶
+ bucket := latencyBucket(latency)
+ if bucket >= 0 && bucket < len(m.LatencyBuckets) {
+ m.LatencyBuckets[bucket]++
+ }
+
+ // 更新成功率 EWMA
+ m.SuccessRate = EwmaUpdate(m.SuccessRate, 1.0, alpha)
+ m.ErrorRate = EwmaUpdate(m.ErrorRate, 0, alpha)
+ m.RateLimitRate = EwmaUpdate(m.RateLimitRate, 0, alpha)
+ m.Status5xxRate = EwmaUpdate(m.Status5xxRate, 0, alpha)
+}
+
+// ObserveFailure 记录一次失败
+func ObserveFailure(channelID int, group, model string, statusCode int, latency time.Duration) {
+ alpha := constant.EwmaAlpha
+ key := metricsKey{channelID, group, model}
+ m := globalSnapshot.ensureKey(key)
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.SampleCount++
+ m.LastSeen = time.Now()
+
+ // 更新延迟
+ if m.AvgLatency == 0 {
+ m.AvgLatency = latency
+ } else {
+ m.AvgLatency = time.Duration(EwmaUpdate(float64(m.AvgLatency), float64(latency), alpha))
+ }
+
+ m.SuccessRate = EwmaUpdate(m.SuccessRate, 0, alpha)
+ m.ErrorRate = EwmaUpdate(m.ErrorRate, 1, alpha)
+
+ switch {
+ case statusCode == 429:
+ m.RateLimitRate = EwmaUpdate(m.RateLimitRate, 1, alpha)
+ m.Status5xxRate = EwmaUpdate(m.Status5xxRate, 0, alpha)
+ case statusCode >= 500:
+ m.Status5xxRate = EwmaUpdate(m.Status5xxRate, 1, alpha)
+ m.RateLimitRate = EwmaUpdate(m.RateLimitRate, 0, alpha)
+ default:
+ m.RateLimitRate = EwmaUpdate(m.RateLimitRate, 0, alpha)
+ m.Status5xxRate = EwmaUpdate(m.Status5xxRate, 0, alpha)
+ }
+}
+
+// GetMetrics 读取指定 (channelID, group, model) 的指标快照
+func GetMetrics(channelID int, group, model string) *ChannelMetrics {
+ key := metricsKey{channelID, group, model}
+ globalSnapshot.mu.RLock()
+ m, ok := globalSnapshot.metrics[key]
+ globalSnapshot.mu.RUnlock()
+ if ok {
+ return snapshotChannelMetrics(m)
+ }
+
+ // 尝试回退到 (channelID, group)
+ key2 := metricsKey{channelID, group, ""}
+ globalSnapshot.mu.RLock()
+ m2, ok2 := globalSnapshot.metrics[key2]
+ globalSnapshot.mu.RUnlock()
+ if ok2 {
+ return snapshotChannelMetrics(m2)
+ }
+
+ // 回退到 (channelID)
+ key3 := metricsKey{channelID, "", ""}
+ globalSnapshot.mu.RLock()
+ m3, ok3 := globalSnapshot.metrics[key3]
+ globalSnapshot.mu.RUnlock()
+ if ok3 {
+ return snapshotChannelMetrics(m3)
+ }
+
+ // 无数据,返回中性默认值
+ return &ChannelMetrics{
+ SuccessRate: 1.0,
+ AvgLatency: 500 * time.Millisecond,
+ }
+}
+
+func snapshotChannelMetrics(m *ChannelMetrics) *ChannelMetrics {
+ if m == nil {
+ return nil
+ }
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return &ChannelMetrics{
+ SuccessRate: m.SuccessRate,
+ ErrorRate: m.ErrorRate,
+ RateLimitRate: m.RateLimitRate,
+ Status5xxRate: m.Status5xxRate,
+ AvgLatency: m.AvgLatency,
+ SampleCount: m.SampleCount,
+ LastSeen: m.LastSeen,
+ LatencyBuckets: m.LatencyBuckets,
+ }
+}
+
+// GetP95Latency 从桶近似计算 p95 延迟
+func GetP95Latency(buckets [6]int64) time.Duration {
+ var total int64
+ for _, v := range buckets {
+ total += v
+ }
+ if total == 0 {
+ return 0
+ }
+
+ target := int64(math.Ceil(float64(total) * 0.95))
+ var cumulative int64
+
+ bucketBoundaries := []time.Duration{
+ 500 * time.Millisecond,
+ 1 * time.Second,
+ 2 * time.Second,
+ 5 * time.Second,
+ 10 * time.Second,
+ math.MaxInt64,
+ }
+
+ for i, count := range buckets {
+ cumulative += count
+ if cumulative >= target {
+ return bucketBoundaries[i]
+ }
+ }
+ return 10 * time.Second
+}
+
+// CurrentConcurrencyTracker 本地并发计数器(原子操作,零网络开销)
+type CurrentConcurrencyTracker struct {
+ counters sync.Map // map[int]*atomic.Int64
+}
+
+var globalConcurrency = &CurrentConcurrencyTracker{}
+
+func (t *CurrentConcurrencyTracker) Inc(channelID int) int64 {
+ v, _ := t.counters.LoadOrStore(channelID, new(atomic.Int64))
+ return v.(*atomic.Int64).Add(1)
+}
+
+func (t *CurrentConcurrencyTracker) Dec(channelID int) int64 {
+ v, ok := t.counters.Load(channelID)
+ if !ok {
+ return 0
+ }
+ return v.(*atomic.Int64).Add(-1)
+}
+
+func (t *CurrentConcurrencyTracker) Get(channelID int) int64 {
+ v, ok := t.counters.Load(channelID)
+ if !ok {
+ return 0
+ }
+ return v.(*atomic.Int64).Load()
+}
+
+// IncChannelConcurrency 增加并发计数
+func IncChannelConcurrency(channelID int) int64 {
+ return globalConcurrency.Inc(channelID)
+}
+
+// DecChannelConcurrency 减少并发计数
+func DecChannelConcurrency(channelID int) int64 {
+ return globalConcurrency.Dec(channelID)
+}
+
+// GetChannelConcurrency 获取当前并发
+func GetChannelConcurrency(channelID int) int64 {
+ return globalConcurrency.Get(channelID)
+}
+
+func latencyBucket(latency time.Duration) int {
+ switch {
+ case latency <= 500*time.Millisecond:
+ return 0
+ case latency <= 1*time.Second:
+ return 1
+ case latency <= 2*time.Second:
+ return 2
+ case latency <= 5*time.Second:
+ return 3
+ case latency <= 10*time.Second:
+ return 4
+ default:
+ return 5
+ }
+}
+
+// SyncAdaptiveMetricsToRedis publishes a compact snapshot for multi-instance
+// sticky-or-shared observation. Best-effort; failures are silent.
+func SyncAdaptiveMetricsToRedis() {
+ if !common.RedisEnabled || common.RDB == nil {
+ return
+ }
+ globalSnapshot.mu.RLock()
+ defer globalSnapshot.mu.RUnlock()
+ type row struct {
+ ChannelID int `json:"c"`
+ Group string `json:"g"`
+ Model string `json:"m"`
+ SuccessRate float64 `json:"s"`
+ SampleCount int64 `json:"n"`
+ }
+ out := make([]row, 0, len(globalSnapshot.metrics))
+ for k, m := range globalSnapshot.metrics {
+ m.mu.Lock()
+ out = append(out, row{k.ChannelID, k.Group, k.Model, m.SuccessRate, m.SampleCount})
+ m.mu.Unlock()
+ }
+ b, err := common.Marshal(out)
+ if err != nil {
+ return
+ }
+ key := fmt.Sprintf("newapi:adaptive:metrics:%s", common.NodeName)
+ if key == "newapi:adaptive:metrics:" {
+ key = "newapi:adaptive:metrics:default"
+ }
+ _ = common.RedisSet(key, string(b), 2*time.Minute)
+}
diff --git a/service/channel_score.go b/service/channel_score.go
new file mode 100644
index 000000000000..2e7b88219e65
--- /dev/null
+++ b/service/channel_score.go
@@ -0,0 +1,165 @@
+package service
+
+import (
+ "github.com/QuantumNous/new-api/constant"
+ "math"
+ "math/rand"
+ "sort"
+ "time"
+
+ "github.com/QuantumNous/new-api/model"
+)
+
+// CandidateScore 评分候选结果
+type CandidateScore struct {
+ Channel *model.Channel
+ Score float64
+
+ // 各因子明细(供日志/观测用)
+ BaseWeight float64 `json:"base_weight"`
+ SuccessFactor float64 `json:"success_factor"`
+ LatencyFactor float64 `json:"latency_factor"`
+ RateLimitFactor float64 `json:"rate_limit_factor"`
+ ConcurrencyFactor float64 `json:"concurrency_factor"`
+ CircuitFactor float64 `json:"circuit_factor"`
+ AffinityFactor float64 `json:"affinity_factor"`
+}
+
+// ScoreCandidates 对一组渠道进行动态评分,返回排序后的候选列表
+// 不传 group, model 时会回退到 channel 级别指标
+func ScoreCandidates(channels []*model.Channel, group, model string, preferredChannelID int) []CandidateScore {
+ if len(channels) == 0 {
+ return nil
+ }
+
+ candidates := make([]CandidateScore, 0, len(channels))
+
+ for _, ch := range channels {
+ circuitFactor := 1.0
+ if constant.ChannelCircuitBreakerEnabled {
+ if IsCircuitOpen(ch.Id) {
+ circuitFactor = 0.0
+ }
+ state, _, _ := GetCircuitState(ch.Id)
+ if state == CircuitHalfOpen {
+ circuitFactor = 0.5
+ }
+ }
+
+ candidates = append(candidates, scoreCandidate(ch, group, model, preferredChannelID, circuitFactor))
+ }
+
+ // 按分数降序排序
+ sort.Slice(candidates, func(i, j int) bool {
+ return candidates[i].Score > candidates[j].Score
+ })
+
+ return candidates
+}
+
+func scoreCandidate(ch *model.Channel, group, model string, preferredChannelID int, circuitFactor float64) CandidateScore {
+ metrics := GetMetrics(ch.Id, group, model)
+ baseWeight := float64(ch.GetWeight())
+ if baseWeight <= 0 {
+ baseWeight = 1.0
+ }
+ successFactor := math.Pow(metrics.SuccessRate, 2)
+ latencyMs := float64(metrics.AvgLatency) / float64(time.Millisecond)
+ latencyFactor := latencyToScore(latencyMs)
+ rateLimitFactor := math.Max(0, 1.0-metrics.RateLimitRate)
+
+ currentConcurrency := GetChannelConcurrency(ch.Id)
+ maxConcurrency := int64(constant.MaxChannelConcurrency)
+ concurrencyFactor := 1.0
+ if maxConcurrency > 0 && currentConcurrency >= maxConcurrency {
+ concurrencyFactor = 0.1
+ } else if maxConcurrency > 0 {
+ concurrencyFactor = 1.0 - float64(currentConcurrency)/float64(maxConcurrency)*0.5
+ }
+
+ affinityFactor := 1.0
+ if preferredChannelID > 0 && ch.Id == preferredChannelID {
+ affinityFactor = 1.5
+ }
+ score := baseWeight * successFactor * latencyFactor * rateLimitFactor *
+ concurrencyFactor * circuitFactor * affinityFactor
+ score *= 0.95 + rand.Float64()*0.1
+
+ return CandidateScore{
+ Channel: ch,
+ Score: score,
+ BaseWeight: baseWeight,
+ SuccessFactor: successFactor,
+ LatencyFactor: latencyFactor,
+ RateLimitFactor: rateLimitFactor,
+ ConcurrencyFactor: concurrencyFactor,
+ CircuitFactor: circuitFactor,
+ AffinityFactor: affinityFactor,
+ }
+}
+
+// SelectTopKWeighted 从候选列表中取 topK 然后按 score 加权随机选一个
+func SelectTopKWeighted(candidates []CandidateScore, k int) *CandidateScore {
+ if len(candidates) == 0 {
+ return nil
+ }
+
+ if len(candidates) == 1 {
+ return &candidates[0]
+ }
+
+ // 取 topK
+ if k <= 0 {
+ k = 3
+ }
+ if k > len(candidates) {
+ k = len(candidates)
+ }
+ top := candidates[:k]
+
+ // 加权随机
+ var totalWeight float64
+ for _, c := range top {
+ if c.Score > 0 {
+ totalWeight += c.Score
+ }
+ }
+
+ if totalWeight <= 0 {
+ // 所有分数为 0,均匀随机
+ idx := rand.Intn(len(top))
+ return &top[idx]
+ }
+
+ r := rand.Float64() * totalWeight
+ var cumulative float64
+ for i, c := range top {
+ cumulative += c.Score
+ if r < cumulative {
+ return &top[i]
+ }
+ }
+
+ return &top[len(top)-1]
+}
+
+// latencyToScore 将延迟(毫秒)映射到 [0, 1] 分数
+// 500ms → 1.0, 1s → 0.8, 2s → 0.5, 5s → 0.2, 10s+ → 0.05
+func latencyToScore(ms float64) float64 {
+ switch {
+ case ms <= 0:
+ return 1.0
+ case ms <= 500:
+ return 1.0
+ case ms <= 1000:
+ return 0.8
+ case ms <= 2000:
+ return 0.5
+ case ms <= 5000:
+ return 0.2
+ case ms <= 10000:
+ return 0.1
+ default:
+ return 0.05
+ }
+}
diff --git a/service/channel_select.go b/service/channel_select.go
index 24c4e252bfb3..4de77641e314 100644
--- a/service/channel_select.go
+++ b/service/channel_select.go
@@ -82,6 +82,18 @@ func (p *RetryParam) ResetRetryNextTry() {
// Retry=3: GroupB, priority1 (startRetryIndex=2, priorityRetry=1)
// 分组B, 优先级1
func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, error) {
+ // Adaptive entry. AdaptiveSelectChannel must only call
+ // cacheGetRandomSatisfiedChannelLegacy — never this function — or flags
+ // cause infinite recursion / stack overflow.
+ if constant.AdaptiveBalanceEnabled || constant.AdaptiveBalanceShadowMode {
+ return AdaptiveSelectChannel(param)
+ }
+ return cacheGetRandomSatisfiedChannelLegacy(param)
+}
+
+// cacheGetRandomSatisfiedChannelLegacy is the original random / auto-group picker.
+// Safe to call from adaptive fallbacks and candidate collection.
+func cacheGetRandomSatisfiedChannelLegacy(param *RetryParam) (*model.Channel, string, error) {
var channel *model.Channel
var err error
selectGroup := param.TokenGroup
@@ -116,7 +128,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
}
logger.LogDebug(param.Ctx, "Auto selecting group: %s, priorityRetry: %d", autoGroup, priorityRetry)
- channel, _ = model.GetRandomSatisfiedChannel(autoGroup, param.ModelName, priorityRetry, param.RequestPath)
+ channel, _ = model.GetRandomSatisfiedChannelExcluding(autoGroup, param.ModelName, priorityRetry, param.RequestPath, adaptiveUsedChannelSet(param.Ctx))
if channel == nil {
// Current group has no available channel for this model, try next group
// 当前分组没有该模型的可用渠道,尝试下一个分组
@@ -154,7 +166,7 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string,
break
}
} else {
- channel, err = model.GetRandomSatisfiedChannel(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath)
+ channel, err = model.GetRandomSatisfiedChannelExcluding(param.TokenGroup, param.ModelName, param.GetRetry(), param.RequestPath, adaptiveUsedChannelSet(param.Ctx))
if err != nil {
return nil, param.TokenGroup, err
}
diff --git a/service/http_client.go b/service/http_client.go
index dd36db6c0fb5..b23510b5f992 100644
--- a/service/http_client.go
+++ b/service/http_client.go
@@ -54,30 +54,17 @@ func ValidateSSRFProtectedFetchURL(urlStr string) error {
}
func InitHttpClient() {
- transport := &http.Transport{
- MaxIdleConns: common.RelayMaxIdleConns,
- MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
- IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
- ForceAttemptHTTP2: true,
- Proxy: http.ProxyFromEnvironment, // Support HTTP_PROXY, HTTPS_PROXY, NO_PROXY env vars
- }
- if common.TLSInsecureSkipVerify {
- transport.TLSClientConfig = common.InsecureTLSConfig
- }
+ transport := common.NewOutboundHTTPTransport(http.ProxyFromEnvironment, nil)
+ httpClient = newOutboundHTTPClient(transport, checkRedirect)
+ ssrfProtectedHTTPClient = newProtectedFetchHTTPClient()
+}
- if common.RelayTimeout == 0 {
- httpClient = &http.Client{
- Transport: transport,
- CheckRedirect: checkRedirect,
- }
- } else {
- httpClient = &http.Client{
- Transport: transport,
- Timeout: time.Duration(common.RelayTimeout) * time.Second,
- CheckRedirect: checkRedirect,
- }
+func newOutboundHTTPClient(transport http.RoundTripper, redirect func(*http.Request, []*http.Request) error) *http.Client {
+ client := &http.Client{Transport: transport, CheckRedirect: redirect}
+ if common.RelayTimeout > 0 {
+ client.Timeout = time.Duration(common.RelayTimeout) * time.Second
}
- ssrfProtectedHTTPClient = newProtectedFetchHTTPClient()
+ return client
}
// GetHttpClient returns the general outbound client used by relay/provider
@@ -91,6 +78,16 @@ func GetHttpClient() *http.Client {
return httpClient
}
+func GetHttpClientWithTimeout(timeout time.Duration) *http.Client {
+ base := GetHttpClient()
+ if base == nil {
+ return &http.Client{Timeout: timeout}
+ }
+ client := *base
+ client.Timeout = timeout
+ return &client
+}
+
// GetSSRFProtectedHTTPClient 返回带拨号时 SSRF 校验的客户端。
// ssrfProtectedHTTPClient 由 InitHttpClient 在启动时初始化,运行期只读。
func GetSSRFProtectedHTTPClient() *http.Client {
@@ -100,6 +97,16 @@ func GetSSRFProtectedHTTPClient() *http.Client {
return ssrfProtectedHTTPClient
}
+func GetSSRFProtectedHTTPClientWithTimeout(timeout time.Duration) *http.Client {
+ base := GetSSRFProtectedHTTPClient()
+ if base == nil {
+ return &http.Client{Timeout: timeout}
+ }
+ client := *base
+ client.Timeout = timeout
+ return &client
+}
+
// GetHttpClientWithProxy returns the default client or a proxy-enabled one when proxyURL is provided.
func GetHttpClientWithProxy(proxyURL string) (*http.Client, error) {
if proxyURL == "" {
@@ -143,21 +150,8 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
switch parsedURL.Scheme {
case "http", "https":
- transport := &http.Transport{
- MaxIdleConns: common.RelayMaxIdleConns,
- MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
- IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
- ForceAttemptHTTP2: true,
- Proxy: http.ProxyURL(parsedURL),
- }
- if common.TLSInsecureSkipVerify {
- transport.TLSClientConfig = common.InsecureTLSConfig
- }
- client := &http.Client{
- Transport: transport,
- CheckRedirect: checkRedirect,
- }
- client.Timeout = time.Duration(common.RelayTimeout) * time.Second
+ transport := common.NewOutboundHTTPTransport(http.ProxyURL(parsedURL), nil)
+ client := newOutboundHTTPClient(transport, checkRedirect)
proxyClientLock.Lock()
proxyClients[proxyURL] = client
proxyClientLock.Unlock()
@@ -183,21 +177,14 @@ func NewProxyHttpClient(proxyURL string) (*http.Client, error) {
return nil, err
}
- transport := &http.Transport{
- MaxIdleConns: common.RelayMaxIdleConns,
- MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
- IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
- ForceAttemptHTTP2: true,
- DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
- return dialer.Dial(network, addr)
- },
- }
- if common.TLSInsecureSkipVerify {
- transport.TLSClientConfig = common.InsecureTLSConfig
+ dialContext := func(ctx context.Context, network, addr string) (net.Conn, error) {
+ if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
+ return contextDialer.DialContext(ctx, network, addr)
+ }
+ return dialer.Dial(network, addr)
}
-
- client := &http.Client{Transport: transport, CheckRedirect: checkRedirect}
- client.Timeout = time.Duration(common.RelayTimeout) * time.Second
+ transport := common.NewOutboundHTTPTransport(nil, dialContext)
+ client := newOutboundHTTPClient(transport, checkRedirect)
proxyClientLock.Lock()
proxyClients[proxyURL] = client
proxyClientLock.Unlock()
diff --git a/service/protected_fetch_client.go b/service/protected_fetch_client.go
index 9d1d4cc87871..50b7dd4be97b 100644
--- a/service/protected_fetch_client.go
+++ b/service/protected_fetch_client.go
@@ -69,7 +69,7 @@ func newProtectedFetchHTTPClientWithProxy(resolver ssrfResolver, dialContext fun
}
if dialContext == nil {
netDialer := &net.Dialer{
- Timeout: 30 * time.Second,
+ Timeout: time.Duration(common.RelayDialTimeout) * time.Second,
KeepAlive: 30 * time.Second,
}
dialContext = netDialer.DialContext
@@ -153,18 +153,7 @@ func (t *ssrfProtectedRoundTripper) newTransport(proxyURL *url.URL) *http.Transp
proxyFunc = nil
}
- transport := &http.Transport{
- MaxIdleConns: common.RelayMaxIdleConns,
- MaxIdleConnsPerHost: common.RelayMaxIdleConnsPerHost,
- IdleConnTimeout: time.Duration(common.RelayIdleConnTimeout) * time.Second,
- ForceAttemptHTTP2: true,
- Proxy: proxyFunc,
- DialContext: dialContext,
- }
- if common.TLSInsecureSkipVerify {
- transport.TLSClientConfig = common.InsecureTLSConfig
- }
- return transport
+ return common.NewOutboundHTTPTransport(proxyFunc, dialContext)
}
func (d *protectedFetchDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
diff --git a/service/system_task.go b/service/system_task.go
index b7182aef1f31..3a113d593a9c 100644
--- a/service/system_task.go
+++ b/service/system_task.go
@@ -104,7 +104,9 @@ type LogCleanupResult struct {
}
var (
- systemTaskRunnerOnce sync.Once
+ systemTaskWorkerOnce sync.Once
+ systemTaskSchedulerOnce sync.Once
+ systemTaskWG sync.WaitGroup
// systemTaskWakeup signals the runner to check for runnable tasks
// immediately instead of waiting for the idle poll. Buffered so a signal
// raised while the runner is busy is not lost and is handled on the next loop.
@@ -121,50 +123,122 @@ func notifySystemTaskRunner() {
}
func StartSystemTaskRunner() {
- systemTaskRunnerOnce.Do(func() {
+ StartSystemTaskRunnerContext(context.Background())
+}
+
+func StartSystemTaskRunnerContext(ctx context.Context) {
+ StartSystemTaskSchedulerContext(ctx)
+ StartSystemTaskWorkerContext(ctx)
+}
+
+func StartSystemTaskWorker() {
+ StartSystemTaskWorkerContext(context.Background())
+}
+
+func StartSystemTaskWorkerContext(ctx context.Context) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ systemTaskWorkerOnce.Do(func() {
if !common.IsMasterNode {
return
}
runnerID := fmt.Sprintf("%s-%s", common.NodeName, common.GetRandomString(8))
+ systemTaskWG.Add(1)
gopool.Go(func() {
- logger.LogInfo(context.Background(), fmt.Sprintf("system task runner started: runner=%s idle_interval=%s", runnerID, systemTaskRunnerIdleInterval))
-
- ticker := time.NewTicker(systemTaskRunnerIdleInterval)
- defer ticker.Stop()
-
- var lastScheduler time.Time
- var lastStaleLockCleanup time.Time
- runPass := func() {
- // The scheduler/stale-lock pass is throttled independently of the
- // claim pass: wakeups (e.g. a manual log cleanup) should claim
- // immediately without re-running the scheduler every time.
- now := time.Now()
- if now.Sub(lastStaleLockCleanup) >= systemTaskStaleLockInterval {
- lastStaleLockCleanup = now
- if err := model.ExpireStaleSystemTaskLocks(common.GetTimestamp()); err != nil {
- logger.LogWarn(context.Background(), fmt.Sprintf("system task stale lock cleanup failed: %v", err))
- }
- }
- if now.Sub(lastScheduler) >= systemTaskSchedulerInterval {
- lastScheduler = now
- runSystemTaskScheduler()
- }
- runSystemTaskClaimPass(runnerID)
- }
+ defer systemTaskWG.Done()
+ runSystemTaskWorkerLoop(ctx, runnerID)
+ })
+ })
+}
- runPass()
- for {
- select {
- case <-ticker.C:
- case <-systemTaskWakeup:
- }
- runPass()
- }
+func StartSystemTaskScheduler() {
+ StartSystemTaskSchedulerContext(context.Background())
+}
+
+func StartSystemTaskSchedulerContext(ctx context.Context) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ systemTaskSchedulerOnce.Do(func() {
+ if !common.IsMasterNode {
+ return
+ }
+ systemTaskWG.Add(1)
+ gopool.Go(func() {
+ defer systemTaskWG.Done()
+ runSystemTaskSchedulerLoop(ctx)
})
})
}
+func runSystemTaskWorkerLoop(ctx context.Context, runnerID string) {
+ logger.LogInfo(ctx, fmt.Sprintf("system task runner started: runner=%s idle_interval=%s", runnerID, systemTaskRunnerIdleInterval))
+ ticker := time.NewTicker(systemTaskRunnerIdleInterval)
+ defer ticker.Stop()
+
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ runSystemTaskClaimPassContext(ctx, runnerID)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ case <-systemTaskWakeup:
+ }
+ runSystemTaskClaimPassContext(ctx, runnerID)
+ }
+}
+
+func runSystemTaskSchedulerLoop(ctx context.Context) {
+ logger.LogInfo(ctx, fmt.Sprintf("system task scheduler started: interval=%s", systemTaskSchedulerInterval))
+ ticker := time.NewTicker(systemTaskSchedulerInterval)
+ defer ticker.Stop()
+ runPass := func() {
+ if err := model.ExpireStaleSystemTaskLocks(common.GetTimestamp()); err != nil {
+ logger.LogWarn(ctx, fmt.Sprintf("system task stale lock cleanup failed: %v", err))
+ }
+ runSystemTaskScheduler()
+ }
+
+ select {
+ case <-ctx.Done():
+ return
+ default:
+ }
+ runPass()
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-ticker.C:
+ runPass()
+ }
+ }
+}
+
+func WaitForSystemTasks(ctx context.Context) error {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ done := make(chan struct{})
+ go func() {
+ systemTaskWG.Wait()
+ close(done)
+ }()
+ select {
+ case <-done:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
func StartLogCleanupTask(targetTimestamp int64) (*model.SystemTask, error) {
if targetTimestamp <= 0 {
return nil, errors.New("target timestamp is required")
@@ -223,6 +297,16 @@ func EnqueueSystemTask(taskType string, payload any) (*model.SystemTask, bool, e
// and dispatches each claimed task in its own goroutine so a long-running
// handler (e.g. channel test) never blocks another type (e.g. log cleanup).
func runSystemTaskClaimPass(runnerID string) {
+ runSystemTaskClaimPassContext(context.Background(), runnerID)
+}
+
+func runSystemTaskClaimPassContext(ctx context.Context, runnerID string) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if ctx.Err() != nil {
+ return
+ }
handlers := registeredSystemTaskHandlers()
taskTypes := make([]string, 0, len(handlers))
for _, handler := range handlers {
@@ -234,6 +318,9 @@ func runSystemTaskClaimPass(runnerID string) {
return
}
for _, handler := range handlers {
+ if ctx.Err() != nil {
+ return
+ }
task := pendingTasks[handler.Type()]
if task == nil {
continue
@@ -248,9 +335,11 @@ func runSystemTaskClaimPass(runnerID string) {
}
dispatchHandler := handler
dispatchTask := claimedTask
+ systemTaskWG.Add(1)
gopool.Go(func() {
- runWithLeaseHeartbeat(dispatchTask, runnerID, func(ctx context.Context) {
- dispatchHandler.Run(ctx, dispatchTask, runnerID)
+ defer systemTaskWG.Done()
+ runWithLeaseHeartbeat(ctx, dispatchTask, runnerID, func(handlerCtx context.Context) {
+ dispatchHandler.Run(handlerCtx, dispatchTask, runnerID)
})
})
}
@@ -305,8 +394,11 @@ func runSystemTaskScheduler() {
// runWithLeaseHeartbeat renews the per-type lock on a background ticker while
// fn runs. The TTL is a crash-detection window, not a task time limit: an
// arbitrarily long handler stays alive as long as the heartbeat succeeds.
-func runWithLeaseHeartbeat(task *model.SystemTask, runnerID string, fn func(ctx context.Context)) {
- ctx, cancel := context.WithCancel(context.Background())
+func runWithLeaseHeartbeat(parent context.Context, task *model.SystemTask, runnerID string, fn func(ctx context.Context)) {
+ if parent == nil {
+ parent = context.Background()
+ }
+ ctx, cancel := context.WithCancel(parent)
defer cancel()
interval := systemTaskLockTTL / 3
diff --git a/service/system_task_test.go b/service/system_task_test.go
index baaf9142eb11..3ce92fe2fd28 100644
--- a/service/system_task_test.go
+++ b/service/system_task_test.go
@@ -107,6 +107,69 @@ func TestSystemTaskSchedulerSkipsDisabled(t *testing.T) {
assert.Equal(t, int64(0), countSystemTasks(t, handler.taskType))
}
+func TestSystemTaskLoopsStopWhenContextCanceled(t *testing.T) {
+ truncate(t)
+ withSystemTaskRegistry(t)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ workerDone := make(chan struct{})
+ schedulerDone := make(chan struct{})
+ go func() {
+ runSystemTaskWorkerLoop(ctx, "runner-cancel")
+ close(workerDone)
+ }()
+ go func() {
+ runSystemTaskSchedulerLoop(ctx)
+ close(schedulerDone)
+ }()
+
+ cancel()
+ for name, done := range map[string]<-chan struct{}{
+ "worker": workerDone,
+ "scheduler": schedulerDone,
+ } {
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatalf("%s did not stop after context cancellation", name)
+ }
+ }
+}
+
+func TestSystemTaskClaimPassContextCancelsDispatchedHandler(t *testing.T) {
+ truncate(t)
+
+ started := make(chan struct{})
+ canceled := make(chan error, 1)
+ handler := &stubScheduledHandler{
+ taskType: "test_context_cancel",
+ onRun: func(ctx context.Context, _ *model.SystemTask, _ string) {
+ close(started)
+ <-ctx.Done()
+ canceled <- ctx.Err()
+ },
+ }
+ withSystemTaskRegistry(t, handler)
+ _, err := model.CreateSystemTask(handler.taskType, nil, nil)
+ require.NoError(t, err)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ runSystemTaskClaimPassContext(ctx, "runner-cancel")
+ select {
+ case <-started:
+ case <-time.After(2 * time.Second):
+ t.Fatal("claimed task handler did not start")
+ }
+ cancel()
+
+ select {
+ case err := <-canceled:
+ assert.ErrorIs(t, err, context.Canceled)
+ case <-time.After(2 * time.Second):
+ t.Fatal("claimed task handler did not receive context cancellation")
+ }
+}
+
func TestSystemTaskClaimPassDispatchesByType(t *testing.T) {
truncate(t)
diff --git a/setting/operation_setting/channel_affinity_setting.go b/setting/operation_setting/channel_affinity_setting.go
index a925a847d6dd..fef0c7f22d6a 100644
--- a/setting/operation_setting/channel_affinity_setting.go
+++ b/setting/operation_setting/channel_affinity_setting.go
@@ -116,6 +116,23 @@ var channelAffinitySetting = ChannelAffinitySetting{
MaxEntries: 100_000,
DefaultTTLSeconds: 3600,
Rules: []ChannelAffinityRule{
+ {
+ Name: "axonhub trace sticky",
+ ModelRegex: []string{".*"},
+ PathRegex: []string{"/v1/.*"},
+ // Only client-provided traces (middleware sets affinity_trace_id).
+ KeySources: []ChannelAffinityKeySource{
+ {Type: "context_string", Key: "affinity_trace_id"},
+ {Type: "request_header", Key: "AH-Trace-Id"},
+ {Type: "request_header", Key: "X-Trace-Id"},
+ },
+ ValueRegex: "",
+ TTLSeconds: 1800,
+ SkipRetryOnFailure: false,
+ IncludeUsingGroup: true,
+ IncludeModelName: true,
+ IncludeRuleName: true,
+ },
{
Name: "codex cli trace",
ModelRegex: []string{"^gpt-.*$"},
diff --git a/setting/perf_metrics_setting/config.go b/setting/perf_metrics_setting/config.go
index fb7780e53b4c..bc887b9560be 100644
--- a/setting/perf_metrics_setting/config.go
+++ b/setting/perf_metrics_setting/config.go
@@ -13,7 +13,9 @@ var perfMetricsSetting = PerfMetricsSetting{
Enabled: true,
FlushInterval: 5,
BucketTime: "hour",
- RetentionDays: 0,
+ // Default 1 day — SQLite probe traffic grows quickly; ops can raise via UI.
+ // 0 still means "keep forever" when explicitly set.
+ RetentionDays: 1,
}
func init() {
diff --git a/setting/ratio_setting/group_ratio.go b/setting/ratio_setting/group_ratio.go
index 7d16d9283932..5e06542e4119 100644
--- a/setting/ratio_setting/group_ratio.go
+++ b/setting/ratio_setting/group_ratio.go
@@ -1,8 +1,8 @@
package ratio_setting
import (
- "encoding/json"
"errors"
+ "strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/setting/config"
@@ -73,7 +73,37 @@ func GroupRatio2JSONString() string {
}
func UpdateGroupRatioByJSONString(jsonStr string) error {
- return types.LoadFromJsonString(groupRatioMap, jsonStr)
+ // Empty object would wipe defaults and break pricing + perf-metrics group
+ // filters (summary only returns groups present in this map). Keep defaults.
+ trimmed := strings.TrimSpace(jsonStr)
+ if trimmed == "" || trimmed == "{}" || trimmed == "null" {
+ groupRatioMap.Clear()
+ groupRatioMap.AddAll(defaultGroupRatio)
+ return nil
+ }
+ tmp := make(map[string]float64)
+ if err := common.Unmarshal([]byte(trimmed), &tmp); err != nil {
+ return err
+ }
+ if len(tmp) == 0 {
+ groupRatioMap.Clear()
+ groupRatioMap.AddAll(defaultGroupRatio)
+ return nil
+ }
+ // Reject negative ratios before load (same rule as CheckGroupRatio).
+ for name, ratio := range tmp {
+ if ratio < 0 {
+ return errors.New("group ratio must be not less than 0: " + name)
+ }
+ }
+ if err := types.LoadFromJsonString(groupRatioMap, trimmed); err != nil {
+ return err
+ }
+ // Always keep a usable default group so pricing/perf never filter to empty.
+ if _, ok := groupRatioMap.Get("default"); !ok {
+ groupRatioMap.Set("default", 1)
+ }
+ return nil
}
func GetGroupRatio(name string) float64 {
@@ -102,12 +132,36 @@ func GroupGroupRatio2JSONString() string {
}
func UpdateGroupGroupRatioByJSONString(jsonStr string) error {
- return types.LoadFromJsonString(groupGroupRatioMap, jsonStr)
+ // Empty / null should not wipe nested group-group overrides to a broken state.
+ // Unlike GroupRatio, empty here means "no nested overrides" (valid), but still
+ // reject null-ish wipe of malformed payloads and negative ratios.
+ trimmed := strings.TrimSpace(jsonStr)
+ if trimmed == "" || trimmed == "null" {
+ groupGroupRatioMap.Clear()
+ return nil
+ }
+ tmp := make(map[string]map[string]float64)
+ if err := common.Unmarshal([]byte(trimmed), &tmp); err != nil {
+ return err
+ }
+ for userGroup, nested := range tmp {
+ for usingGroup, ratio := range nested {
+ if ratio < 0 {
+ return errors.New("group_group_ratio must be not less than 0: " + userGroup + " -> " + usingGroup)
+ }
+ }
+ }
+ return types.LoadFromJsonString(groupGroupRatioMap, trimmed)
}
func CheckGroupRatio(jsonStr string) error {
+ trimmed := strings.TrimSpace(jsonStr)
+ if trimmed == "" || trimmed == "{}" || trimmed == "null" {
+ // Empty is accepted; UpdateGroupRatioByJSONString restores defaults.
+ return nil
+ }
checkGroupRatio := make(map[string]float64)
- err := json.Unmarshal([]byte(jsonStr), &checkGroupRatio)
+ err := common.Unmarshal([]byte(trimmed), &checkGroupRatio)
if err != nil {
return err
}
@@ -118,3 +172,23 @@ func CheckGroupRatio(jsonStr string) error {
}
return nil
}
+
+// CheckGroupGroupRatio validates nested user→using group ratio maps.
+func CheckGroupGroupRatio(jsonStr string) error {
+ trimmed := strings.TrimSpace(jsonStr)
+ if trimmed == "" || trimmed == "{}" || trimmed == "null" {
+ return nil
+ }
+ check := make(map[string]map[string]float64)
+ if err := common.Unmarshal([]byte(trimmed), &check); err != nil {
+ return err
+ }
+ for userGroup, nested := range check {
+ for usingGroup, ratio := range nested {
+ if ratio < 0 {
+ return errors.New("group_group_ratio must be not less than 0: " + userGroup + " -> " + usingGroup)
+ }
+ }
+ }
+ return nil
+}
diff --git a/setting/ratio_setting/group_ratio_empty_test.go b/setting/ratio_setting/group_ratio_empty_test.go
new file mode 100644
index 000000000000..578b544f28d0
--- /dev/null
+++ b/setting/ratio_setting/group_ratio_empty_test.go
@@ -0,0 +1,84 @@
+package ratio_setting
+
+import (
+ "testing"
+)
+
+func TestUpdateGroupRatioByJSONStringKeepsDefaultsOnEmpty(t *testing.T) {
+ // Ensure defaults are present first.
+ if err := UpdateGroupRatioByJSONString(`{"default":1,"vip":1,"svip":1}`); err != nil {
+ t.Fatalf("seed defaults: %v", err)
+ }
+ if err := UpdateGroupRatioByJSONString(`{}`); err != nil {
+ t.Fatalf("empty update: %v", err)
+ }
+ got := GetGroupRatioCopy()
+ if len(got) == 0 {
+ t.Fatalf("expected defaults after empty update, got empty map")
+ }
+ if _, ok := got["default"]; !ok {
+ t.Fatalf("expected default group after empty update, got %#v", got)
+ }
+}
+
+func TestUpdateGroupRatioByJSONStringAcceptsCustom(t *testing.T) {
+ if err := UpdateGroupRatioByJSONString(`{"default":1,"pro":1.2}`); err != nil {
+ t.Fatalf("custom update: %v", err)
+ }
+ got := GetGroupRatioCopy()
+ if got["pro"] != 1.2 {
+ t.Fatalf("expected pro=1.2, got %#v", got)
+ }
+ if _, ok := got["default"]; !ok {
+ t.Fatalf("expected default retained/set, got %#v", got)
+ }
+}
+
+func TestUpdateGroupRatioInjectsDefaultWhenMissing(t *testing.T) {
+ if err := UpdateGroupRatioByJSONString(`{"pro":1.5}`); err != nil {
+ t.Fatalf("update without default: %v", err)
+ }
+ got := GetGroupRatioCopy()
+ if _, ok := got["default"]; !ok {
+ t.Fatalf("expected default injected, got %#v", got)
+ }
+ if got["pro"] != 1.5 {
+ t.Fatalf("pro lost: %#v", got)
+ }
+}
+
+func TestUpdateGroupRatioRejectsNegative(t *testing.T) {
+ if err := UpdateGroupRatioByJSONString(`{"default":-1}`); err == nil {
+ t.Fatal("expected error for negative ratio")
+ }
+}
+
+func TestUpdateGroupGroupRatioEmptyClears(t *testing.T) {
+ if err := UpdateGroupGroupRatioByJSONString(`{"vip":{"default":0.9}}`); err != nil {
+ t.Fatalf("seed nested: %v", err)
+ }
+ if err := UpdateGroupGroupRatioByJSONString(`{}`); err != nil {
+ t.Fatalf("empty nested: %v", err)
+ }
+ if r, ok := GetGroupGroupRatio("vip", "default"); ok {
+ t.Fatalf("expected cleared nested map, still got %v", r)
+ }
+}
+
+func TestUpdateGroupGroupRatioRejectsNegative(t *testing.T) {
+ if err := UpdateGroupGroupRatioByJSONString(`{"vip":{"default":-0.1}}`); err == nil {
+ t.Fatal("expected negative nested ratio error")
+ }
+}
+
+func TestCheckGroupRatioAllowsEmpty(t *testing.T) {
+ if err := CheckGroupRatio(`{}`); err != nil {
+ t.Fatalf("empty should pass check: %v", err)
+ }
+ if err := CheckGroupRatio(`{"default":1}`); err != nil {
+ t.Fatalf("valid should pass: %v", err)
+ }
+ if err := CheckGroupRatio(`{"default":-2}`); err == nil {
+ t.Fatal("negative should fail check")
+ }
+}
diff --git a/setting/system_setting/theme.go b/setting/system_setting/theme.go
index 44dfc142941d..2848ed10f327 100644
--- a/setting/system_setting/theme.go
+++ b/setting/system_setting/theme.go
@@ -10,7 +10,7 @@ type ThemeSettings struct {
}
var themeSettings = ThemeSettings{
- Frontend: "classic",
+ Frontend: "default",
}
func init() {
diff --git a/setting/user_usable_group.go b/setting/user_usable_group.go
index eb04b7f30534..24f85da178a1 100644
--- a/setting/user_usable_group.go
+++ b/setting/user_usable_group.go
@@ -1,7 +1,7 @@
package setting
import (
- "encoding/json"
+ "strings"
"sync"
"github.com/QuantumNous/new-api/common"
@@ -28,7 +28,7 @@ func UserUsableGroups2JSONString() string {
userUsableGroupsMutex.RLock()
defer userUsableGroupsMutex.RUnlock()
- jsonBytes, err := json.Marshal(userUsableGroups)
+ jsonBytes, err := common.Marshal(userUsableGroups)
if err != nil {
common.SysLog("error marshalling user groups: " + err.Error())
}
@@ -39,8 +39,28 @@ func UpdateUserUsableGroupsByJSONString(jsonStr string) error {
userUsableGroupsMutex.Lock()
defer userUsableGroupsMutex.Unlock()
- userUsableGroups = make(map[string]string)
- return json.Unmarshal([]byte(jsonStr), &userUsableGroups)
+ // Empty object wipes defaults and empties the pricing page (filter by usable groups).
+ trimmed := strings.TrimSpace(jsonStr)
+ if trimmed == "" || trimmed == "{}" || trimmed == "null" {
+ userUsableGroups = map[string]string{
+ "default": "默认分组",
+ "vip": "vip分组",
+ }
+ return nil
+ }
+ tmp := make(map[string]string)
+ if err := common.Unmarshal([]byte(trimmed), &tmp); err != nil {
+ return err
+ }
+ if len(tmp) == 0 {
+ userUsableGroups = map[string]string{
+ "default": "默认分组",
+ "vip": "vip分组",
+ }
+ return nil
+ }
+ userUsableGroups = tmp
+ return nil
}
func GetUsableGroupDescription(groupName string) string {
diff --git a/setting/user_usable_group_empty_test.go b/setting/user_usable_group_empty_test.go
new file mode 100644
index 000000000000..e0b7149043da
--- /dev/null
+++ b/setting/user_usable_group_empty_test.go
@@ -0,0 +1,16 @@
+package setting
+
+import "testing"
+
+func TestUpdateUserUsableGroupsByJSONStringKeepsDefaultsOnEmpty(t *testing.T) {
+ if err := UpdateUserUsableGroupsByJSONString(`{"default":"默认分组","vip":"vip分组"}`); err != nil {
+ t.Fatalf("seed: %v", err)
+ }
+ if err := UpdateUserUsableGroupsByJSONString(`{}`); err != nil {
+ t.Fatalf("empty: %v", err)
+ }
+ got := GetUserUsableGroupsCopy()
+ if _, ok := got["default"]; !ok {
+ t.Fatalf("expected default usable group, got %#v", got)
+ }
+}
diff --git a/trusted_proxy.go b/trusted_proxy.go
new file mode 100644
index 000000000000..641fb9551170
--- /dev/null
+++ b/trusted_proxy.go
@@ -0,0 +1,27 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+func configureTrustedProxies(engine *gin.Engine) error {
+ raw := strings.TrimSpace(os.Getenv("TRUSTED_PROXY_CIDRS"))
+ if raw == "" {
+ return engine.SetTrustedProxies(nil)
+ }
+
+ items := strings.Split(raw, ",")
+ trustedProxies := make([]string, 0, len(items))
+ for _, item := range items {
+ proxy := strings.TrimSpace(item)
+ if proxy == "" {
+ return fmt.Errorf("TRUSTED_PROXY_CIDRS contains an empty entry")
+ }
+ trustedProxies = append(trustedProxies, proxy)
+ }
+ return engine.SetTrustedProxies(trustedProxies)
+}
diff --git a/trusted_proxy_test.go b/trusted_proxy_test.go
new file mode 100644
index 000000000000..824fead671c1
--- /dev/null
+++ b/trusted_proxy_test.go
@@ -0,0 +1,44 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+)
+
+func TestConfigureTrustedProxiesDisablesForwardedHeadersByDefault(t *testing.T) {
+ t.Setenv("TRUSTED_PROXY_CIDRS", "")
+ engine := gin.New()
+ require.NoError(t, configureTrustedProxies(engine))
+
+ require.Equal(t, "203.0.113.10", requestClientIP(engine, "203.0.113.10:4321", "198.51.100.20"))
+}
+
+func TestConfigureTrustedProxiesUsesConfiguredCIDRs(t *testing.T) {
+ t.Setenv("TRUSTED_PROXY_CIDRS", "127.0.0.1/32, ::1/128")
+ engine := gin.New()
+ require.NoError(t, configureTrustedProxies(engine))
+
+ require.Equal(t, "198.51.100.20", requestClientIP(engine, "127.0.0.1:4321", "198.51.100.20"))
+}
+
+func TestConfigureTrustedProxiesRejectsInvalidCIDR(t *testing.T) {
+ t.Setenv("TRUSTED_PROXY_CIDRS", "not-a-cidr")
+ require.Error(t, configureTrustedProxies(gin.New()))
+}
+
+func requestClientIP(engine *gin.Engine, remoteAddr string, forwardedFor string) string {
+ var clientIP string
+ engine.GET("/", func(c *gin.Context) {
+ clientIP = c.ClientIP()
+ c.Status(http.StatusNoContent)
+ })
+ request := httptest.NewRequest(http.MethodGet, "/", nil)
+ request.RemoteAddr = remoteAddr
+ request.Header.Set("X-Forwarded-For", forwardedFor)
+ engine.ServeHTTP(httptest.NewRecorder(), request)
+ return clientIP
+}
diff --git a/web/bun.lock b/web/bun.lock
index d86f3a8b3ffe..469bbc01518f 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -19,6 +19,7 @@
"axios": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
+ "dompurify": "3.4.11",
"highlight.js": "^11.11.1",
"history": "^5.3.0",
"i18next": "^23.16.8",
@@ -57,6 +58,7 @@
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-react-hooks": "^5.2.0",
"i18next-cli": "^1.10.3",
+ "jsdom": "^26.1.0",
"postcss": "^8.5.3",
"prettier": "catalog:",
"prop-types": "^15.8.1",
@@ -123,6 +125,7 @@
"tw-animate-css": "^1.4.0",
"use-stick-to-bottom": "^1.1.6",
"vaul": "^1.1.2",
+ "web-vitals": "^5.1.0",
"zod": "^4.4.3",
"zustand": "^5.0.14",
},
@@ -146,6 +149,12 @@
},
},
},
+ "overrides": {
+ "dompurify": "3.4.11",
+ "form-data": "4.0.6",
+ "hono": "4.12.25",
+ "minimist": "1.2.8",
+ },
"catalog": {
"@lobehub/icons": "^5.10.1",
"@rsbuild/core": "^2.1.4",
@@ -190,6 +199,8 @@
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
+ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
+
"@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
@@ -280,6 +291,16 @@
"@croct/json5-parser": ["@croct/json5-parser@0.2.2", "", { "dependencies": { "@croct/json": "^2.1.0" } }, "sha512-0NJMLrbeLbQ0eCVj3UoH/kG2QckUgOASfwmfDTjyW1xAYPyTNJXcWVT/dssJdTJd0pRchW+qF0VFWQHcxs1OVw=="],
+ "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
+
+ "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
+
+ "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="],
+
+ "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
+
+ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
+
"@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
@@ -1436,6 +1457,8 @@
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
+ "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
+
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"cytoscape": ["cytoscape@3.33.4", "", {}, "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww=="],
@@ -1512,6 +1535,8 @@
"dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
+ "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
+
"date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
"date-fns-tz": ["date-fns-tz@1.3.8", "", { "peerDependencies": { "date-fns": ">=2.0.0" } }, "sha512-qwNXUFtMHTTU6CFSFjoJ80W8Fzzp24LntbjFFBgL/faqds4e5mo9mftoRLgr3Vi1trISsg4awSpYVsOQCRnapQ=="],
@@ -1520,6 +1545,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
@@ -1720,7 +1747,7 @@
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
- "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
+ "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
"formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="],
@@ -1832,7 +1859,9 @@
"hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="],
- "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
+ "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="],
+
+ "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
@@ -1842,6 +1871,8 @@
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
+ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
+
"https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="],
@@ -1854,7 +1885,7 @@
"i18next-resources-for-ts": ["i18next-resources-for-ts@2.1.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@swc/core": "^1.15.18", "chokidar": "^5.0.0", "yaml": "^2.8.2" }, "bin": { "i18next-resources-for-ts": "bin/i18next-resources-for-ts.js" } }, "sha512-n5UexwEVt0OoIAhG2MWpSnAVJW1U8mQrQTmXyxc5DMAx+NLhcLZhSMJo/FnUsA5JQ3obTYqTgB7YIuZKWpDgow=="],
- "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+ "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
@@ -1926,6 +1957,8 @@
"is-plain-object": ["is-plain-object@2.0.4", "", { "dependencies": { "isobject": "^3.0.1" } }, "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og=="],
+ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
+
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="],
@@ -1956,6 +1989,8 @@
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
+ "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="],
+
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -2258,6 +2293,8 @@
"numeral": ["numeral@2.0.6", "", {}, "sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA=="],
+ "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="],
+
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
@@ -2616,6 +2653,8 @@
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
+ "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
+
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
"run-async": ["run-async@4.0.6", "", {}, "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ=="],
@@ -2634,6 +2673,8 @@
"sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="],
+ "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"screenfull": ["screenfull@5.2.0", "", {}, "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="],
@@ -2748,6 +2789,8 @@
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
+ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
@@ -2774,6 +2817,10 @@
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
+ "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
+
+ "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
+
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"to-vfile": ["to-vfile@8.0.0", "", { "dependencies": { "vfile": "^6.0.0" } }, "sha512-IcmH1xB5576MJc9qcfEC/m/nQCFt3fzMHz45sSlgJyTWjRbKW1HAkJpuf3DgE57YzIlZcwcBZA5ENQbBo4aLkg=="],
@@ -2786,6 +2833,10 @@
"topojson-server": ["topojson-server@3.0.1", "", { "dependencies": { "commander": "2" }, "bin": { "geo2topo": "bin/geo2topo" } }, "sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw=="],
+ "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
+
+ "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
+
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
@@ -2894,20 +2945,38 @@
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
+ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
+
"walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="],
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
+ "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="],
+
+ "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
+
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
+ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
+
+ "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
+
+ "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
+
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+ "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
+
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
+ "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
+
+ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
+
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -2926,6 +2995,8 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+ "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+
"@codemirror/autocomplete/@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="],
"@codemirror/lang-html/@codemirror/view": ["@codemirror/view@6.43.3", "", { "dependencies": { "@codemirror/state": "^6.7.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-MwEwCAr/o0agJefhC2+reBv5kfOQpMcDRUNQrRYZgWlhH8IwQcerMZrpqWyUFSyO0ebgN2cnh/w87F7G4BGSng=="],
@@ -2964,6 +3035,8 @@
"@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
+ "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+
"@lobehub/fluent-emoji/lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"@lobehub/icons/lucide-react": ["lucide-react@0.469.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw=="],
@@ -3114,6 +3187,8 @@
"babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
+ "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+
"cosmiconfig/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -3146,18 +3221,20 @@
"geojson-dissolve/@turf/meta": ["@turf/meta@3.14.0", "", {}, "sha512-OtXqLQuR9hlQ/HkAF/OdzRea7E0eZK1ay8y8CBXkoO2R6v34CsDrWYLMSo0ZzMsaQDpKo76NPP2GGo+PyG1cSg=="],
- "geojson-flatten/minimist": ["minimist@1.2.0", "", {}, "sha512-7Wl+Jz+IGWuSdgsQEJ4JunV0si/iMhg42MnQQG6h1R6TNeVenp4U9x5CC5v/gYqz/fENLQITAWXidNtVL0NNbw=="],
-
"glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+ "http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
"i18next-cli/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"i18next-cli/ora": ["ora@9.4.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ=="],
"i18next-cli/react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
+ "jsdom/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"leva/react-dropzone": ["react-dropzone@12.1.0", "", { "dependencies": { "attr-accept": "^2.2.2", "file-selector": "^0.5.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8" } }, "sha512-iBYHA1rbopIvtzokEX4QubO6qk5IF/x3BtKGu74rF2JkQDXnwC4uO/lHKpaw4PJIV6iIAYOlwLv2FpiGyqHNog=="],
@@ -3170,8 +3247,6 @@
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
- "mermaid/dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="],
-
"mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
@@ -3200,6 +3275,8 @@
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+ "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+
"rc-menu/@rc-component/trigger": ["@rc-component/trigger@2.3.1", "", { "dependencies": { "@babel/runtime": "^7.23.2", "@rc-component/portal": "^1.1.0", "classnames": "^2.3.2", "rc-motion": "^2.0.0", "rc-resize-observer": "^1.3.1", "rc-util": "^5.44.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A=="],
"react-i18next/typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
@@ -3246,8 +3323,6 @@
"simplify-geojson/concat-stream": ["concat-stream@1.4.11", "", { "dependencies": { "inherits": "~2.0.1", "readable-stream": "~1.1.9", "typedarray": "~0.0.5" } }, "sha512-X3JMh8+4je3U1cQpG87+f9lXHDrqcb2MVLg9L7o8b1UZ0DzhRrUpdn65ttzu10PpJPPI3MQNkis+oha6TSA9Mw=="],
- "simplify-geojson/minimist": ["minimist@1.2.6", "", {}, "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="],
-
"split-string/extend-shallow": ["extend-shallow@3.0.2", "", { "dependencies": { "assign-symbols": "^1.0.0", "is-extendable": "^1.0.1" } }, "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q=="],
"string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -3388,16 +3463,12 @@
"d3-fetch/d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
- "d3-fetch/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
-
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"d3/d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
- "d3/d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
-
"express/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"glob/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
@@ -3414,6 +3485,8 @@
"i18next-cli/ora/string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="],
+ "jsdom/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
"leva/react-dropzone/file-selector": ["file-selector@0.5.0", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-s8KNnmIDTBoD0p9uJ9uD0XY38SCeBOtj0UMXyQSLg1Ypfrfj8+dAvwsLjYQkQ2GjhVtp2HrnF5cJzMhBjfD8HA=="],
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
diff --git a/web/classic/bundle-budget.json b/web/classic/bundle-budget.json
new file mode 100644
index 000000000000..ffac7ff5bb7e
--- /dev/null
+++ b/web/classic/bundle-budget.json
@@ -0,0 +1,5 @@
+{
+ "entryGzipKiB": 1100,
+ "maxJsGzipKiB": 2150,
+ "totalJsGzipKiB": 5200
+}
diff --git a/web/classic/package.json b/web/classic/package.json
index ecc6971280bf..ba9f6389638f 100644
--- a/web/classic/package.json
+++ b/web/classic/package.json
@@ -14,6 +14,7 @@
"axios": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
+ "dompurify": "3.4.11",
"history": "^5.3.0",
"highlight.js": "^11.11.1",
"i18next": "^23.16.8",
@@ -45,7 +46,9 @@
},
"scripts": {
"dev": "rsbuild dev",
+ "test": "bun test",
"build": "rsbuild build",
+ "bundle:check": "node ../scripts/check-bundle-budget.mjs bundle-budget.json",
"lint": "prettier . --check",
"lint:fix": "prettier . --write",
"eslint": "bunx eslint \"**/*.{js,jsx}\" --cache",
@@ -83,6 +86,7 @@
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-react-hooks": "^5.2.0",
"i18next-cli": "^1.10.3",
+ "jsdom": "^26.1.0",
"postcss": "^8.5.3",
"prop-types": "^15.8.1",
"prettier": "catalog:",
diff --git a/web/classic/rsbuild.config.ts b/web/classic/rsbuild.config.ts
index 3ccf1e96df8e..b45cb33034f9 100644
--- a/web/classic/rsbuild.config.ts
+++ b/web/classic/rsbuild.config.ts
@@ -10,6 +10,7 @@ const semiUiDir = path.resolve(
path.dirname(require.resolve('@douyinfe/semi-ui')),
'../..',
)
+const semiDateFnsDir = path.resolve(semiUiDir, 'node_modules/date-fns')
export default defineConfig(({ envMode }) => {
const env = loadEnv({ mode: envMode, prefixes: ['VITE_'] })
@@ -43,6 +44,8 @@ export default defineConfig(({ envMode }) => {
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
+ // date-fns-tz 1.x imports date-fns v2 internals; the workspace root uses v4.
+ 'date-fns': semiDateFnsDir,
'@douyinfe/semi-ui/dist/css/semi.css': path.resolve(
semiUiDir,
'dist/css/semi.css',
diff --git a/web/classic/src/components/common/DocumentRenderer/index.jsx b/web/classic/src/components/common/DocumentRenderer/index.jsx
index 3325b2feb8ae..670692aef51e 100644
--- a/web/classic/src/components/common/DocumentRenderer/index.jsx
+++ b/web/classic/src/components/common/DocumentRenderer/index.jsx
@@ -27,6 +27,7 @@ import {
} from '@douyinfe/semi-illustrations';
import { useTranslation } from 'react-i18next';
import MarkdownRenderer from '../markdown/MarkdownRenderer';
+import { sanitizeHtml as sanitizeDocumentHtml } from '../../../helpers/sanitizeHtml';
// Check whether content is a URL.
const isUrl = (content) => {
@@ -46,19 +47,9 @@ const isHtmlContent = (content) => {
return htmlTagRegex.test(content);
};
-// Parse HTML content and extract inline styles.
+// Keep the existing payload shape while dropping executable markup and CSS.
const sanitizeHtml = (html) => {
- const tempDiv = document.createElement('div');
- tempDiv.innerHTML = html;
-
- const styles = Array.from(tempDiv.querySelectorAll('style'))
- .map((style) => style.innerHTML)
- .join('\n');
-
- const bodyContent = tempDiv.querySelector('body');
- const content = bodyContent ? bodyContent.innerHTML : html;
-
- return { content, styles };
+ return { content: sanitizeDocumentHtml(html), styles: '' };
};
/**
diff --git a/web/classic/src/components/dashboard/AnnouncementsPanel.jsx b/web/classic/src/components/dashboard/AnnouncementsPanel.jsx
index c62850b3b8fa..e54b304e6323 100644
--- a/web/classic/src/components/dashboard/AnnouncementsPanel.jsx
+++ b/web/classic/src/components/dashboard/AnnouncementsPanel.jsx
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
import React from 'react';
import { Card, Tag, Timeline, Empty } from '@douyinfe/semi-ui';
import { Bell } from 'lucide-react';
-import { marked } from 'marked';
+import { renderMarkdown } from '../../helpers/sanitizeHtml';
import {
IllustrationConstruction,
IllustrationConstructionDark,
@@ -80,7 +80,7 @@ const AnnouncementsPanel = ({
{announcementData.length > 0 ? (
{announcementData.map((item, idx) => {
- const htmlExtra = item.extra ? marked.parse(item.extra) : '';
+ const htmlExtra = item.extra ? renderMarkdown(item.extra) : '';
return (
diff --git a/web/classic/src/components/dashboard/FaqPanel.jsx b/web/classic/src/components/dashboard/FaqPanel.jsx
index a092abbd1089..232841b6260e 100644
--- a/web/classic/src/components/dashboard/FaqPanel.jsx
+++ b/web/classic/src/components/dashboard/FaqPanel.jsx
@@ -21,7 +21,7 @@ import React from 'react';
import { Card, Collapse, Empty } from '@douyinfe/semi-ui';
import { HelpCircle } from 'lucide-react';
import { IconPlus, IconMinus } from '@douyinfe/semi-icons';
-import { marked } from 'marked';
+import { renderMarkdown } from '../../helpers/sanitizeHtml';
import {
IllustrationConstruction,
IllustrationConstructionDark,
@@ -62,7 +62,7 @@ const FaqPanel = ({
>
diff --git a/web/classic/src/components/layout/Footer.jsx b/web/classic/src/components/layout/Footer.jsx
index 759e45ab9b05..7ae37dbfc19f 100644
--- a/web/classic/src/components/layout/Footer.jsx
+++ b/web/classic/src/components/layout/Footer.jsx
@@ -21,11 +21,12 @@ import React, { useEffect, useState, useMemo, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { Typography } from '@douyinfe/semi-ui';
import { getFooterHTML, getLogo, getSystemName } from '../../helpers';
+import { sanitizeHtml } from '../../helpers/sanitizeHtml';
import { StatusContext } from '../../context/Status';
const FooterBar = () => {
const { t } = useTranslation();
- const [footer, setFooter] = useState(getFooterHTML());
+ const [footer, setFooter] = useState(sanitizeHtml(getFooterHTML()));
const systemName = getSystemName();
const logo = getLogo();
const [statusState] = useContext(StatusContext);
@@ -34,7 +35,7 @@ const FooterBar = () => {
const loadFooter = () => {
let footer_html = localStorage.getItem('footer_html');
if (footer_html) {
- setFooter(footer_html);
+ setFooter(sanitizeHtml(footer_html));
}
};
diff --git a/web/classic/src/components/layout/NoticeModal.jsx b/web/classic/src/components/layout/NoticeModal.jsx
index c8197a58ba7b..f400063cd020 100644
--- a/web/classic/src/components/layout/NoticeModal.jsx
+++ b/web/classic/src/components/layout/NoticeModal.jsx
@@ -28,7 +28,7 @@ import {
} from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { API, showError, getRelativeTime } from '../../helpers';
-import { marked } from 'marked';
+import { renderMarkdown } from '../../helpers/sanitizeHtml';
import {
IllustrationNoContent,
IllustrationNoContentDark,
@@ -89,7 +89,7 @@ const NoticeModal = ({
const { success, message, data } = res.data;
if (success) {
if (data !== '') {
- const htmlNotice = marked.parse(data);
+ const htmlNotice = renderMarkdown(data);
setNoticeContent(htmlNotice);
} else {
setNoticeContent('');
@@ -170,8 +170,8 @@ const NoticeModal = ({
{processedAnnouncements.map((item, idx) => {
- const htmlContent = marked.parse(item.content || '');
- const htmlExtra = item.extra ? marked.parse(item.extra) : '';
+ const htmlContent = renderMarkdown(item.content || '');
+ const htmlExtra = item.extra ? renderMarkdown(item.extra) : '';
return (
{
}, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
const renderedContent = useMemo(() => {
- return linkifyHtml(highlightedContent);
+ return sanitizeHtml(linkifyHtml(highlightedContent));
}, [highlightedContent]);
const handleCopy = useCallback(async () => {
diff --git a/web/classic/src/components/settings/OtherSetting.jsx b/web/classic/src/components/settings/OtherSetting.jsx
index a9848551b590..2b04b45516db 100644
--- a/web/classic/src/components/settings/OtherSetting.jsx
+++ b/web/classic/src/components/settings/OtherSetting.jsx
@@ -35,7 +35,7 @@ import {
showSuccess,
timestamp2string,
} from '../../helpers';
-import { marked } from 'marked';
+import { renderMarkdown } from '../../helpers/sanitizeHtml';
import { useTranslation } from 'react-i18next';
import { StatusContext } from '../../context/Status';
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
@@ -271,7 +271,7 @@ const OtherSetting = () => {
} else {
setUpdateData({
tag_name: tag_name,
- content: marked.parse(body),
+ content: renderMarkdown(body),
});
setShowUpdateModal(true);
}
diff --git a/web/classic/src/helpers/sanitizeHtml.js b/web/classic/src/helpers/sanitizeHtml.js
new file mode 100644
index 000000000000..b92b758e5788
--- /dev/null
+++ b/web/classic/src/helpers/sanitizeHtml.js
@@ -0,0 +1,24 @@
+import createDOMPurify from 'dompurify';
+import { marked } from 'marked';
+
+const SANITIZE_OPTIONS = {
+ USE_PROFILES: { html: true },
+ FORBID_TAGS: ['style', 'iframe', 'object', 'embed', 'form'],
+ FORBID_ATTR: ['style', 'srcdoc', 'srcset'],
+};
+
+export function createHtmlSanitizer(windowObject) {
+ const purifier = createDOMPurify(windowObject);
+ return (dirty) => purifier.sanitize(String(dirty ?? ''), SANITIZE_OPTIONS);
+}
+
+const browserSanitizer =
+ typeof window === 'undefined' ? () => '' : createHtmlSanitizer(window);
+
+export function sanitizeHtml(dirty) {
+ return browserSanitizer(dirty);
+}
+
+export function renderMarkdown(markdown) {
+ return sanitizeHtml(marked.parse(String(markdown ?? '')));
+}
diff --git a/web/classic/src/helpers/sanitizeHtml.test.js b/web/classic/src/helpers/sanitizeHtml.test.js
new file mode 100644
index 000000000000..e9bcd89b7aba
--- /dev/null
+++ b/web/classic/src/helpers/sanitizeHtml.test.js
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'bun:test';
+import { JSDOM } from 'jsdom';
+
+import { createHtmlSanitizer } from './sanitizeHtml';
+
+const sanitizeHtml = createHtmlSanitizer(new JSDOM('').window);
+
+describe('sanitizeHtml', () => {
+ it('removes scripts, event handlers, and javascript URLs', () => {
+ const dirty = [
+ '',
+ '
',
+ 'click',
+ ].join('');
+
+ const clean = sanitizeHtml(dirty);
+
+ expect(clean).not.toContain('